From ce6907a0c9fe65e2e0c3fd9d1305c6db526676af Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 13:38:37 +0000 Subject: [PATCH 01/32] feat(tally): a proof of correct decryption anyone can check Paillier lets the key holder recover a ciphertext's randomness, so a tally can be opened: the randomness that opens the sum of the valid final ballots to exactly the published counters, and a public opening of each ballot excluded as invalid. Dependency free, so the browser, the auditor's CLI and the contract tests share one definition of a valid tally. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi --- frontend/src/lib/tallyProof.test.ts | 123 ++++++++++ frontend/src/lib/tallyProof.ts | 363 ++++++++++++++++++++++++++++ 2 files changed, 486 insertions(+) create mode 100644 frontend/src/lib/tallyProof.test.ts create mode 100644 frontend/src/lib/tallyProof.ts diff --git a/frontend/src/lib/tallyProof.test.ts b/frontend/src/lib/tallyProof.test.ts new file mode 100644 index 0000000..414570e --- /dev/null +++ b/frontend/src/lib/tallyProof.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { generateRandomKeys } from "paillier-bigint"; + +import { + decodeTallyProof, + encodeTallyProof, + finalBallots, + proveTally, + verifyTally, + type FinalBallot, +} from "./tallyProof"; + +// Small keys keep the suite fast; the arithmetic is the same at 2048 bits. +const { publicKey, privateKey } = await generateRandomKeys(512); +const pk = { n: publicKey.n, g: publicKey.g }; +const BASE = 1_000n; +const SLOTS = 4; // three options and the blank vote + +const vote = (option: number): bigint => publicKey.encrypt(BASE ** BigInt(option)); + +function prove(ballots: FinalBallot[]) { + return proveTally({ + publicKey: pk, + lambda: privateKey.lambda, + decrypt: c => privateKey.decrypt(c), + ballots, + slots: SLOTS, + base: BASE, + }); +} + +const ballots = (...ciphertexts: bigint[]): FinalBallot[] => + ciphertexts.map((ciphertext, i) => ({ nullifier: BigInt(i + 1), ciphertext })); + +describe("tally proof", () => { + it("proves an honest tally that anyone can verify without the key", () => { + const cast = ballots(vote(0), vote(1), vote(1), vote(3)); + const tally = prove(cast); + + expect(tally.counts).toEqual([1n, 2n, 0n, 1n]); + expect(tally.invalidBallots).toBe(0); + expect( + verifyTally({ publicKey: pk, ballots: cast, counts: tally.counts, invalidBallots: 0, proof: tally.proof, base: BASE }), + ).toEqual({ ok: true, validBallots: 4, invalidBallots: 0 }); + }); + + it("catches votes moved between options, which keep the total intact", () => { + const cast = ballots(vote(0), vote(1), vote(1)); + const tally = prove(cast); + const moved = [2n, 1n, 0n, 0n]; + const verdict = verifyTally({ + publicKey: pk, ballots: cast, counts: moved, invalidBallots: 0, proof: tally.proof, base: BASE, + }); + expect(verdict.ok).toBe(false); + }); + + it("excludes a stuffed ballot in public instead of counting it", () => { + // Five votes for option 0 in one ciphertext: the attack the sum alone let through. + const stuffed = publicKey.encrypt(5n); + const cast = ballots(vote(1), stuffed, vote(2)); + const tally = prove(cast); + + expect(tally.counts).toEqual([0n, 1n, 1n, 0n]); + expect(tally.invalidBallots).toBe(1); + expect(tally.proof.invalid[0].plaintext).toBe(5n); + expect( + verifyTally({ publicKey: pk, ballots: cast, counts: tally.counts, invalidBallots: 1, proof: tally.proof, base: BASE }).ok, + ).toBe(true); + }); + + it("excludes a ciphertext that is not a Paillier ciphertext at all, with no opening", () => { + const cast = ballots(vote(0), 0n, pk.n * 3n); + const tally = prove(cast); + expect(tally.invalidBallots).toBe(2); + expect(tally.proof.invalid).toHaveLength(0); + expect( + verifyTally({ publicKey: pk, ballots: cast, counts: tally.counts, invalidBallots: 2, proof: tally.proof, base: BASE }).ok, + ).toBe(true); + }); + + it("refuses to let an honest ballot be excluded", () => { + const cast = ballots(vote(0), vote(1)); + const tally = prove(cast); + const forged = { + ...tally.proof, + invalid: [{ nullifier: 2n, plaintext: 7n, randomness: 1n }], + }; + expect( + verifyTally({ publicKey: pk, ballots: cast, counts: [1n, 0n, 0n, 0n], invalidBallots: 1, proof: forged, base: BASE }).ok, + ).toBe(false); + }); + + it("refuses an excluded count that disagrees with the proof", () => { + const cast = ballots(vote(0)); + const tally = prove(cast); + expect( + verifyTally({ publicKey: pk, ballots: cast, counts: tally.counts, invalidBallots: 1, proof: tally.proof, base: BASE }).ok, + ).toBe(false); + }); + + it("verifies an election nobody voted in", () => { + const tally = prove([]); + expect(tally.counts).toEqual([0n, 0n, 0n, 0n]); + expect( + verifyTally({ publicKey: pk, ballots: [], counts: tally.counts, invalidBallots: 0, proof: tally.proof, base: BASE }).ok, + ).toBe(true); + }); + + it("counts only each voter's last ballot", () => { + const events = [ + { nullifier: 9n, nonce: 0n, ciphertext: "0x" + vote(0).toString(16) }, + { nullifier: 9n, nonce: 1n, ciphertext: "0x" + vote(2).toString(16) }, + ]; + const tally = prove(finalBallots(events)); + expect(tally.counts).toEqual([0n, 0n, 1n, 0n]); + }); + + it("round-trips through the bytes publishResults carries", () => { + const tally = prove(ballots(vote(0), publicKey.encrypt(42n))); + expect(decodeTallyProof(encodeTallyProof(tally.proof))).toEqual(tally.proof); + expect(() => decodeTallyProof("0x7b7d")).toThrow(); + }); +}); diff --git a/frontend/src/lib/tallyProof.ts b/frontend/src/lib/tallyProof.ts new file mode 100644 index 0000000..5a0343c --- /dev/null +++ b/frontend/src/lib/tallyProof.ts @@ -0,0 +1,363 @@ +/** + * The proof that a published tally is what the ballots actually hold. + * + * WHAT IT CLOSES. A Paillier tally used to rest on the organizer's word: they + * hold the key, they decrypt the sum, they publish counters, and nobody else + * can decrypt to check. Two things were missing. Anyone could see that the + * counters added up to the number of voters, but not that the votes had not + * been moved between options. And one voter could encrypt garbage, or five + * votes for one option, and either break the tally outright or stuff it. + * + * HOW. Paillier is randomness-recoverable: whoever holds the key can compute, + * from a ciphertext c and its plaintext m, the r with c = g^m · r^n (mod n²). + * Publishing (m, r) lets anyone check that equation with nothing but the public + * key, and reveals nothing about any other ciphertext. So: + * + * - every final ballot (the highest nonce per nullifier) is decrypted + * privately and classified: a valid ballot encrypts exactly B^i for one + * option i, anything else is invalid; + * - each INVALID ballot is excluded and opened in public, so excluding an + * honest ballot would require an opening that cannot exist; + * - the product of the VALID ballots is opened once, to the packed counters. + * That reveals the total per option and nothing about who chose what. + * + * Ciphertexts that are not even elements of Z*_{n²} are excluded by everyone + * alike, without any opening, since that needs no key to see. + * + * Deliberately free of dependencies: the browser, the auditor's CLI and the + * contract tests all import this one file, so the three can never disagree + * about what "verified" means. + */ + +export interface PaillierPublic { + n: bigint; + g: bigint; +} + +/** One voter's surviving ballot, as the chain recorded it. */ +export interface FinalBallot { + nullifier: bigint; + ciphertext: bigint; +} + +/** An excluded ballot, opened so anyone can see it encrypts no valid choice. */ +export interface BallotOpening { + nullifier: bigint; + plaintext: bigint; + randomness: bigint; +} + +export interface TallyProof { + version: 1; + /** Opens the product of the valid ballots to the packed counters. */ + aggregateRandomness: bigint; + /** Ballots that decrypt to something other than one vote for one option. */ + invalid: BallotOpening[]; +} + +export const TALLY_PROOF_VERSION = 1; + +// ──────────────────────────────────────────────── +// Arithmetic +// ──────────────────────────────────────────────── + +const mod = (a: bigint, m: bigint): bigint => ((a % m) + m) % m; + +export function modPow(base: bigint, exponent: bigint, m: bigint): bigint { + if (m === 1n) return 0n; + let result = 1n; + let b = mod(base, m); + let e = exponent; + while (e > 0n) { + if (e & 1n) result = (result * b) % m; + e >>= 1n; + b = (b * b) % m; + } + return result; +} + +function gcd(a: bigint, b: bigint): bigint { + let [x, y] = [a < 0n ? -a : a, b < 0n ? -b : b]; + while (y !== 0n) [x, y] = [y, x % y]; + return x; +} + +export function modInverse(a: bigint, m: bigint): bigint { + let [oldR, r] = [mod(a, m), m]; + let [oldS, s] = [1n, 0n]; + while (r !== 0n) { + const q = oldR / r; + [oldR, r] = [r, oldR - q * r]; + [oldS, s] = [s, oldS - q * s]; + } + if (oldR !== 1n) throw new Error("not invertible"); + return mod(oldS, m); +} + +// ──────────────────────────────────────────────── +// Ballots and counters +// ──────────────────────────────────────────────── + +/** Whether a ciphertext is an element of Z*_{n²}. Needs no key. */ +export function isWellFormed(pk: PaillierPublic, c: bigint): boolean { + const n2 = pk.n * pk.n; + return c > 0n && c < n2 && gcd(c, pk.n) === 1n; +} + +/** Whether a plaintext is exactly one vote for one of `slots` options. */ +export function isValidBallotPlaintext(m: bigint, base: bigint, slots: number): boolean { + let power = 1n; + for (let i = 0; i < slots; i++) { + if (m === power) return true; + power *= base; + } + return false; +} + +/** The single plaintext the counters stand for: Σ counts[i] · base^i. */ +export function packCounts(counts: readonly bigint[], base: bigint): bigint { + let packed = 0n; + for (let i = counts.length - 1; i >= 0; i--) packed = packed * base + counts[i]; + return packed; +} + +/** Whether (m, r) opens c: c = g^m · r^n (mod n²). */ +export function verifyOpening(pk: PaillierPublic, c: bigint, m: bigint, r: bigint): boolean { + const n2 = pk.n * pk.n; + if (m < 0n || m >= pk.n) return false; + if (r <= 0n || r >= pk.n || gcd(r, pk.n) !== 1n) return false; + return (modPow(pk.g, m, n2) * modPow(r, pk.n, n2)) % n2 === mod(c, n2); +} + +/** The product of ciphertexts, which encrypts the sum of their plaintexts. */ +export function aggregate(pk: PaillierPublic, ciphertexts: readonly bigint[]): bigint { + const n2 = pk.n * pk.n; + return ciphertexts.reduce((acc, c) => (acc * c) % n2, 1n); +} + +/** + * The r that opens c to m, which only the key holder can compute. + * + * c · g^{-m} = r^n (mod n²), and raising that to n^{-1} mod λ gives r back + * modulo n, because r^λ = 1 (mod n) whenever λ is a multiple of the Carmichael + * function of n, which both of Paillier's usual choices of λ are. + */ +export function recoverRandomness( + pk: PaillierPublic, + lambda: bigint, + c: bigint, + m: bigint, +): bigint { + const n2 = pk.n * pk.n; + const rToN = (mod(c, n2) * modInverse(modPow(pk.g, m, n2), n2)) % n2; + const d = modInverse(pk.n, lambda); + return modPow(rToN % pk.n, d, pk.n); +} + +// ──────────────────────────────────────────────── +// Final ballots +// ──────────────────────────────────────────────── + +/** The ballot each nullifier's highest nonce left, which is the one that counts. */ +export function finalBallots( + events: readonly { nullifier: bigint; nonce: bigint; ciphertext: string | bigint }[], +): FinalBallot[] { + const latest = new Map(); + for (const e of events) { + const existing = latest.get(e.nullifier); + if (!existing || e.nonce > existing.nonce) { + latest.set(e.nullifier, { nonce: e.nonce, ciphertext: toBigInt(e.ciphertext) }); + } + } + return [...latest.entries()].map(([nullifier, v]) => ({ nullifier, ciphertext: v.ciphertext })); +} + +function toBigInt(value: string | bigint): bigint { + if (typeof value === "bigint") return value; + return value === "0x" || value === "" ? 0n : BigInt(value); +} + +// ──────────────────────────────────────────────── +// Producing and checking a tally +// ──────────────────────────────────────────────── + +export interface ProvenTally { + counts: bigint[]; + /** Ballots excluded: malformed ciphertexts plus opened invalid ones. */ + invalidBallots: number; + proof: TallyProof; +} + +/** + * Tallies the final ballots and proves the result. Needs the private key, + * given as `decrypt` and `lambda` so this file stays free of any library. + */ +export function proveTally(params: { + publicKey: PaillierPublic; + lambda: bigint; + decrypt: (c: bigint) => bigint; + ballots: readonly FinalBallot[]; + slots: number; + base: bigint; +}): ProvenTally { + const { publicKey: pk, lambda, decrypt, ballots, slots, base } = params; + + const valid: bigint[] = []; + const invalid: BallotOpening[] = []; + let malformed = 0; + const counts = Array.from({ length: slots }, () => 0n); + + for (const ballot of ballots) { + if (!isWellFormed(pk, ballot.ciphertext)) { + malformed++; + continue; + } + const m = decrypt(ballot.ciphertext); + if (isValidBallotPlaintext(m, base, slots)) { + valid.push(ballot.ciphertext); + counts[indexOfPower(m, base)] += 1n; + } else { + invalid.push({ + nullifier: ballot.nullifier, + plaintext: m, + randomness: recoverRandomness(pk, lambda, ballot.ciphertext, m), + }); + } + } + + const packed = packCounts(counts, base); + const aggregateRandomness = + valid.length === 0 ? 1n : recoverRandomness(pk, lambda, aggregate(pk, valid), packed); + + return { + counts, + invalidBallots: malformed + invalid.length, + proof: { version: TALLY_PROOF_VERSION, aggregateRandomness, invalid }, + }; +} + +function indexOfPower(m: bigint, base: bigint): number { + let i = 0; + while (m > 1n) { + m /= base; + i++; + } + return i; +} + +export type TallyVerdict = + | { ok: true; validBallots: number; invalidBallots: number } + | { ok: false; reason: string }; + +/** + * Checks a published tally against the ballots on chain, with no key. + * + * Everything it needs is public: the ballots from `VoteCast`, the public key, + * the counters and the proof. Any reader can run it and reach the same answer. + */ +export function verifyTally(params: { + publicKey: PaillierPublic; + ballots: readonly FinalBallot[]; + counts: readonly bigint[]; + invalidBallots: bigint | number; + proof: TallyProof; + base: bigint; +}): TallyVerdict { + const { publicKey: pk, ballots, counts, proof, base } = params; + if (proof.version !== TALLY_PROOF_VERSION) return { ok: false, reason: "unknown proof version" }; + if (counts.some(c => c < 0n || c >= base)) return { ok: false, reason: "a counter overflows its slot" }; + + const byNullifier = new Map(ballots.map(b => [b.nullifier, b.ciphertext])); + const excluded = new Set(); + + for (const opening of proof.invalid) { + const c = byNullifier.get(opening.nullifier); + if (c === undefined) return { ok: false, reason: "an excluded ballot is not on chain" }; + if (excluded.has(opening.nullifier)) return { ok: false, reason: "a ballot is excluded twice" }; + if (!verifyOpening(pk, c, opening.plaintext, opening.randomness)) { + return { ok: false, reason: "an excluded ballot's opening does not match it" }; + } + if (isValidBallotPlaintext(opening.plaintext, base, counts.length)) { + return { ok: false, reason: "a valid ballot was excluded" }; + } + excluded.add(opening.nullifier); + } + + const valid: bigint[] = []; + let malformed = 0; + for (const b of ballots) { + if (excluded.has(b.nullifier)) continue; + if (!isWellFormed(pk, b.ciphertext)) { + malformed++; + continue; + } + valid.push(b.ciphertext); + } + + const invalidBallots = malformed + excluded.size; + if (BigInt(invalidBallots) !== BigInt(params.invalidBallots)) { + return { ok: false, reason: "the excluded count does not match the proof" }; + } + + const total = counts.reduce((a, c) => a + c, 0n); + if (total !== BigInt(valid.length)) { + return { ok: false, reason: "the counters do not add up to the valid ballots" }; + } + + const packed = packCounts(counts, base); + const opened = + valid.length === 0 + ? packed === 0n + : verifyOpening(pk, aggregate(pk, valid), packed, proof.aggregateRandomness); + if (!opened) return { ok: false, reason: "the counters are not what the ballots add up to" }; + + return { ok: true, validBallots: valid.length, invalidBallots }; +} + +// ──────────────────────────────────────────────── +// Wire format +// ──────────────────────────────────────────────── + +const hex = (x: bigint): string => "0x" + x.toString(16); + +/** UTF-8 JSON as `0x`-prefixed hex, the form `publishResults` takes it in. */ +export function encodeTallyProof(proof: TallyProof): string { + const json = JSON.stringify({ + v: proof.version, + r: hex(proof.aggregateRandomness), + invalid: proof.invalid.map(o => ({ + nullifier: hex(o.nullifier), + m: hex(o.plaintext), + r: hex(o.randomness), + })), + }); + const bytes = new TextEncoder().encode(json); + return "0x" + Array.from(bytes, b => b.toString(16).padStart(2, "0")).join(""); +} + +/** The inverse of `encodeTallyProof`. Throws on anything else. */ +export function decodeTallyProof(data: string): TallyProof { + const digits = data.startsWith("0x") ? data.slice(2) : data; + if (digits.length % 2 !== 0 || /[^0-9a-f]/i.test(digits)) throw new Error("not hex"); + const bytes = new Uint8Array(digits.length / 2); + for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(digits.slice(i * 2, i * 2 + 2), 16); + + const raw = JSON.parse(new TextDecoder().decode(bytes)) as { + v?: unknown; + r?: unknown; + invalid?: unknown; + }; + if (raw.v !== TALLY_PROOF_VERSION || typeof raw.r !== "string" || !Array.isArray(raw.invalid)) { + throw new Error("not a tally proof"); + } + return { + version: TALLY_PROOF_VERSION, + aggregateRandomness: BigInt(raw.r), + invalid: raw.invalid.map((o: { nullifier?: unknown; m?: unknown; r?: unknown }) => { + if (typeof o?.nullifier !== "string" || typeof o.m !== "string" || typeof o.r !== "string") { + throw new Error("not a tally proof"); + } + return { nullifier: BigInt(o.nullifier), plaintext: BigInt(o.m), randomness: BigInt(o.r) }; + }), + }; +} From 5738f35ac3a5cf80f46584f7183fc8bd1f810e5f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 13:38:37 +0000 Subject: [PATCH 02/32] fix(contracts): close the pre-Amoy review findings - ElectionV4 no longer inherits ERC2771Context: the deploy script fell back to the deployer as trusted forwarder, which could speak as any organizer. - Ballots capped at 512 bytes; voteEnd is exclusive everywhere. - publishResults takes the tally proof and the excluded-ballot count, checks the counters account for distinctVoters, and emits TallyProofPublished. - markVoided is refused on a non-cancellable election whose result is publishable: voiding it would be the veto it promised not to have. - enrollPrivate carries a documentTag on gated elections, so one passport behind several World ID accounts enrols once. - ElectionPaymaster: factory set once, relay params bounded, even the factory limited to an organizer's own elections, sponsored re-votes spaced by a one-hour cooldown so no voter can drain a tank. - Shared TwoStepOwnable; PlatformRegistry caps vault entries and sizes. - Seed and E2E publish real proofs; seed tag derivation matches the backend. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi --- contracts/.env.example | 7 - contracts/DEVELOPMENT.md | 18 +- contracts/contracts/ElectionFactory.sol | 10 +- contracts/contracts/ElectionPaymaster.sol | 121 +++++++----- contracts/contracts/ElectionV4.sol | 162 +++++++++++----- contracts/contracts/PlatformRegistry.sol | 79 ++++---- contracts/contracts/TwoStepOwnable.sol | 46 +++++ .../contracts/mocks/ElectionV4Harness.sol | 22 +++ contracts/package.json | 4 + contracts/scripts/deploy.ts | 10 - contracts/scripts/estimate-deploy-cost.ts | 1 - contracts/scripts/seed-demo.ts | 130 ++++++++++--- contracts/test/E2E.test.ts | 173 +++++++++++++----- contracts/test/Election.test.ts | 165 ++++++++++++++--- contracts/test/Eligibility.test.ts | 8 +- contracts/test/Factory.test.ts | 3 +- contracts/test/Paymaster.test.ts | 101 +++++++++- contracts/test/PrivateEnrollment.test.ts | 111 ++++++++--- contracts/test/fixtures.ts | 17 +- contracts/tsconfig.json | 3 + 20 files changed, 880 insertions(+), 311 deletions(-) create mode 100644 contracts/contracts/TwoStepOwnable.sol create mode 100644 contracts/contracts/mocks/ElectionV4Harness.sol diff --git a/contracts/.env.example b/contracts/.env.example index 617b5be..f92104c 100644 --- a/contracts/.env.example +++ b/contracts/.env.example @@ -13,13 +13,6 @@ AMOY_RPC_URL=https://polygon-amoy.drpc.org # Budget: run `npx hardhat run scripts/estimate-deploy-cost.ts` for a live figure. PRIVATE_KEY= -# ── ERC-2771 trusted forwarder ── -# ZeroDev is ERC-4337, not ERC-2771, so there is no forwarder to point at here. -# Use a burn address to keep _msgSender() == msg.sender. Leaving it unset makes -# deploy.ts fall back to the deployer address, which grants that key the ability -# to impersonate organizers. -TRUSTED_FORWARDER=0x000000000000000000000000000000000000dEaD - # ── PolygonScan verification ── # Free key from https://etherscan.io/apis. The Etherscan V2 unified API means one # key covers Polygon Amoy (chainid 80002); the old PolygonScan key is gone. diff --git a/contracts/DEVELOPMENT.md b/contracts/DEVELOPMENT.md index a9a5d4f..aed7604 100644 --- a/contracts/DEVELOPMENT.md +++ b/contracts/DEVELOPMENT.md @@ -15,9 +15,9 @@ | Contract | Description | |----------|-------------| -| ElectionV4.sol | Single election. On-chain Semaphore V4 group (LeanIMT/PoseidonT3), `enroll` gated to PlatformRegistry members plus an optional attribute policy (`enrollAttested`), `castVote` (bytes Paillier ciphertext) with merkle-root validation + coercion resistance (nullifier+nonce), `VotingType` enum + `thresholdValue`, lifecycle (cancel/closeEarly/void/publishResults with per-type outcome). ERC-2771 meta-tx. | +| ElectionV4.sol | Single election. On-chain Semaphore V4 group (LeanIMT/PoseidonT3), `enroll` gated to PlatformRegistry members plus an optional attribute policy (`enrollAttested`), `castVote` (bytes Paillier ciphertext) with merkle-root validation + coercion resistance (nullifier+nonce), `VotingType` enum + `thresholdValue`, lifecycle (cancel/closeEarly/void/publishResults with per-type outcome). `castVote` refuses ballots over 512 bytes; `publishResults` requires the counters plus the excluded ballots to equal `distinctVoters` and emits the tally proof (`TallyProofPublished`); `markVoided` is refused on a non-cancellable election whose result is publishable. `enrollPrivate` carries a `documentTag` on gated elections so one document enrols once. No meta-transaction forwarder. | | ElectionFactory.sol | Deploys ElectionV4 from a `Config` struct, routes MATIC deposit to the paymaster, enumerable `getElections(offset, limit)`. | -| ElectionPaymaster.sol | Gas tank **and relay hub**. `relayEnroll` / `relayEnrollAttested` / `relayVote` call the election and reimburse the caller from `gasBalance[organizerOf[election]]` in the same tx. `depositFor` / `withdraw`; `setFactory` and `setRelayParams` are onlyOwner. | +| ElectionPaymaster.sol | Gas tank **and relay hub**. `relayEnroll` / `relayEnrollAttested` / `relayEnrollPrivate` / `relayVote` call the election and reimburse the caller from the election's reserve, then the organizer's free balance, in the same tx. Sponsored re-votes wait out `REVOTE_COOLDOWN` (1 h) per nullifier. `deposit` / `depositForElection` / `withdraw`; `setFactory` is onlyOwner and settable ONCE; `setRelayParams` is onlyOwner within fixed ceilings. Ownership is `TwoStepOwnable`, shared with `PlatformRegistry`. | | PlatformRegistry.sol | Identity-commitment registry (issuer-owned). Gates enrollment. Binds one World ID nullifier to exactly one active commitment; `rotateMember` is the recovery path and revokes the old commitment atomically. `nullifierOf` resolves a commitment (even a revoked one) back to its human. | | vendor/SemaphoreVerifierVendor.sol | `SemaphoreVerifierV4`: official Groth16 verifier (production). | | mocks/MockVerifier.sol | Always-true verifier, unit tests only. | @@ -284,7 +284,6 @@ which is why the ceiling matters more than the floor. ``` AMOY_RPC_URL=https://polygon-amoy.drpc.org PRIVATE_KEY=0x... # deployer key (also PlatformRegistry owner). Never commit. -TRUSTED_FORWARDER=0x...dEaD # burn address, see below USE_REAL_VERIFIER=true # optional, use SemaphoreVerifierV4 on a local net too ``` @@ -294,14 +293,13 @@ endpoints: `polygon-amoy.drpc.org`, `polygon-amoy-bor-rpc.publicnode.com`, here, but the frontend and the tally need Tenderly (the others cap `eth_getLogs` at 10000 blocks). -### On TRUSTED_FORWARDER +### No trusted forwarder -There is no forwarder to point at. Voter calls arrive through `ElectionPaymaster`, and neither -`enroll` nor `castVote` reads `msg.sender` anyway, so `ERC2771Context._msgSender()` only -affects the organizer-only functions, where organizers sign with MetaMask directly. Set it to -`0x000000000000000000000000000000000000dEaD` so `_msgSender()` always equals `msg.sender`. -Leaving it unset makes `deploy.ts` fall back to the deployer address, which would let that key -impersonate any organizer. +`ElectionV4` does not inherit `ERC2771Context`. Voter calls arrive through +`ElectionPaymaster`, and neither `enroll` nor `castVote` reads the sender, while organizers +sign their own transactions. A trusted forwarder would therefore add nothing but a party able +to speak as any organizer, which is exactly what the old fallback to the deployer's address +made possible. There is nothing to configure. ## Deployment cost diff --git a/contracts/contracts/ElectionFactory.sol b/contracts/contracts/ElectionFactory.sol index c593eca..bf1a77d 100644 --- a/contracts/contracts/ElectionFactory.sol +++ b/contracts/contracts/ElectionFactory.sol @@ -9,7 +9,6 @@ import {ElectionPaymaster} from "./ElectionPaymaster.sol"; /// to the gas paymaster. Keeps an enumerable list of every election created. contract ElectionFactory { ElectionPaymaster public immutable paymaster; - address public immutable forwarder; address public immutable verifier; address public immutable registry; /** @@ -40,20 +39,17 @@ contract ElectionFactory { constructor( address _paymaster, - address _forwarder, address _verifier, address _registry, address _platformAttester ) { if ( _paymaster == address(0) || - _forwarder == address(0) || _verifier == address(0) || _registry == address(0) ) revert ZeroAddress(); paymaster = ElectionPaymaster(payable(_paymaster)); - forwarder = _forwarder; verifier = _verifier; registry = _registry; platformAttester = _platformAttester; @@ -74,7 +70,6 @@ contract ElectionFactory { uint256 fromBalance ) external payable returns (address) { ElectionV4 newElection = new ElectionV4( - forwarder, verifier, registry, platformAttester, @@ -121,8 +116,9 @@ contract ElectionFactory { uint256 total = elections.length; if (offset >= total) return new address[](0); - uint256 end = offset + limit; - if (end > total) end = total; + // Clamped before adding, so a caller asking for "everything" with a + // huge limit gets the rest of the list rather than an overflow revert. + uint256 end = limit > total - offset ? total : offset + limit; page = new address[](end - offset); for (uint256 i = offset; i < end; i++) { diff --git a/contracts/contracts/ElectionPaymaster.sol b/contracts/contracts/ElectionPaymaster.sol index 91e5923..cc9d019 100644 --- a/contracts/contracts/ElectionPaymaster.sol +++ b/contracts/contracts/ElectionPaymaster.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.37; import {ElectionV4} from "./ElectionV4.sol"; +import {TwoStepOwnable} from "./TwoStepOwnable.sol"; /// @title ElectionPaymaster /// @notice Gas tank and relay hub. Organizers deposit POL for their own @@ -24,7 +25,7 @@ import {ElectionV4} from "./ElectionV4.sol"; /// Neither `ElectionV4.enroll` nor `ElectionV4.castVote` reads `msg.sender`: /// enrollment is gated on the registry and voting on a zero-knowledge proof. /// That is what makes relaying safe. -contract ElectionPaymaster { +contract ElectionPaymaster is TwoStepOwnable { /// @dev Fixed gas outside the metered region: the 21000 transaction base plus /// the reimbursement transfer and event. Calldata is charged separately per /// byte, because `relayVote` carries a Paillier ciphertext and a Groth16 @@ -46,17 +47,42 @@ contract ElectionPaymaster { /// relayEnrollAttested head: selector + address + 3 uint256 + bytes offset + /// length word, then the padded signature added at call time. uint256 private constant ATTESTED_ENROLL_CALLDATA_HEAD = 4 + 32 + (32 * 3) + 32 + 32; - /// @dev selector + election + (commitment, tag, deadline) + two dynamic - /// offsets + two lengths. The two signatures themselves are measured from + /// @dev selector + election + (commitment, humanTag, documentTag, deadline) + /// + two dynamic offsets + two lengths. The two signatures themselves are measured from /// their own lengths, as the attested path does with its one. - uint256 private constant PRIVATE_ENROLL_CALLDATA_HEAD = 4 + 32 + (32 * 3) + (32 * 2) + (32 * 2); + uint256 private constant PRIVATE_ENROLL_CALLDATA_HEAD = 4 + 32 + (32 * 4) + (32 * 2) + (32 * 2); /// relayVote head: selector + address + bytes offset + 3 uint256 + pA + pB + pC, /// then the bytes tail (length word + padded contents) added at call time. uint256 private constant VOTE_CALLDATA_HEAD = 4 + 32 + 32 + (32 * 3) + 64 + 128 + 64 + 32; - address public owner; - address public pendingOwner; - /// @dev Allowed to bind an election to its organizer (set to ElectionFactory). + /** + * @dev Ceilings on what the owner may set the relay parameters to. + * + * The owner is also free to relay, so without these the owner could raise + * the reimbursed price and gas to whatever a tank holds and collect it one + * ballot at a time. Bounded, the worst an owner can do is overpay a relayer + * by a known factor on work that was genuinely done. + */ + uint256 public constant MAX_GAS_PRICE_CEILING = 500 gwei; + uint256 public constant MAX_BASE_OVERHEAD = 100_000; + uint256 public constant MAX_RELAY_GAS_CEILING = 3_000_000; + + /** + * @dev Least time between two SPONSORED ballots from one voter in one + * election. + * + * Re-voting is the coercion defence, and relaying is permissionless, so + * without this one enrolled voter could re-vote in a loop, be reimbursed + * as their own relayer, and empty the organizer's tank until voting stops + * for everyone. A count cap would bound that too, but a coercer standing + * over the voter could spend the whole allowance and leave no override. A + * cooldown cannot be spent in advance: the honest override is only ever + * delayed, never refused, and the drain is bounded by the election's + * length divided by this. + */ + uint256 public constant REVOTE_COOLDOWN = 1 hours; + + /// @dev Allowed to bind an election to its organizer (set once, to ElectionFactory). address public factory; /// @dev Upper bound on the gas price a relayer may be reimbursed at, so a @@ -91,11 +117,11 @@ contract ElectionPaymaster { mapping(address => uint256) public reservedFor; /// @dev election => the organizer whose tank pays for it mapping(address => address) public organizerOf; + /// @dev election => Semaphore nullifier => when its last sponsored ballot landed. + mapping(address => mapping(uint256 => uint256)) public lastSponsoredBallot; uint256 private _entered; - event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner); - event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); /// @dev No `from`: a tank is only ever filled by its owner, so the two were /// always the same address and one of them was decoration. event Deposited(address indexed organizer, uint256 amount); @@ -112,9 +138,11 @@ contract ElectionPaymaster { uint256 maxRelayGas ); - error NotOwner(); - error NotPendingOwner(); error NotFactory(); + error FactoryAlreadySet(); + error RelayParamsOutOfBounds(); + /// @dev This voter's previous sponsored ballot here is too recent; see REVOTE_COOLDOWN. + error RevoteTooSoon(uint256 availableAt); error InsufficientBalance(); error WithdrawFailed(); error ReimbursementFailed(); @@ -124,14 +152,8 @@ contract ElectionPaymaster { error NothingReserved(); /// @dev Only the organizer an election was registered to may spend their balance on it. error NotElectionOrganizer(); - error ZeroAddress(); error Reentrancy(); - modifier onlyOwner() { - if (msg.sender != owner) revert NotOwner(); - _; - } - modifier nonReentrant() { if (_entered == 1) revert Reentrancy(); _entered = 1; @@ -140,7 +162,6 @@ contract ElectionPaymaster { } constructor() { - owner = msg.sender; // Polygon's enforced floor sits at 25 to 30 gwei. This leaves headroom for // congestion without letting a careless relayer burn a tank several times // faster than necessary. @@ -154,8 +175,18 @@ contract ElectionPaymaster { // Configuration // ──────────────────────────────────────────────── + /** + * @notice Names the factory, once. + * + * ONCE, because the factory is the one contract this one trusts to say who + * organises an election and to spend an organizer's balance on it. An owner + * able to swap it could install a factory that registers elections to + * themselves and moves everybody's free balance into them. A new factory + * means a new paymaster, which organizers can see and choose to fund. + */ function setFactory(address _factory) external onlyOwner { if (_factory == address(0)) revert ZeroAddress(); + if (factory != address(0)) revert FactoryAlreadySet(); factory = _factory; emit FactoryChanged(_factory); } @@ -166,6 +197,14 @@ contract ElectionPaymaster { uint256 _calldataGasPerByte, uint256 _maxRelayGas ) external onlyOwner { + if ( + _maxGasPrice == 0 || + _maxGasPrice > MAX_GAS_PRICE_CEILING || + _baseOverheadGas > MAX_BASE_OVERHEAD || + _calldataGasPerByte > DEFAULT_CALLDATA_GAS || + _maxRelayGas == 0 || + _maxRelayGas > MAX_RELAY_GAS_CEILING + ) revert RelayParamsOutOfBounds(); maxGasPrice = _maxGasPrice; baseOverheadGas = _baseOverheadGas; calldataGasPerByte = _calldataGasPerByte; @@ -225,9 +264,9 @@ contract ElectionPaymaster { * Paying for an election is not a donation to the election. It is taking on * the organizer's obligation, and the refund is what proves it. * - * Someone who genuinely wants to help still can, through `depositFor`, which - * is open to anyone: there the money lands in the organizer's balance, where - * it is plainly a gift and is never mistaken for a reserve. + * Someone who genuinely wants to help still can: a plain transfer to the + * organizer's wallet is plainly a gift, and leaves them to decide whether it + * goes into a tank at all. * * The factory is allowed because it forwards value on behalf of the very * account creating the election, which is that election's organizer. @@ -255,10 +294,8 @@ contract ElectionPaymaster { * `withdraw`, and everything else is the balance moving between two columns * of the same tank. * - * ONLY THE ORGANIZER OF THAT ELECTION, unlike `depositForElection`, and the - * difference is the point: there you are committing your own money and - * anyone may want an election to go ahead, here you are spending someone's - * balance and only they may decide that. + * ONLY THE ORGANIZER OF THAT ELECTION, like `depositForElection`: it spends + * their balance and only they may decide that. */ function reserveFromBalance(address election, uint256 amount) external { if (organizerOf[election] != msg.sender) revert NotElectionOrganizer(); @@ -286,6 +323,9 @@ contract ElectionPaymaster { */ function reserveFromBalanceFor(address election, address organizer, uint256 amount) external { if (msg.sender != factory) revert NotFactory(); + // Defence in depth: even the factory may only spend an organizer's + // balance on an election registered to that same organizer. + if (organizerOf[election] != organizer) revert NotElectionOrganizer(); if (gasBalance[organizer] < amount) revert InsufficientBalance(); gasBalance[organizer] -= amount; @@ -312,7 +352,7 @@ contract ElectionPaymaster { ElectionV4 e = ElectionV4(election); // `voteEnd` moves when voting is closed early, so this follows it. - bool over = e.cancelled() || e.voided() || block.timestamp > e.voteEnd(); + bool over = e.cancelled() || e.voided() || block.timestamp >= e.voteEnd(); if (!over) revert ElectionStillOpen(); uint256 amount = reservedFor[election]; @@ -395,6 +435,7 @@ contract ElectionPaymaster { address election, uint256 identityCommitment, uint256 humanTag, + uint256 documentTag, uint256 deadline, bytes calldata platformSignature, bytes calldata eligibilitySignature @@ -405,6 +446,7 @@ contract ElectionPaymaster { ElectionV4(election).enrollPrivate( identityCommitment, humanTag, + documentTag, deadline, platformSignature, eligibilitySignature @@ -437,6 +479,13 @@ contract ElectionPaymaster { uint256 startGas = gasleft(); address organizer = _organizerOrRevert(election); + // First ballots are always sponsored; re-votes wait out the cooldown. + uint256 last = lastSponsoredBallot[election][nullifier]; + if (last != 0 && block.timestamp < last + REVOTE_COOLDOWN) { + revert RevoteTooSoon(last + REVOTE_COOLDOWN); + } + lastSponsoredBallot[election][nullifier] = block.timestamp; + ElectionV4(election).castVote( voteCiphertext, nullifier, @@ -521,24 +570,4 @@ contract ElectionPaymaster { if (organizer == address(0)) revert UnknownElection(); return (reservedFor[election], gasBalance[organizer]); } - - /// @notice Hand control to another address. - /// @dev Lets the deployer key stay offline while a separate hot wallet does - /// the day-to-day work. Without it the deployer key has to live wherever the - /// operational calls are made, which for a registry means the issuer server. - /// Two-step on purpose: a typo here would brick the contract permanently. - function transferOwnership(address newOwner) external onlyOwner { - if (newOwner == address(0)) revert ZeroAddress(); - pendingOwner = newOwner; - emit OwnershipTransferStarted(owner, newOwner); - } - - /// @notice Called by the incoming owner to prove the address is usable. - function acceptOwnership() external { - if (msg.sender != pendingOwner) revert NotPendingOwner(); - address previous = owner; - owner = pendingOwner; - pendingOwner = address(0); - emit OwnershipTransferred(previous, owner); - } } diff --git a/contracts/contracts/ElectionV4.sol b/contracts/contracts/ElectionV4.sol index 159aa6e..90ad50b 100644 --- a/contracts/contracts/ElectionV4.sol +++ b/contracts/contracts/ElectionV4.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.37; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; -import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol"; import {ISemaphoreVerifier} from "@semaphore-protocol/contracts/interfaces/ISemaphoreVerifier.sol"; import {InternalLeanIMT, LeanIMTData} from "@zk-kit/lean-imt.sol/InternalLeanIMT.sol"; @@ -19,7 +18,12 @@ interface IPlatformRegistry { /// later cast Paillier-encrypted votes proving group membership with a Semaphore V4 /// zero-knowledge proof. Re-voting is allowed: only the highest nonce per nullifier /// counts at tally time (coercion resistance). -contract ElectionV4 is ERC2771Context { +/// +/// No meta-transaction forwarder, and deliberately: voters reach this contract +/// through ElectionPaymaster and nothing they call reads the sender, while the +/// organizer signs their own transactions. A trusted forwarder would only add +/// a party able to speak as the organizer. +contract ElectionV4 { using InternalLeanIMT for LeanIMTData; // ──────────────────────────────────────────────── @@ -101,6 +105,16 @@ contract ElectionV4 is ERC2771Context { */ uint256 public constant MAX_OPTIONS = 50; + /** + * @dev Ceiling on one ballot's size in bytes. + * + * A Paillier ciphertext lives modulo n², so under the platform's 2048-bit + * keys it is at most 4096 bits: 512 bytes. Anything longer cannot be a + * ballot, and accepting it would let one voter make every relayed ballot + * as expensive as the gas cap allows, paid from the organizer's tank. + */ + uint256 public constant MAX_BALLOT_BYTES = 512; + address public immutable organizer; ISemaphoreVerifier public immutable verifier; IPlatformRegistry public immutable registry; @@ -181,7 +195,7 @@ contract ElectionV4 is ERC2771Context { /// nullifier is the same number in every election, the tag is not. bytes32 private constant PRIVATE_ENROLL_TYPEHASH = keccak256( - "PrivateEnrollment(uint256 identityCommitment,uint256 humanTag,uint256 deadline)" + "PrivateEnrollment(uint256 identityCommitment,uint256 humanTag,uint256 documentTag,uint256 deadline)" ); /** @@ -350,6 +364,17 @@ contract ElectionV4 is ERC2771Context { event ElectionCancelled(address indexed by); event ElectionVoided(address indexed by); event ResultsPublished(string ipfsCid, uint256[] tally, Outcome outcome, uint256 winnerIndex); + /** + * @notice The evidence that the published counts are what the ballots hold. + * + * UTF-8 JSON, produced and checked by `tallyProof.ts`: the randomness that + * opens the product of every valid final ballot to exactly the published + * counts, and an opening of each ballot excluded as invalid. Anyone can + * check it against the ciphertexts in `VoteCast` without any key. Emitted + * rather than stored, because it is read by auditors and never by this + * contract. + */ + event TallyProofPublished(uint256 invalidBallots, bytes proof); /** * Name and metadata bounds, in BYTES rather than characters: Solidity cannot @@ -398,13 +423,23 @@ contract ElectionV4 is ERC2771Context { error PersonhoodNullifierUsed(); error TooManyOptions(); error PrivacyQuorumNotMet(); + /// @dev An empty ballot, or one longer than any Paillier ciphertext can be. + error InvalidBallot(); + /// @dev A non-cancellable election whose result can be published cannot be voided. + error ResultPublishable(); + /// @dev A gated private enrolment arrived without the document tag. + error MissingDocumentTag(); + /// @dev A document tag on an election with no document requirement. + error UnexpectedDocumentTag(); + /// @dev A result was published without the proof that makes it checkable. + error MissingTallyProof(); // ──────────────────────────────────────────────── // Modifiers // ──────────────────────────────────────────────── modifier onlyOrganizer() { - if (_msgSender() != organizer) revert NotOrganizer(); + if (msg.sender != organizer) revert NotOrganizer(); _; } @@ -420,13 +455,12 @@ contract ElectionV4 is ERC2771Context { // ──────────────────────────────────────────────── constructor( - address trustedForwarder, address _verifier, address _registry, address _platformAttester, address _organizer, Config memory cfg - ) ERC2771Context(trustedForwarder) { + ) { if ( _verifier == address(0) || _registry == address(0) || @@ -650,6 +684,7 @@ contract ElectionV4 is ERC2771Context { function enrollPrivate( uint256 identityCommitment, uint256 humanTag, + uint256 documentTag, uint256 deadline, bytes calldata platformSignature, bytes calldata eligibilitySignature @@ -658,7 +693,23 @@ contract ElectionV4 is ERC2771Context { if (block.timestamp > deadline) revert AttestationExpired(); if (humanTag == 0) revert MissingHumanTag(); - bytes32 digest = privateEnrollmentDigest(identityCommitment, humanTag, deadline); + /** + * ONE DOCUMENT, ONE LEAF, which `humanTag` alone cannot promise. The + * tag is derived from the World ID account, and below Orb an account + * is not a person: somebody holding several could pass the same + * passport check once per account and enrol once per account. A gated + * election therefore carries a second tag derived from the document + * the voter proved, and refuses it the second time, exactly as the + * public path refuses a reused personhood nullifier. + */ + if (eligibilityAttester != address(0)) { + if (documentTag == 0) revert MissingDocumentTag(); + if (usedPersonhoodNullifiers[documentTag]) revert PersonhoodNullifierUsed(); + } else if (documentTag != 0) { + revert UnexpectedDocumentTag(); + } + + bytes32 digest = privateEnrollmentDigest(identityCommitment, humanTag, documentTag, deadline); _requireSignature(digest, platformSignature, platformAttester); // The organizer's own gatekeeper, where they named one. It signs the @@ -667,6 +718,7 @@ contract ElectionV4 is ERC2771Context { // only add a second thing to keep in step. if (eligibilityAttester != address(0)) { _requireSignature(digest, eligibilitySignature, eligibilityAttester); + usedPersonhoodNullifiers[documentTag] = true; } _insertMember(identityCommitment, humanTag); @@ -676,10 +728,11 @@ contract ElectionV4 is ERC2771Context { function privateEnrollmentDigest( uint256 identityCommitment, uint256 humanTag, + uint256 documentTag, uint256 deadline ) public view returns (bytes32) { bytes32 structHash = keccak256( - abi.encode(PRIVATE_ENROLL_TYPEHASH, identityCommitment, humanTag, deadline) + abi.encode(PRIVATE_ENROLL_TYPEHASH, identityCommitment, humanTag, documentTag, deadline) ); return keccak256(abi.encodePacked(hex"1901", _domainSeparator(), structHash)); } @@ -762,14 +815,19 @@ contract ElectionV4 is ERC2771Context { uint256[2][2] calldata _pB, uint256[2] calldata _pC ) external notDecided { - if (block.timestamp < voteStart || block.timestamp > voteEnd) revert VotingNotOpen(); + // `voteEnd` is exclusive, matching `phase()`: the second it reads + // TALLYING, no ballot lands. + if (block.timestamp < voteStart || block.timestamp >= voteEnd) revert VotingNotOpen(); + if (voteCiphertext.length == 0 || voteCiphertext.length > MAX_BALLOT_BYTES) { + revert InvalidBallot(); + } if (merkleDepth < MIN_TREE_DEPTH || merkleDepth > MAX_TREE_DEPTH) revert InvalidTreeDepth(); // The proof must be built against the current tree root, or a recent root // still inside its validity window. if (merkleRoot != membersTree._root()) { - uint256 createdAt = rootTimestamps[merkleRoot]; - if (createdAt == 0 || block.timestamp > createdAt + MERKLE_ROOT_VALIDITY) { + uint256 rootCreatedAt = rootTimestamps[merkleRoot]; + if (rootCreatedAt == 0 || block.timestamp > rootCreatedAt + MERKLE_ROOT_VALIDITY) { revert UnknownOrExpiredRoot(); } } @@ -808,9 +866,9 @@ contract ElectionV4 is ERC2771Context { /// @notice Cancel the election before it ends. Terminal. function cancelElection() external onlyOrganizer notDecided { if (!cancellable) revert NotCancellable(); - if (block.timestamp > voteEnd) revert WrongPhase(); + if (block.timestamp >= voteEnd) revert WrongPhase(); cancelled = true; - emit ElectionCancelled(_msgSender()); + emit ElectionCancelled(msg.sender); } /** @@ -876,57 +934,68 @@ contract ElectionV4 is ERC2771Context { /// @notice End the voting period now, moving the election into tallying. function closeVotingEarly() external onlyOrganizer notDecided { if (fixedSchedule) revert ScheduleIsFixed(); - if (block.timestamp < voteStart || block.timestamp > voteEnd) revert WrongPhase(); + if (block.timestamp < voteStart || block.timestamp >= voteEnd) revert WrongPhase(); voteEnd = block.timestamp; emit VotingClosedEarly(voteEnd); } - /// @notice Void the election during tallying (e.g. privacy quorum not met). Terminal. + /** + * @notice Void the election during tallying. Terminal. + * + * Always open below the privacy quorum, where no result may be published. + * Above it, only on an election that kept the power to be called off: the + * organizer holds the key and can read the result before deciding, so + * voiding a publishable result is a veto, and an election that promised no + * veto (`cancellable == false`) must not keep this one. + */ function markVoided() external onlyOrganizer notDecided { - if (block.timestamp <= voteEnd) revert VotingNotEnded(); + if (block.timestamp < voteEnd) revert VotingNotEnded(); + if (!cancellable && distinctVoters >= privacyQuorum) revert ResultPublishable(); voided = true; - emit ElectionVoided(_msgSender()); + emit ElectionVoided(msg.sender); } - /// @notice Publish the decrypted tally and its IPFS audit trail. Terminal. - /// @param ipfsCid CID of the auditable tally JSON pinned on IPFS. - /// @param tallyResults Vote counts per option; the LAST entry is the blank vote. + /** + * @notice Publish the decrypted tally with the proof that it is correct. Terminal. + * @param ipfsCid CID of the auditable tally JSON pinned on IPFS, or empty. + * @param tallyResults Vote counts per option; the LAST entry is the blank vote. + * @param invalidBallots Final ballots excluded because they encrypt no valid + * choice. Each one is opened in `tallyProof`, so excluding an honest ballot + * is detectable by anyone. + * @param tallyProof The decryption proof, see `TallyProofPublished`. + * + * THE COUNTERS MUST ACCOUNT FOR EVERY VOTER. Each person's surviving ballot + * either adds exactly one to exactly one counter or is excluded as + * invalid, so the two together equal `distinctVoters`. That used to be + * left unchecked, because a single malformed ballot made any honest tally + * miss the total and the rule would have let one voter block publication. + * Excluding such ballots openly removes that power, so the check is now + * safe and turns an invented or dropped ballot into a revert. + * + * What this contract cannot afford to check is the proof itself: it needs + * every ciphertext and 4096-bit arithmetic. It requires one to be present + * and publishes it, and every reader of the result verifies it. + */ function publishResults( string calldata ipfsCid, - uint256[] calldata tallyResults + uint256[] calldata tallyResults, + uint256 invalidBallots, + bytes calldata tallyProof ) external onlyOrganizer notDecided { - if (block.timestamp <= voteEnd) revert VotingNotEnded(); + if (block.timestamp < voteEnd) revert VotingNotEnded(); if (tallyResults.length != numOptions + 1) revert InvalidTally(); + if (tallyProof.length == 0) revert MissingTallyProof(); // Below the quorum there is no publishable result, only `markVoided`. // Enforced here rather than in the client that draws the button, // because a rule a wallet can step around is not a rule. if (distinctVoters < privacyQuorum) revert PrivacyQuorumNotMet(); - // NOT CHECKED HERE: that the counters sum to `distinctVoters`. - // - // It is true of every honest tally, and refusing anything else looks - // like the obvious way to make ballot stuffing impossible. It is not, - // for two reasons that only show up together. - // - // This contract never validates a ciphertext; it validates the proof of - // membership around one. So an enrolled voter can cast arbitrary bytes - // as their ballot, and no tally over that election will ever sum to the - // voter count again. The rule would hand any single voter the power to - // make any election permanently unpublishable. - // - // And it would buy little: an organizer inclined to falsify moves votes - // BETWEEN options and keeps the total right, which the sum cannot see. - // It would stop the crude attack at the price of a denial of service, - // and leave the careful one untouched. - // - // The equality is checked where it is safe and useful instead: in the - // client, as `decryptTally`'s `expectedBallots`, where it turns a - // corrupted or overflowed tally into a visible error rather than a - // wrong number; and on the results screen, where `distinctVoters` is - // public and anyone can compare it against the published counters. What - // would make stuffing genuinely impossible is a ballot validity proof, - // and that is a different piece of work. + uint256 counted = invalidBallots; + for (uint256 i = 0; i < tallyResults.length; i++) { + counted += tallyResults[i]; + } + if (counted != distinctVoters) revert InvalidTally(); (Outcome computed, uint256 winIdx) = _computeOutcome(tallyResults); @@ -937,6 +1006,7 @@ contract ElectionV4 is ERC2771Context { resultsPublished = true; emit ResultsPublished(ipfsCid, tallyResults, computed, winIdx); + emit TallyProofPublished(invalidBallots, tallyProof); } // ──────────────────────────────────────────────── diff --git a/contracts/contracts/PlatformRegistry.sol b/contracts/contracts/PlatformRegistry.sol index 1d9997b..5f4ef0f 100644 --- a/contracts/contracts/PlatformRegistry.sol +++ b/contracts/contracts/PlatformRegistry.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.37; +import {TwoStepOwnable} from "./TwoStepOwnable.sol"; + /// @title PlatformRegistry /// @notice Binds a World ID nullifier (one per human, per app+action) to the /// Semaphore identity commitment that human votes with. Elections gate @@ -18,7 +20,7 @@ pragma solidity ^0.8.37; /// only for recovery, when that secret is genuinely lost: it revokes the old /// commitment in the same transaction that activates the new one, so the count /// of active identities per human never exceeds one. -contract PlatformRegistry { +contract PlatformRegistry is TwoStepOwnable { /// @notice One passkey's copy of the voter's sealed Semaphore secret. struct VaultEntry { /// @dev WebAuthn credential id. Public by nature, and the key a voter's @@ -30,9 +32,6 @@ contract PlatformRegistry { uint64 addedAt; } - address public owner; - address public pendingOwner; - /// @dev World ID nullifier => registered at all. mapping(uint256 => bool) public registeredNullifiers; /// @dev Semaphore identity commitment => currently active. @@ -107,6 +106,19 @@ contract PlatformRegistry { */ uint256 public constant MAX_PREFERENCES_BYTES = 4096; + /** + * @dev Ceilings on the vault, for the same reason as the one above: every + * write is paid for by the platform's relayer on the voter's behalf, so + * nothing a client sends may grow without bound. + * + * Eight passkeys is more devices than anyone keeps. A WebAuthn credential + * id is at most 1023 bytes by specification, and a sealed secret is an IV, + * a recovery phrase and a tag, which fits comfortably in 512. + */ + uint256 public constant MAX_VAULT_ENTRIES = 8; + uint256 public constant MAX_CREDENTIAL_ID_BYTES = 1023; + uint256 public constant MAX_VAULT_BLOB_BYTES = 512; + /** * @dev World ID nullifier => this human's slot in the credential status * list, 1 based so that zero still means "not registered". @@ -133,8 +145,6 @@ contract PlatformRegistry { */ mapping(uint256 => bool) public revokedStatus; - event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner); - event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); event MemberVerified(uint256 indexed nullifier, uint256 indexed identityCommitment); event MemberRotated( uint256 indexed nullifier, @@ -151,8 +161,6 @@ contract PlatformRegistry { event StatusRevoked(uint256 indexed statusIndex); event StatusRestored(uint256 indexed statusIndex); - error NotOwner(); - error NotPendingOwner(); error NullifierAlreadyRegistered(); error IdentityAlreadyVerified(); error NullifierNotRegistered(); @@ -162,16 +170,8 @@ contract PlatformRegistry { error CredentialNotFound(); error UnknownStatusIndex(); error PreferencesTooLarge(); - error ZeroAddress(); - - constructor() { - owner = msg.sender; - } - - modifier onlyOwner() { - if (msg.sender != owner) revert NotOwner(); - _; - } + error TooManyVaultEntries(); + error VaultEntryTooLarge(); /// @notice First registration of a human. Reverts if either side is taken. function registerMember(uint256 nullifier, uint256 identityCommitment) external onlyOwner { @@ -250,11 +250,10 @@ contract PlatformRegistry { bytes calldata credentialId, bytes calldata blob ) external onlyOwner { - if (nullifier == 0) revert ZeroValue(); - if (credentialId.length == 0 || blob.length == 0) revert EmptyVaultEntry(); - if (!registeredNullifiers[nullifier]) revert NullifierNotRegistered(); + _checkVaultEntry(nullifier, credentialId, blob); VaultEntry[] storage entries = vaults[nullifier]; + if (entries.length >= MAX_VAULT_ENTRIES) revert TooManyVaultEntries(); for (uint256 i = 0; i < entries.length; i++) { // Same passkey twice would leave the browser two blobs to choose // between with nothing to choose on, since both open equally well. @@ -301,9 +300,7 @@ contract PlatformRegistry { bytes calldata credentialId, bytes calldata blob ) external onlyOwner { - if (nullifier == 0) revert ZeroValue(); - if (credentialId.length == 0 || blob.length == 0) revert EmptyVaultEntry(); - if (!registeredNullifiers[nullifier]) revert NullifierNotRegistered(); + _checkVaultEntry(nullifier, credentialId, blob); delete vaults[nullifier]; vaults[nullifier].push( @@ -312,6 +309,20 @@ contract PlatformRegistry { emit VaultReset(nullifier, credentialId); } + /// @dev The checks both vault writes share. + function _checkVaultEntry( + uint256 nullifier, + bytes calldata credentialId, + bytes calldata blob + ) private view { + if (nullifier == 0) revert ZeroValue(); + if (credentialId.length == 0 || blob.length == 0) revert EmptyVaultEntry(); + if (credentialId.length > MAX_CREDENTIAL_ID_BYTES || blob.length > MAX_VAULT_BLOB_BYTES) { + revert VaultEntryTooLarge(); + } + if (!registeredNullifiers[nullifier]) revert NullifierNotRegistered(); + } + /// @notice Every sealed copy this human has, for the browser to try. /// @dev Public, and nothing is lost by that: see the note on `vaults`. function getVault(uint256 nullifier) external view returns (VaultEntry[] memory) { @@ -353,24 +364,4 @@ contract PlatformRegistry { function preferencesOf(uint256 nullifier) external view returns (bytes memory) { return preferences[nullifier]; } - - /// @notice Hand control to another address. - /// @dev Lets the deployer key stay offline while a separate hot wallet does - /// the day-to-day work. Without it the deployer key has to live wherever the - /// operational calls are made, which for a registry means the issuer server. - /// Two-step on purpose: a typo here would brick the contract permanently. - function transferOwnership(address newOwner) external onlyOwner { - if (newOwner == address(0)) revert ZeroAddress(); - pendingOwner = newOwner; - emit OwnershipTransferStarted(owner, newOwner); - } - - /// @notice Called by the incoming owner to prove the address is usable. - function acceptOwnership() external { - if (msg.sender != pendingOwner) revert NotPendingOwner(); - address previous = owner; - owner = pendingOwner; - pendingOwner = address(0); - emit OwnershipTransferred(previous, owner); - } } diff --git a/contracts/contracts/TwoStepOwnable.sol b/contracts/contracts/TwoStepOwnable.sol new file mode 100644 index 0000000..1868fa6 --- /dev/null +++ b/contracts/contracts/TwoStepOwnable.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.37; + +/// @title TwoStepOwnable +/// @notice Ownership handed over in two steps, shared by the platform's +/// owner-administered contracts so the logic exists once. +/// @dev Lets the deployer key stay offline while a separate hot wallet does the +/// day-to-day work. Two-step on purpose: a typo in a one-step transfer would +/// brick the contract permanently. The errors and events keep the names these +/// contracts always exposed, so no reader of their ABI has to change. +abstract contract TwoStepOwnable { + address public owner; + address public pendingOwner; + + event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + error NotOwner(); + error NotPendingOwner(); + error ZeroAddress(); + + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; + } + + constructor() { + owner = msg.sender; + } + + /// @notice Hand control to another address, pending their acceptance. + function transferOwnership(address newOwner) external onlyOwner { + if (newOwner == address(0)) revert ZeroAddress(); + pendingOwner = newOwner; + emit OwnershipTransferStarted(owner, newOwner); + } + + /// @notice Called by the incoming owner to prove the address is usable. + function acceptOwnership() external { + if (msg.sender != pendingOwner) revert NotPendingOwner(); + address previous = owner; + owner = pendingOwner; + pendingOwner = address(0); + emit OwnershipTransferred(previous, owner); + } +} diff --git a/contracts/contracts/mocks/ElectionV4Harness.sol b/contracts/contracts/mocks/ElectionV4Harness.sol new file mode 100644 index 0000000..a0c1f94 --- /dev/null +++ b/contracts/contracts/mocks/ElectionV4Harness.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.37; + +import {ElectionV4} from "../ElectionV4.sol"; + +/// @notice TEST ONLY. Lets the outcome tests publish tallies of any size +/// without casting that many ballots first: `publishResults` requires the +/// counters to account for `distinctVoters`, and the arithmetic of outcomes is +/// what those tests are about, not the voting that precedes it. +contract ElectionV4Harness is ElectionV4 { + constructor( + address _verifier, + address _registry, + address _platformAttester, + address _organizer, + Config memory cfg + ) ElectionV4(_verifier, _registry, _platformAttester, _organizer, cfg) {} + + function forceDistinctVoters(uint256 n) external { + distinctVoters = n; + } +} diff --git a/contracts/package.json b/contracts/package.json index 9d02869..c011fe9 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -6,6 +6,10 @@ "directories": { "test": "test" }, + "engines": { + "node": ">=24", + "npm": ">=11" + }, "scripts": { "test": "hardhat test", "build": "hardhat compile", diff --git a/contracts/scripts/deploy.ts b/contracts/scripts/deploy.ts index 135b9d3..079d759 100644 --- a/contracts/scripts/deploy.ts +++ b/contracts/scripts/deploy.ts @@ -104,14 +104,6 @@ async function main() { await verifier.waitForDeployment(); console.log(`${verifierName}:`, await verifier.getAddress()); - // ERC-2771 forwarder. Voter calls arrive via ElectionPaymaster and neither - // enroll nor castVote reads msg.sender, so a burn address is the right value; - // see contracts/DEVELOPMENT.md. - const forwarder = process.env.TRUSTED_FORWARDER ?? deployer.address; - if (!process.env.TRUSTED_FORWARDER) { - console.warn("WARN: TRUSTED_FORWARDER not set, falling back to deployer address"); - } - const platformAttester = resolvePlatformAttester(); if (platformAttester === ZERO_ADDRESS) { const complaint = @@ -145,7 +137,6 @@ async function main() { }); const factory = await Factory.deploy( await paymaster.getAddress(), - forwarder, await verifier.getAddress(), await registry.getAddress(), platformAttester, @@ -177,7 +168,6 @@ async function main() { ElectionFactory: await factory.getAddress(), PoseidonT3: poseidonAddress, }, - config: { trustedForwarder: forwarder }, }; const manifest = JSON.stringify(deployment, null, 2) + "\n"; diff --git a/contracts/scripts/estimate-deploy-cost.ts b/contracts/scripts/estimate-deploy-cost.ts index dd49af2..a802a00 100644 --- a/contracts/scripts/estimate-deploy-cost.ts +++ b/contracts/scripts/estimate-deploy-cost.ts @@ -146,7 +146,6 @@ async function main(): Promise { }); const factory = await Factory.deploy( await paymaster.getAddress(), - deployer.address, await verifier.getAddress(), await registry.getAddress(), // The platform attester, which costs the same to store whoever it is. diff --git a/contracts/scripts/seed-demo.ts b/contracts/scripts/seed-demo.ts index f1b477c..0ed9ff1 100644 --- a/contracts/scripts/seed-demo.ts +++ b/contracts/scripts/seed-demo.ts @@ -29,6 +29,7 @@ import { generateProof } from "@semaphore-protocol/proof"; import { poseidon2 } from "poseidon-lite/poseidon2"; import { generateRandomKeys, PublicKey } from "paillier-bigint"; import type { Wallet } from "ethers"; +import { encodeTallyProof, finalBallots, proveTally } from "../../frontend/src/lib/tallyProof.js"; import { createHmac } from "node:crypto"; const { ethers } = await network.getOrCreate(); @@ -282,6 +283,7 @@ const PRIVATE_ENROLLMENT_TYPES = { PrivateEnrollment: [ { name: "identityCommitment", type: "uint256" }, { name: "humanTag", type: "uint256" }, + { name: "documentTag", type: "uint256" }, { name: "deadline", type: "uint256" }, ], } as const; @@ -321,17 +323,38 @@ async function electionIdentity(master: Identity, electionAddress: string): Prom * The tag that says "this person, here", and nothing anywhere else. * * MIRRORS `humanTagFor` in backend/src/eligibility/attester.ts, including the - * key it is derived under. The two have to agree, because a voter seeded here + * per-epoch key it is derived under (ENROLMENT_TAG_KEYS). The two have to agree, because a voter seeded here * may later enrol through the running dApp in the same election, and a second * tag for the same human would be a second leaf and a second vote. */ -function humanTagFor(worldIdNullifier: bigint, electionAddress: string): bigint { - if (!attesterKey) throw new Error("no attester key: cannot derive an enrolment tag"); - const tagKey = createHmac("sha256", "votain/enrolment-tag/v1").update(attesterKey).digest(); +function tagKeyFor(createdAtSeconds: number): string { + const d = new Date(createdAtSeconds * 1000); + const epoch = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`; + const secret = tagKeys?.[epoch]; + if (!secret) { + throw new Error( + `no enrolment tag key for epoch ${epoch}: set ENROLMENT_TAG_KEYS in backend/.env ` + + "(or SEED_TAG_KEYS here) to the same JSON the backend uses", + ); + } + return "0x" + createHmac("sha256", "votain/enrolment-tag/v2").update(secret).digest("hex"); +} + +function humanTagFor(worldIdNullifier: bigint, electionAddress: string, createdAt: number): bigint { return BigInt( ethers.solidityPackedKeccak256( ["bytes32", "uint256", "address"], - ["0x" + tagKey.toString("hex"), worldIdNullifier, ethers.getAddress(electionAddress)], + [tagKeyFor(createdAt), worldIdNullifier, ethers.getAddress(electionAddress)], + ), + ); +} + +/** MIRRORS `documentTagFor` in backend/src/eligibility/attester.ts, for the same reason. */ +function documentTagFor(documentNullifier: bigint, electionAddress: string, createdAt: number): bigint { + return BigInt( + ethers.solidityPackedKeccak256( + ["bytes32", "string", "uint256", "address"], + [tagKeyFor(createdAt), "votain/document-tag/v1", documentNullifier, ethers.getAddress(electionAddress)], ), ); } @@ -367,6 +390,24 @@ function readAttesterKey(): string | undefined { } const attesterKey = readAttesterKey(); + +/** The backend's ENROLMENT_TAG_KEYS, read the same way and for the same reason. */ +function readTagKeys(): Record | undefined { + const raw = + process.env.SEED_TAG_KEYS ?? + (() => { + try { + const backendEnv = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "backend", ".env"); + return readFileSync(backendEnv, "utf-8").match(/^ENROLMENT_TAG_KEYS=(.+)$/m)?.[1].trim(); + } catch { + return undefined; + } + })(); + if (!raw) return undefined; + return JSON.parse(raw.replace(/^'(.*)'$/, "$1")) as Record; +} + +const tagKeys = readTagKeys(); const attesterWallet = attesterKey ? new ethers.Wallet(attesterKey) : null; async function main(): Promise { @@ -591,12 +632,20 @@ async function main(): Promise { for (const v of participants) { if (enrolsPrivately) { const identity = await electionIdentity(v.identity, address); - const tag = humanTagFor(v.worldId, address); + const tag = humanTagFor(v.worldId, address, created); + // Stands in for the nullifier a document proof would yield, as below. + const documentTag = spec.eligibility + ? documentTagFor( + BigInt(ethers.keccak256(ethers.toUtf8Bytes(`seed-document-${address}-${v.worldId}`))) >> 8n, + address, + created, + ) + : 0n; const deadline = (await chainNow()) + 900; const signature = await (attesterWallet as Wallet).signTypedData( { name: "VotainElection", version: "1", chainId, verifyingContract: address }, PRIVATE_ENROLLMENT_TYPES as unknown as Record>, - { identityCommitment: identity.commitment, humanTag: tag, deadline }, + { identityCommitment: identity.commitment, humanTag: tag, documentTag, deadline }, ); // The gated case needs the organizer's gatekeeper too, and the seed // holds that key as well: same digest, same signer, same signature. @@ -605,6 +654,7 @@ async function main(): Promise { address, identity.commitment, tag, + documentTag, deadline, signature, spec.eligibility ? signature : "0x", @@ -653,26 +703,32 @@ async function main(): Promise { const group = new Group(voting.map(identity => identity.commitment)); const scope: bigint = await election.scope(); - const castFor = async (voterIndex: number, option: number): Promise => { + /** + * `sponsored` false submits straight to the election, paying its own gas. + * That is how the seed re-votes: the paymaster sponsors a re-vote only an + * hour after the last one, and waiting an hour here would push the chain + * clock an hour past the wall for every election that follows. + */ + const castFor = async (voterIndex: number, option: number, sponsored = true): Promise => { const identity = voting[voterIndex]; const ciphertext = encryptBallot(keys.publicKey, option); const nonce: bigint = await election.nullifierNonces(voteNullifier(identity, scope)); const proof = await generateProof(identity, group, voteMessage(ciphertext, nonce), scope); const p = proof.points.map(BigInt); + const args: [string, bigint, bigint, bigint, [bigint, bigint], [[bigint, bigint], [bigint, bigint]], [bigint, bigint]] = [ + ciphertext, + BigInt(proof.nullifier), + BigInt(proof.merkleTreeRoot), + BigInt(proof.merkleTreeDepth), + [p[0], p[1]], + [ + [p[2], p[3]], + [p[4], p[5]], + ], + [p[6], p[7]], + ]; await ( - await paymaster.relayVote( - address, - ciphertext, - BigInt(proof.nullifier), - BigInt(proof.merkleTreeRoot), - BigInt(proof.merkleTreeDepth), - [p[0], p[1]], - [ - [p[2], p[3]], - [p[4], p[5]], - ], - [p[6], p[7]], - ) + await (sponsored ? paymaster.relayVote(address, ...args) : election.castVote(...args)) ).wait(); }; @@ -681,7 +737,7 @@ async function main(): Promise { const finalChoices = [...spec.ballots]; if (spec.revote) { const [voterIndex, replacement] = spec.revote; - await castFor(voterIndex, replacement); + await castFor(voterIndex, replacement, false); finalChoices[voterIndex] = replacement; } @@ -694,10 +750,34 @@ async function main(): Promise { if (spec.finish === "publish") { await advanceTo(created + spec.voteTo + 60); - const counts = new Array(spec.candidates.length + 1).fill(0n); - for (const choice of finalChoices) counts[choice] += 1n; + // Proved exactly as the organizer's browser proves it, so the results + // screen of every seeded election verifies against its own ballots. + const events = await election.queryFilter(election.filters.VoteCast()); + const tally = proveTally({ + publicKey: { n: keys.publicKey.n, g: keys.publicKey.g }, + lambda: keys.privateKey.lambda, + decrypt: c => keys.privateKey.decrypt(c), + ballots: finalBallots( + events.map(e => { + const a = (e as unknown as { args: { nullifier: bigint; nonce: bigint; voteCiphertext: string } }).args; + return { nullifier: a.nullifier, nonce: a.nonce, ciphertext: a.voteCiphertext }; + }), + ), + slots: spec.candidates.length + 1, + base: COUNTER_BASE, + }); + const expected = new Array(spec.candidates.length + 1).fill(0n); + for (const choice of finalChoices) expected[choice] += 1n; + if (tally.counts.join() !== expected.join()) { + throw new Error(`${spec.name}: tallied ${tally.counts.join()}, expected ${expected.join()}`); + } await ( - await election.publishResults(`Qm${spec.name.slice(0, 8).replace(/\W/g, "")}Demo`, counts) + await election.publishResults( + `Qm${spec.name.slice(0, 8).replace(/\W/g, "")}Demo`, + tally.counts, + BigInt(tally.invalidBallots), + encodeTallyProof(tally.proof), + ) ).wait(); const outcomeNames = ["NONE", "WINNER", "TIE", "APPROVED", "REJECTED", "THRESHOLD_NOT_MET"]; diff --git a/contracts/test/E2E.test.ts b/contracts/test/E2E.test.ts index 01a52de..61475b6 100644 --- a/contracts/test/E2E.test.ts +++ b/contracts/test/E2E.test.ts @@ -11,7 +11,9 @@ * So this suite deploys SemaphoreVerifierV4, generates real proofs with the * official JS libraries, encrypts real Paillier ballots, routes everything * through ElectionPaymaster exactly as the relayer does, and decrypts the - * homomorphic tally at the end. + * homomorphic tally at the end, proving it with the same `tallyProof.ts` the + * browser and the auditor's CLI use, and checking that proof as any reader + * of the result would. */ import { expect } from "chai"; import { network } from "hardhat"; @@ -29,16 +31,26 @@ import { Outcome, Phase, } from "./fixtures.js"; +import { + decodeTallyProof, + encodeTallyProof, + finalBallots, + proveTally, + verifyTally, +} from "../../frontend/src/lib/tallyProof.js"; const { ethers, networkHelpers } = await network.create(); -const FORWARDER = "0x000000000000000000000000000000000000dEaD"; const COUNTER_BASE = 1_000_000n; // A 1024-bit Paillier key keeps the suite fast; production uses 2048. const PAILLIER_BITS = 1024; const toHex = (x: bigint): string => "0x" + x.toString(16); +const toBytes = (x: bigint): string => { + const digits = x.toString(16); + return "0x" + (digits.length % 2 === 0 ? digits : "0" + digits); +}; /** * Mirrors frontend/src/lib/paillier.ts encryptBallot, padding included: the @@ -116,7 +128,6 @@ describe("E2E, real Groth16 proofs, real Paillier, relayed like production", () }); const factory = await Factory.deploy( await paymaster.getAddress(), - FORWARDER, await verifier.getAddress(), await registry.getAddress(), platform.address, @@ -182,7 +193,7 @@ describe("E2E, real Groth16 proofs, real Paillier, relayed like production", () await ( await stack.paymaster .connect(relayer) - .relayEnrollPrivate(address, identity.commitment, tag, deadline, signature, "0x") + .relayEnrollPrivate(address, identity.commitment, tag, 0n, deadline, signature, "0x") ).wait(); return identity; @@ -192,6 +203,8 @@ describe("E2E, real Groth16 proofs, real Paillier, relayed like production", () const now = await networkHelpers.time.latest(); const cfg = baseConfig(now, { numOptions: 3n, + // Long enough for a sponsored re-vote after ElectionPaymaster.REVOTE_COOLDOWN. + voteEnd: now + 4 * 3600, paillierPublicKey: JSON.stringify({ n: toHex(paillier.publicKey.n), g: toHex(paillier.publicKey.g), @@ -205,19 +218,48 @@ describe("E2E, real Groth16 proofs, real Paillier, relayed like production", () return ethers.getContractAt("ElectionV4", await stack.factory.elections(count - 1n)); } + /** Tallies an ended election from its events, proves it and publishes it. */ + async function proveAndPublish(election: any, slots: number, cid: string): Promise { + const events = await election.queryFilter(election.filters.VoteCast()); + const ballots = finalBallots( + events.map((e: any) => ({ + nullifier: e.args.nullifier, + nonce: e.args.nonce, + ciphertext: e.args.voteCiphertext, + })), + ); + const tally = proveTally({ + publicKey: { n: paillier.publicKey.n, g: paillier.publicKey.g }, + lambda: paillier.privateKey.lambda, + decrypt: c => paillier.privateKey.decrypt(c), + ballots, + slots, + base: COUNTER_BASE, + }); + await ( + await election + .connect(organizer) + .publishResults(cid, tally.counts, BigInt(tally.invalidBallots), encodeTallyProof(tally.proof)) + ).wait(); + return tally.counts; + } + /** Full relayed vote: encrypt, prove, submit through the paymaster. */ async function relayedVote( election: any, identity: Identity, group: Group, optionIndex: number, + /** Overrides the honest plaintext, to cast a malformed or stuffed ballot. */ + plaintext?: bigint, ): Promise<{ ciphertext: string; nullifier: bigint }> { const scope: bigint = await election.scope(); const pkJson: string = await election.paillierPublicKey(); const parsed = JSON.parse(pkJson) as { n: string; g: string }; const pk = new PublicKey(BigInt(parsed.n), BigInt(parsed.g)); - const ciphertext = encryptBallot(pk, optionIndex); + const ciphertext = + plaintext === undefined ? encryptBallot(pk, optionIndex) : toBytes(pk.encrypt(plaintext)); // The nonce must be read BEFORE proving: it is bound into the message. const nonce: bigint = await election.nullifierNonces(voteNullifier(identity, scope)); @@ -255,82 +297,109 @@ describe("E2E, real Groth16 proofs, real Paillier, relayed like production", () const alice = await newVoter("alice"); const bob = await newVoter("bob"); const carol = await newVoter("carol"); + const mallory = await newVoter("mallory"); const voting = new Map(); - for (const v of [alice, bob, carol]) { + for (const v of [alice, bob, carol, mallory]) { voting.set(v.seed, await enrolPrivately(election, v)); } - expect(await election.memberCount()).to.equal(3n); + expect(await election.memberCount()).to.equal(4n); // NONE of these are the commitments the registry knows. That is the point: - // the tree says three verified humans joined, and nothing on chain says - // which three, nor what else they have joined. - for (const v of [alice, bob, carol]) { + // the tree says four verified humans joined, and nothing on chain says + // which four, nor what else they have joined. + for (const v of [alice, bob, carol, mallory]) { expect(await election.hasMember(v.identity.commitment)).to.equal(false); expect(await election.hasMember(voting.get(v.seed)!.commitment)).to.equal(true); } - const group = new Group([ - voting.get("alice")!.commitment, - voting.get("bob")!.commitment, - voting.get("carol")!.commitment, - ]); + const group = new Group([...voting.values()].map(identity => identity.commitment)); expect(BigInt(group.root)).to.equal(await election.merkleTreeRoot()); // ── Voting ── await networkHelpers.time.increaseTo((await election.voteStart()) + 1n); - const ballots: string[] = []; - ballots.push((await relayedVote(election, voting.get("alice")!, group, 0)).ciphertext); - ballots.push((await relayedVote(election, voting.get("bob")!, group, 0)).ciphertext); + await relayedVote(election, voting.get("alice")!, group, 0); + await relayedVote(election, voting.get("bob")!, group, 0); + // Mallory stuffs the box: five votes for option 2 in one ciphertext. The + // proof of membership is valid; only the tally proof can catch this. + await relayedVote(election, voting.get("mallory")!, group, 0, 5n * COUNTER_BASE ** 2n); // Carol votes for option 2, then changes her mind: coercion resistance means - // only her highest-nonce ballot may count. + // only her highest-nonce ballot may count. The paymaster sponsors the + // re-vote once its cooldown has passed. const carolIdentity = voting.get("carol")!; const coerced = await relayedVote(election, carolIdentity, group, 2); + await networkHelpers.time.increase(Number(await stack.paymaster.REVOTE_COOLDOWN())); const real = await relayedVote(election, carolIdentity, group, 1); expect(real.nullifier).to.equal(coerced.nullifier); expect(await election.nullifierNonces(real.nullifier)).to.equal(2n); - ballots.push(real.ciphertext); - expect(await election.voteCount()).to.equal(4n); // 3 voters, 4 emitted ballots + expect(await election.voteCount()).to.equal(5n); // 4 voters, 5 emitted ballots + expect(await election.distinctVoters()).to.equal(4n); - // ── Tally: dedup by max nonce, homomorphic sum, decrypt ── + // ── Tally: last ballot per voter, classified, proved ── const events = await election.queryFilter(election.filters.VoteCast()); - const latest = new Map(); - for (const e of events) { - const a = (e as any).args; - const key = a.nullifier.toString(); - const seen = latest.get(key); - if (!seen || a.nonce > seen.nonce) latest.set(key, { ciphertext: a.voteCiphertext, nonce: a.nonce }); - } - expect(latest.size).to.equal(3); - - const aggregated = [...latest.values()] - .map(v => BigInt(v.ciphertext)) - .reduce((acc, c) => paillier.publicKey.addition(acc, c)); - - let remaining = paillier.privateKey.decrypt(aggregated); - const counts: bigint[] = []; - for (let i = 0; i < 4; i++) { - counts.push(remaining % COUNTER_BASE); - remaining /= COUNTER_BASE; - } - expect(remaining).to.equal(0n); + const ballots = finalBallots( + events.map((e: unknown) => { + const a = (e as any).args; + return { nullifier: a.nullifier, nonce: a.nonce, ciphertext: a.voteCiphertext }; + }), + ); + expect(ballots.length).to.equal(4); + + const publicKey = { n: paillier.publicKey.n, g: paillier.publicKey.g }; + const tally = proveTally({ + publicKey, + lambda: paillier.privateKey.lambda, + decrypt: c => paillier.privateKey.decrypt(c), + ballots, + slots: 4, + base: COUNTER_BASE, + }); // Alice + Bob on option 0, Carol's REPLACEMENT on option 1, nothing on the - // coerced option 2. The overridden ballot is invisible in the total. - expect(counts.slice(0, 3)).to.deep.equal([2n, 1n, 0n]); + // coerced option 2, and Mallory's five votes excluded in the open. + expect(tally.counts).to.deep.equal([2n, 1n, 0n, 0n]); + expect(tally.invalidBallots).to.equal(1); - // ── Publish ── + // ── Publish, with the proof ── await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); - // publishResults expects numOptions + 1 entries: the blank slot counts too. - await (await election.connect(organizer).publishResults("QmAuditTrail", counts)).wait(); + const proofBytes = encodeTallyProof(tally.proof); + await ( + await election + .connect(organizer) + .publishResults("QmAuditTrail", tally.counts, BigInt(tally.invalidBallots), proofBytes) + ).wait(); expect(await election.resultsPublished()).to.equal(true); expect(await election.outcome()).to.equal(Outcome.WINNER); expect(await election.winnerIndex()).to.equal(0n); expect(await election.phase()).to.equal(Phase.CLOSED); + + // ── Audit, as any reader: from the chain alone, with no key ── + const [published] = await election.queryFilter(election.filters.TallyProofPublished()); + const args = (published as any).args; + const verdict = verifyTally({ + publicKey, + ballots, + counts: [...(await election.tally())], + invalidBallots: args.invalidBallots, + proof: decodeTallyProof(args.proof), + base: COUNTER_BASE, + }); + expect(verdict).to.deep.equal({ ok: true, validBallots: 3, invalidBallots: 1 }); + + // And the stuffed tally an organizer might have preferred does not verify. + const stuffed = verifyTally({ + publicKey, + ballots, + counts: [2n, 1n, 5n, 0n], + invalidBallots: 0, + proof: tally.proof, + base: COUNTER_BASE, + }); + expect(stuffed.ok).to.equal(false); }); it("rejects a proof from someone outside the group", async function () { @@ -439,7 +508,13 @@ describe("E2E, real Groth16 proofs, real Paillier, relayed like production", () await (await stack.paymaster.connect(relayer).relayVote(...args)).wait(); // The nonce has moved on, so the same proof no longer binds the message. - await expect(stack.paymaster.connect(relayer).relayVote(...args)).to.be.revert(ethers); + // Submitted straight to the election, past the paymaster's cooldown, so it + // is the proof that is refused and not the sponsorship. + const [, ...castArgs] = args; + await expect(election.connect(relayer).castVote(...castArgs)).to.be.revertedWithCustomError( + election, + "InvalidProof", + ); expect(await election.voteCount()).to.equal(1n); }); @@ -467,7 +542,7 @@ describe("E2E, real Groth16 proofs, real Paillier, relayed like production", () await relayedVote(election, voting[2], group, 1); await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); - await (await election.connect(organizer).publishResults("QmTwoThirds", [2n, 1n, 0n])).wait(); + expect(await proveAndPublish(election, 3, "QmTwoThirds")).to.deep.equal([2n, 1n, 0n]); expect(await election.outcome()).to.equal(Outcome.APPROVED); }); diff --git a/contracts/test/Election.test.ts b/contracts/test/Election.test.ts index 5d118b9..391842c 100644 --- a/contracts/test/Election.test.ts +++ b/contracts/test/Election.test.ts @@ -8,13 +8,13 @@ import { Phase, Outcome, DUMMY_PROOF, + PLACEHOLDER_TALLY_PROOF, ZERO_ADDRESS, type Stack, } from "./fixtures.js"; const { ethers, networkHelpers } = await network.create(); -const FORWARDER = ethers.Wallet.createRandom().address; let stack: Stack; let organizer: any; @@ -22,7 +22,7 @@ let voter: any; before(async () => { [, organizer, voter] = await ethers.getSigners(); - stack = await deployStack(ethers, FORWARDER); + stack = await deployStack(ethers); }); /// Registers a member on the platform registry (issuer-owned) so it can enroll. @@ -32,7 +32,27 @@ async function platformRegister(nullifier: bigint, commitment: bigint): Promise< async function freshElection(overrides = {}): Promise { const now = await networkHelpers.time.latest(); - return deployElection(ethers, stack, FORWARDER, organizer, baseConfig(now, overrides)); + return deployElection(ethers, stack, organizer, baseConfig(now, overrides)); +} + +/// An election whose voter count the test sets directly, for outcome arithmetic. +async function harnessElection(overrides = {}): Promise { + const now = await networkHelpers.time.latest(); + return deployElection( + ethers, + stack, + organizer, + baseConfig(now, overrides), + ZERO_ADDRESS, + "ElectionV4Harness", + ); +} + +/// Publishes with the placeholder proof and no excluded ballots. +function publishCall(election: any, counts: bigint[], invalid = 0n) { + return election + .connect(organizer) + .publishResults("cid", counts, invalid, PLACEHOLDER_TALLY_PROOF); } let commitmentSeq = 1_000n; @@ -50,7 +70,6 @@ describe("ElectionV4, config validation", () => { const deployWith = (cfg: ReturnType) => Election.deploy( - FORWARDER, stack.verifier.getAddress(), stack.registry.getAddress(), ZERO_ADDRESS, @@ -92,7 +111,6 @@ describe("ElectionV4, config validation", () => { const deployWith = (cfg: ReturnType) => Election.deploy( - FORWARDER, stack.verifier.getAddress(), stack.registry.getAddress(), ZERO_ADDRESS, @@ -404,9 +422,73 @@ describe("ElectionV4, organizer lifecycle", () => { await expect(election.connect(organizer).markVoided()).to.emit(election, "ElectionVoided"); expect(await election.phase()).to.equal(Phase.VOIDED); + await expect(publishCall(election, [1n, 1n, 1n, 0n])).to.be.revertedWithCustomError( + election, + "AlreadyDecided", + ); + }); + + it("takes no ballot at voteEnd itself, the second phase() reads TALLYING", async () => { + const election = await freshElection(); + const { nullifier, commitment } = nextCommitment(); + await platformRegister(nullifier, commitment); + await (await election.connect(voter).enroll(commitment)).wait(); + await networkHelpers.time.increaseTo(await election.voteStart()); + const root = await election.merkleTreeRoot(); + + await networkHelpers.time.setNextBlockTimestamp(await election.voteEnd()); await expect( - election.connect(organizer).publishResults("cid", [1n, 1n, 1n, 0n]), - ).to.be.revertedWithCustomError(election, "AlreadyDecided"); + election.connect(voter).castVote("0x01", nullifier, root, 1n, DUMMY_PROOF.pA, DUMMY_PROOF.pB, DUMMY_PROOF.pC), + ).to.be.revertedWithCustomError(election, "VotingNotOpen"); + // The refused call mined nothing, so the pinned timestamp is still pending. + await networkHelpers.mine(); + }); +}); + +describe("ElectionV4, ballot size", () => { + it("refuses an empty ballot and one longer than any Paillier ciphertext", async () => { + const election = await freshElection(); + const { nullifier, commitment } = nextCommitment(); + await platformRegister(nullifier, commitment); + await (await election.connect(voter).enroll(commitment)).wait(); + await networkHelpers.time.increaseTo(await election.voteStart()); + const root = await election.merkleTreeRoot(); + const cast = (ballot: string) => + election.connect(voter).castVote(ballot, nullifier, root, 1n, DUMMY_PROOF.pA, DUMMY_PROOF.pB, DUMMY_PROOF.pC); + + expect(await election.MAX_BALLOT_BYTES()).to.equal(512n); + await expect(cast("0x")).to.be.revertedWithCustomError(election, "InvalidBallot"); + await expect(cast("0x" + "ab".repeat(513))).to.be.revertedWithCustomError(election, "InvalidBallot"); + await expect(cast("0x" + "ab".repeat(512))).to.emit(election, "VoteCast"); + }); +}); + +describe("ElectionV4, voiding an election that promised no veto", () => { + async function tallying(overrides = {}) { + const election = await harnessElection(overrides); + await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); + return election; + } + + it("refuses to void a publishable result once cancelling was given up", async () => { + const election = await tallying({ cancellable: false, privacyQuorum: 2n }); + await (await election.forceDistinctVoters(2n)).wait(); + await expect(election.connect(organizer).markVoided()).to.be.revertedWithCustomError( + election, + "ResultPublishable", + ); + }); + + it("still voids below the privacy quorum, where nothing may be published", async () => { + const election = await tallying({ cancellable: false, privacyQuorum: 3n }); + await (await election.forceDistinctVoters(2n)).wait(); + await expect(election.connect(organizer).markVoided()).to.emit(election, "ElectionVoided"); + }); + + it("keeps the veto for an election that kept the power to be called off", async () => { + const election = await tallying({ cancellable: true }); + await (await election.forceDistinctVoters(5n)).wait(); + await expect(election.connect(organizer).markVoided()).to.emit(election, "ElectionVoided"); }); }); @@ -458,9 +540,10 @@ describe("ElectionV4, privacy quorum", () => { await withVoters(election, 2); await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); - await expect( - election.connect(organizer).publishResults("cid", [1n, 1n, 0n, 0n]), - ).to.be.revertedWithCustomError(election, "PrivacyQuorumNotMet"); + await expect(publishCall(election, [1n, 1n, 0n, 0n])).to.be.revertedWithCustomError( + election, + "PrivacyQuorumNotMet", + ); }); it("leaves voiding as the way out, so the election still reaches an end", async () => { @@ -477,7 +560,7 @@ describe("ElectionV4, privacy quorum", () => { await withVoters(election, 3); await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); - await (await election.connect(organizer).publishResults("cid", [2n, 1n, 0n, 0n])).wait(); + await (await publishCall(election, [2n, 1n, 0n, 0n])).wait(); expect(await election.phase()).to.equal(Phase.CLOSED); expect(await election.distinctVoters()).to.equal(3n); }); @@ -486,7 +569,7 @@ describe("ElectionV4, privacy quorum", () => { const election = await freshElection(); expect(await election.privacyQuorum()).to.equal(0n); await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); - await (await election.connect(organizer).publishResults("cid", [1n, 0n, 0n, 0n])).wait(); + await (await publishCall(election, [0n, 0n, 0n, 0n])).wait(); expect(await election.phase()).to.equal(Phase.CLOSED); }); }); @@ -499,7 +582,6 @@ describe("ElectionV4, option ceiling", () => { }); const deployWith = (cfg: ReturnType) => Election.deploy( - FORWARDER, stack.verifier.getAddress(), stack.registry.getAddress(), ZERO_ADDRESS, @@ -519,22 +601,60 @@ describe("ElectionV4, option ceiling", () => { }); describe("ElectionV4, publishResults & outcomes", () => { + /// Publishes `tallyArr` on an election whose recorded voters it accounts for. async function publish(election: any, tallyArr: bigint[]): Promise { await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); - await (await election.connect(organizer).publishResults("QmTestCid", tallyArr)).wait(); + await (await election.forceDistinctVoters(tallyArr.reduce((a, b) => a + b, 0n))).wait(); + await ( + await election + .connect(organizer) + .publishResults("QmTestCid", tallyArr, 0n, PLACEHOLDER_TALLY_PROOF) + ).wait(); } + const freshElection = harnessElection; + it("validates timing and tally length", async () => { const election = await freshElection(); // 3 options → tally length must be 4 - await expect( - election.connect(organizer).publishResults("cid", [1n, 2n, 3n, 0n]), - ).to.be.revertedWithCustomError(election, "VotingNotEnded"); + await expect(publishCall(election, [0n, 0n, 0n, 0n])).to.be.revertedWithCustomError( + election, + "VotingNotEnded", + ); + + await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); + await expect(publishCall(election, [0n, 0n, 0n])).to.be.revertedWithCustomError( + election, + "InvalidTally", + ); + }); + it("requires the counters and the excluded ballots to account for every voter", async () => { + const election = await freshElection(); + await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); + await (await election.forceDistinctVoters(5n)).wait(); + + // An invented ballot, and a dropped one. + await expect(publishCall(election, [3n, 2n, 1n, 0n])).to.be.revertedWithCustomError( + election, + "InvalidTally", + ); + await expect(publishCall(election, [2n, 1n, 1n, 0n])).to.be.revertedWithCustomError( + election, + "InvalidTally", + ); + // One ballot excluded as invalid, openly: four counted plus one excluded. + await expect(publishCall(election, [2n, 1n, 1n, 0n], 1n)) + .to.emit(election, "TallyProofPublished") + .withArgs(1n, PLACEHOLDER_TALLY_PROOF); + }); + + it("refuses a result published without its proof", async () => { + const election = await freshElection(); await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); await expect( - election.connect(organizer).publishResults("cid", [1n, 2n, 3n]), - ).to.be.revertedWithCustomError(election, "InvalidTally"); + election.connect(organizer).publishResults("cid", [0n, 0n, 0n, 0n], 0n, "0x"), + ).to.be.revertedWithCustomError(election, "MissingTallyProof"); }); it("SIMPLE_PLURALITY: highest vote count wins; equal top is a tie", async () => { @@ -628,9 +748,10 @@ describe("ElectionV4, publishResults & outcomes", () => { it("publishing twice is impossible", async () => { const election = await freshElection(); await publish(election, [1n, 2n, 3n, 0n]); - await expect( - election.connect(organizer).publishResults("cid2", [9n, 9n, 9n, 9n]), - ).to.be.revertedWithCustomError(election, "AlreadyDecided"); + await expect(publishCall(election, [1n, 2n, 3n, 0n])).to.be.revertedWithCustomError( + election, + "AlreadyDecided", + ); }); it("exposes the stored tally", async () => { diff --git a/contracts/test/Eligibility.test.ts b/contracts/test/Eligibility.test.ts index c42c789..0c25155 100644 --- a/contracts/test/Eligibility.test.ts +++ b/contracts/test/Eligibility.test.ts @@ -12,7 +12,6 @@ import { const { ethers, networkHelpers } = await network.create(); -const FORWARDER = ethers.Wallet.createRandom().address; const POLICY_HASH = ethers.keccak256( ethers.toUtf8Bytes('{"minAge":18,"allowedCountries":["ESP"]}'), ); @@ -25,7 +24,7 @@ let chainId: bigint; before(async () => { [, organizer, attester, outsider] = await ethers.getSigners(); - stack = await deployStack(ethers, FORWARDER); + stack = await deployStack(ethers); chainId = (await ethers.provider.getNetwork()).chainId; }); @@ -56,7 +55,6 @@ async function gatedElection(overrides = {}): Promise { return deployElection( ethers, stack, - FORWARDER, organizer, baseConfig(now, { eligibilityAttester: attester.address, @@ -71,7 +69,7 @@ async function gatedElection(overrides = {}): Promise { async function openElection(): Promise { const now = await networkHelpers.time.latest(); - return deployElection(ethers, stack, FORWARDER, organizer, baseConfig(now)); + return deployElection(ethers, stack, organizer, baseConfig(now)); } async function futureDeadline(): Promise { @@ -87,7 +85,6 @@ describe("ElectionV4, eligibility config", () => { const deployWith = (cfg: ReturnType) => Election.deploy( - FORWARDER, stack.verifier.getAddress(), stack.registry.getAddress(), ZERO_ADDRESS, @@ -112,7 +109,6 @@ describe("ElectionV4, eligibility config", () => { libraries: { PoseidonT3: stack.poseidonAddress }, }); return Election.deploy( - FORWARDER, stack.verifier.getAddress(), stack.registry.getAddress(), ZERO_ADDRESS, diff --git a/contracts/test/Factory.test.ts b/contracts/test/Factory.test.ts index b6a8f03..f04a9d6 100644 --- a/contracts/test/Factory.test.ts +++ b/contracts/test/Factory.test.ts @@ -4,14 +4,13 @@ import { deployStack, baseConfig, VotingType, type Stack } from "./fixtures.js"; const { ethers, networkHelpers } = await network.create(); -const FORWARDER = ethers.Wallet.createRandom().address; let stack: Stack; let organizer: any; before(async () => { [, organizer] = await ethers.getSigners(); - stack = await deployStack(ethers, FORWARDER); + stack = await deployStack(ethers); }); describe("ElectionFactory", () => { diff --git a/contracts/test/Paymaster.test.ts b/contracts/test/Paymaster.test.ts index f6796ee..9993acc 100644 --- a/contracts/test/Paymaster.test.ts +++ b/contracts/test/Paymaster.test.ts @@ -1,10 +1,9 @@ import { expect } from "chai"; import { network } from "hardhat"; -import { deployStack, baseConfig, type Stack } from "./fixtures.js"; +import { deployStack, baseConfig, DUMMY_PROOF, type Stack } from "./fixtures.js"; const { ethers, networkHelpers } = await network.create(); -const FORWARDER = "0x000000000000000000000000000000000000dEaD"; let owner: any; let organizer: any; @@ -14,7 +13,7 @@ let stack: Stack; before(async () => { [owner, organizer, relayer, stranger] = await ethers.getSigners(); - stack = await deployStack(ethers, FORWARDER); + stack = await deployStack(ethers); }); describe("ElectionPaymaster, gas tank", () => { @@ -594,3 +593,99 @@ describe("ElectionFactory, funding a new election from both sources", () => { expect(await stack.factory.electionsCount()).to.equal(before); }); }); + +describe("ElectionPaymaster, what its owner cannot do", () => { + it("names the factory once, so no later factory can claim anybody's balance", async () => { + const Paymaster = await ethers.getContractFactory("ElectionPaymaster"); + const paymaster = await Paymaster.connect(owner).deploy(); + await paymaster.waitForDeployment(); + + await (await paymaster.connect(owner).setFactory(stranger.address)).wait(); + await expect( + paymaster.connect(owner).setFactory(owner.address), + ).to.be.revertedWithCustomError(paymaster, "FactoryAlreadySet"); + }); + + it("lets even the factory spend a balance only on that organizer's own election", async () => { + const Paymaster = await ethers.getContractFactory("ElectionPaymaster"); + const paymaster = await Paymaster.connect(owner).deploy(); + await paymaster.waitForDeployment(); + // An account standing in for the factory, to drive the factory-only paths. + await (await paymaster.connect(owner).setFactory(relayer.address)).wait(); + + const election = ethers.Wallet.createRandom().address; + await (await paymaster.connect(relayer).registerElection(election, stranger.address)).wait(); + await (await paymaster.connect(organizer).deposit({ value: ethers.parseEther("1") })).wait(); + + // The organizer's money, pointed at somebody else's election. + await expect( + paymaster.connect(relayer).reserveFromBalanceFor(election, organizer.address, 1n), + ).to.be.revertedWithCustomError(paymaster, "NotElectionOrganizer"); + }); + + it("keeps the relay parameters inside fixed bounds", async () => { + const p = stack.paymaster.connect(owner); + const gwei = 1_000_000_000n; + await expect(p.setRelayParams(501n * gwei, 32_000n, 16n, 2_000_000n)).to.be.revertedWithCustomError( + stack.paymaster, + "RelayParamsOutOfBounds", + ); + await expect(p.setRelayParams(50n * gwei, 100_001n, 16n, 2_000_000n)).to.be.revertedWithCustomError( + stack.paymaster, + "RelayParamsOutOfBounds", + ); + await expect(p.setRelayParams(50n * gwei, 32_000n, 17n, 2_000_000n)).to.be.revertedWithCustomError( + stack.paymaster, + "RelayParamsOutOfBounds", + ); + await expect(p.setRelayParams(50n * gwei, 32_000n, 16n, 3_000_001n)).to.be.revertedWithCustomError( + stack.paymaster, + "RelayParamsOutOfBounds", + ); + await expect(p.setRelayParams(0n, 32_000n, 16n, 2_000_000n)).to.be.revertedWithCustomError( + stack.paymaster, + "RelayParamsOutOfBounds", + ); + await expect(p.setRelayParams(500n * gwei, 100_000n, 16n, 3_000_000n)).to.emit( + stack.paymaster, + "RelayParamsChanged", + ); + // Back to the defaults for the tests that follow. + await (await p.setRelayParams(50n * gwei, 32_000n, 16n, 2_000_000n)).wait(); + }); +}); + +describe("ElectionPaymaster, sponsored re-votes", () => { + it("sponsors a first ballot at once and a re-vote only after the cooldown", async () => { + const now = await networkHelpers.time.latest(); + await ( + await stack.factory + .connect(organizer) + // Voting open long enough for a re-vote after the cooldown. + .createElection(baseConfig(now, { voteEnd: now + 3 * 3600 }), 0n, { + value: ethers.parseEther("1"), + }) + ).wait(); + const address = await stack.factory.elections((await stack.factory.electionsCount()) - 1n); + const election = await ethers.getContractAt("ElectionV4", address); + + const nullifier = 88_001n; + const commitment = 88_002n; + await (await stack.registry.registerMember(nullifier, commitment)).wait(); + await (await stack.paymaster.connect(relayer).relayEnroll(address, commitment)).wait(); + await networkHelpers.time.increaseTo(await election.voteStart()); + const root = await election.merkleTreeRoot(); + const vote = (ballot: string) => + stack.paymaster + .connect(relayer) + .relayVote(address, ballot, 7n, root, 1n, DUMMY_PROOF.pA, DUMMY_PROOF.pB, DUMMY_PROOF.pC); + + await (await vote("0x01")).wait(); + await expect(vote("0x02")).to.be.revertedWithCustomError(stack.paymaster, "RevoteTooSoon"); + + // The override is delayed, never refused: after the cooldown it lands. + await networkHelpers.time.increase(Number(await stack.paymaster.REVOTE_COOLDOWN())); + await expect(vote("0x02")).to.emit(election, "VoteCast"); + expect(await election.nullifierNonces(7n)).to.equal(2n); + }); +}); diff --git a/contracts/test/PrivateEnrollment.test.ts b/contracts/test/PrivateEnrollment.test.ts index bca182f..dff0bf8 100644 --- a/contracts/test/PrivateEnrollment.test.ts +++ b/contracts/test/PrivateEnrollment.test.ts @@ -30,7 +30,6 @@ import { const { ethers, networkHelpers } = await network.create(); -const FORWARDER = ethers.Wallet.createRandom().address; const POLICY_HASH = ethers.keccak256( ethers.toUtf8Bytes('{"minAge":18,"allowedCountries":["ESP"]}'), ); @@ -45,8 +44,8 @@ let chainId: bigint; before(async () => { [, organizer, platform, eligibility, outsider] = await ethers.getSigners(); - stack = await deployStack(ethers, FORWARDER, platform.address); - legacyStack = await deployStack(ethers, FORWARDER); + stack = await deployStack(ethers, platform.address); + legacyStack = await deployStack(ethers); chainId = (await ethers.provider.getNetwork()).chainId; }); @@ -73,7 +72,6 @@ async function privateElection(overrides = {}): Promise { return deployElection( ethers, stack, - FORWARDER, organizer, baseConfig(await now(), overrides), platform.address, @@ -102,7 +100,7 @@ describe("private enrollment", () => { ); await expect( - election.enrollPrivate(commitment, tag, deadline, signature, "0x"), + election.enrollPrivate(commitment, tag, 0n, deadline, signature, "0x"), ).to.emit(election, "MemberEnrolled"); expect(await election.hasMember(commitment)).to.equal(true); @@ -121,6 +119,7 @@ describe("private enrollment", () => { await election.enrollPrivate( first, tag, + 0n, deadline, await signPrivateEnrollment(platform, address, chainId, first, tag, deadline), "0x", @@ -134,6 +133,7 @@ describe("private enrollment", () => { election.enrollPrivate( second, tag, + 0n, deadline, await signPrivateEnrollment(platform, address, chainId, second, tag, deadline), "0x", @@ -170,7 +170,7 @@ describe("private enrollment", () => { ); await expect( - election.enrollPrivate(commitment, tag, deadline, forged, "0x"), + election.enrollPrivate(commitment, tag, 0n, deadline, forged, "0x"), ).to.be.revertedWithCustomError(election, "BadAttestation"); }); @@ -191,7 +191,7 @@ describe("private enrollment", () => { ); await expect( - two.enrollPrivate(commitment, tag, deadline, forOne, "0x"), + two.enrollPrivate(commitment, tag, 0n, deadline, forOne, "0x"), ).to.be.revertedWithCustomError(two, "BadAttestation"); }); @@ -206,6 +206,7 @@ describe("private enrollment", () => { election.enrollPrivate( commitment, tag, + 0n, stale, await signPrivateEnrollment(platform, address, chainId, commitment, tag, stale), "0x", @@ -217,6 +218,7 @@ describe("private enrollment", () => { election.enrollPrivate( commitment, 0n, + 0n, deadline, await signPrivateEnrollment(platform, address, chainId, commitment, 0n, deadline), "0x", @@ -252,7 +254,6 @@ describe("private enrollment", () => { const election = await deployElection( ethers, legacyStack, - FORWARDER, organizer, baseConfig(await now()), ); @@ -265,6 +266,7 @@ describe("private enrollment", () => { election.enrollPrivate( commitment, tag, + 0n, deadline, await signPrivateEnrollment(platform, address, chainId, commitment, tag, deadline), "0x", @@ -281,42 +283,96 @@ describe("private enrollment", () => { const address = await election.getAddress(); const commitment = derivedCommitment(); const tag = humanTagFor(15n, address); + const doc = 9_001n; const deadline = (await now()) + 600; - const platformSig = await signPrivateEnrollment( - platform, - address, - chainId, - commitment, - tag, - deadline, - ); + const sign = (who: any) => + signPrivateEnrollment(who, address, chainId, commitment, tag, deadline, doc); + const platformSig = await sign(platform); // The platform says "a verified human, not yet enrolled here". It does not // say "over eighteen and Spanish", and on this election that is a second // question with a second answer. await expect( - election.enrollPrivate(commitment, tag, deadline, platformSig, "0x"), + election.enrollPrivate(commitment, tag, doc, deadline, platformSig, "0x"), ).to.be.revertedWithCustomError(election, "BadAttestation"); await expect( - election.enrollPrivate( + election.enrollPrivate(commitment, tag, doc, deadline, platformSig, await sign(outsider)), + ).to.be.revertedWithCustomError(election, "BadAttestation"); + + await expect( + election.enrollPrivate(commitment, tag, doc, deadline, platformSig, await sign(eligibility)), + ).to.emit(election, "MemberEnrolled"); + expect(await election.usedPersonhoodNullifiers(doc)).to.equal(true); + }); + + it("refuses one document behind two accounts on a gated election", async () => { + const election = await privateElection({ + eligibilityAttester: eligibility.address, + eligibilityPolicyHash: POLICY_HASH, + personhood: 1, + }); + const address = await election.getAddress(); + const doc = 4_242n; + const deadline = (await now()) + 600; + + // Two World ID accounts, so two different human tags, one passport. + const enrol = async (account: bigint) => { + const commitment = derivedCommitment(); + const tag = humanTagFor(account, address); + const sign = (who: any) => + signPrivateEnrollment(who, address, chainId, commitment, tag, deadline, doc); + return election.enrollPrivate( commitment, tag, + doc, deadline, - platformSig, - await signPrivateEnrollment(outsider, address, chainId, commitment, tag, deadline), - ), - ).to.be.revertedWithCustomError(election, "BadAttestation"); + await sign(platform), + await sign(eligibility), + ); + }; + + await (await enrol(1_001n)).wait(); + await expect(enrol(1_002n)).to.be.revertedWithCustomError( + election, + "PersonhoodNullifierUsed", + ); + }); + it("insists on the document tag exactly where a document is required", async () => { + const gated = await privateElection({ + eligibilityAttester: eligibility.address, + eligibilityPolicyHash: POLICY_HASH, + personhood: 1, + }); + const gatedAddress = await gated.getAddress(); + const commitment = derivedCommitment(); + const deadline = (await now()) + 600; + const tag = humanTagFor(31n, gatedAddress); await expect( - election.enrollPrivate( + gated.enrollPrivate( commitment, tag, + 0n, deadline, - platformSig, - await signPrivateEnrollment(eligibility, address, chainId, commitment, tag, deadline), + await signPrivateEnrollment(platform, gatedAddress, chainId, commitment, tag, deadline), + await signPrivateEnrollment(eligibility, gatedAddress, chainId, commitment, tag, deadline), ), - ).to.emit(election, "MemberEnrolled"); + ).to.be.revertedWithCustomError(gated, "MissingDocumentTag"); + + const open = await privateElection(); + const openAddress = await open.getAddress(); + const openTag = humanTagFor(31n, openAddress); + await expect( + open.enrollPrivate( + commitment, + openTag, + 7n, + deadline, + await signPrivateEnrollment(platform, openAddress, chainId, commitment, openTag, deadline, 7n), + "0x", + ), + ).to.be.revertedWithCustomError(open, "UnexpectedDocumentTag"); }); it("refuses outside the enrollment window", async () => { @@ -336,6 +392,7 @@ describe("private enrollment", () => { election.enrollPrivate( commitment, tag, + 0n, deadline, await signPrivateEnrollment(platform, address, chainId, commitment, tag, deadline), "0x", @@ -372,7 +429,7 @@ describe("private enrollment", () => { await ( await stack.paymaster .connect(outsider) - .relayEnrollPrivate(address, commitment, tag, deadline, signature, "0x") + .relayEnrollPrivate(address, commitment, tag, 0n, deadline, signature, "0x") ).wait(); expect(await election.hasMember(commitment)).to.equal(true); diff --git a/contracts/test/fixtures.ts b/contracts/test/fixtures.ts index 1589577..39e41ae 100644 --- a/contracts/test/fixtures.ts +++ b/contracts/test/fixtures.ts @@ -78,6 +78,7 @@ export function privateEnrollmentTypedData( identityCommitment: bigint, humanTag: bigint, deadline: number | bigint, + documentTag: bigint = 0n, ) { return { domain: { @@ -90,10 +91,11 @@ export function privateEnrollmentTypedData( PrivateEnrollment: [ { name: "identityCommitment", type: "uint256" }, { name: "humanTag", type: "uint256" }, + { name: "documentTag", type: "uint256" }, { name: "deadline", type: "uint256" }, ], }, - value: { identityCommitment, humanTag, deadline }, + value: { identityCommitment, humanTag, documentTag, deadline }, }; } @@ -105,6 +107,7 @@ export async function signPrivateEnrollment( identityCommitment: bigint, humanTag: bigint, deadline: number | bigint, + documentTag: bigint = 0n, ): Promise { const { domain, types, value } = privateEnrollmentTypedData( electionAddress, @@ -112,6 +115,7 @@ export async function signPrivateEnrollment( identityCommitment, humanTag, deadline, + documentTag, ); return attester.signTypedData(domain, types, value); } @@ -121,7 +125,6 @@ export async function signPrivateEnrollment( /// the old public way: the tests for the private path pass a key explicitly. export async function deployStack( ethers: any, - forwarder: string, platformAttester: string = ZERO_ADDRESS, ): Promise { const poseidonAddress = await deployPoseidonT3(ethers); @@ -143,7 +146,6 @@ export async function deployStack( }); const factory = await Factory.deploy( await paymaster.getAddress(), - forwarder, await verifier.getAddress(), await registry.getAddress(), platformAttester, @@ -236,16 +238,15 @@ export function baseConfig(now: number, overrides: Partial = {}) export async function deployElection( ethers: any, stack: Stack, - forwarder: string, organizer: any, cfg: ElectionConfig, platformAttester: string = ZERO_ADDRESS, + contractName: "ElectionV4" | "ElectionV4Harness" = "ElectionV4", ): Promise { - const Election = await ethers.getContractFactory("ElectionV4", { + const Election = await ethers.getContractFactory(contractName, { libraries: { [POSEIDON_FQN]: stack.poseidonAddress }, }); const election = await Election.deploy( - forwarder, await stack.verifier.getAddress(), await stack.registry.getAddress(), platformAttester, @@ -256,6 +257,10 @@ export async function deployElection( return election; } +/// A placeholder tally proof. The contract requires one to be present and +/// publishes it; verifying it is the readers' job, tested in the frontend. +export const PLACEHOLDER_TALLY_PROOF = "0x7b7d"; + /// Dummy Groth16 proof accepted by the MockVerifier. export const DUMMY_PROOF = { pA: [0n, 0n] as [bigint, bigint], diff --git a/contracts/tsconfig.json b/contracts/tsconfig.json index b3c86ea..cae3f62 100644 --- a/contracts/tsconfig.json +++ b/contracts/tsconfig.json @@ -9,6 +9,9 @@ "esModuleInterop": true, "resolveJsonModule": true, "outDir": "dist", + // One level up: the E2E suite and the seed share `tallyProof.ts` with the + // frontend, so the three can never disagree about what a valid tally is. + "rootDir": "..", "types": ["node", "mocha"] } } From c7510fed58da03dbdd38da796d99c74f46332a00 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 13:38:37 +0000 Subject: [PATCH 03/32] fix(backend): server-fixed World ID action, honest credentials, bounded writes - The World ID action is fixed by the server and checked on the proof: a client-chosen action minted a new nullifier, and identity, per action. - Private enrolment signs a document tag and consumes the passport check only once the enrolment has landed. - Credentials no longer assert an unverified country, region or age, and first-time sessions are revocable through the human's slot. - Per-voter quotas and size limits on every platform-paid registry write. - One provider and one serialized signer per key (no nonce collisions); one generic relay path; errors reduced to the revert name for clients. - Revocation list read in parallel, deduplicated, never truncated. - Security headers, JSON error handler, body limit, oxlint, noUnused*. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi --- backend/.env.example | 7 - backend/.oxlintrc.json | 8 + backend/DEVELOPMENT.md | 2 +- backend/package-lock.json | 401 ++++++++++++++++++ backend/package.json | 7 + backend/src/auth/session.ts | 11 +- backend/src/auth/voterLimit.ts | 39 ++ backend/src/auth/worldId.test.ts | 26 ++ backend/src/auth/worldId.ts | 19 + backend/src/chain/election.ts | 5 +- backend/src/chain/registrar.ts | 69 ++- backend/src/chain/relayer.ts | 196 ++++----- backend/src/chain/signer.test.ts | 54 +++ backend/src/chain/signer.ts | 86 ++++ backend/src/eligibility/attester.ts | 34 +- backend/src/eligibility/enrolment.ts | 46 +- backend/src/eligibility/policy.ts | 2 +- .../src/eligibility/privateEnrolment.test.ts | 26 +- backend/src/eligibility/sessions.ts | 14 +- backend/src/identity/preferences.ts | 6 +- backend/src/identity/vault.ts | 20 +- backend/src/index.ts | 48 ++- backend/src/routes/credentials.ts | 39 +- backend/src/routes/eligibility.ts | 10 +- backend/src/routes/identity.ts | 65 ++- backend/src/routes/organizerDomains.ts | 1 - backend/src/routes/preferences.ts | 17 +- backend/src/routes/relay.ts | 15 + backend/src/routes/verify.ts | 47 +- backend/src/sd/issuer.ts | 9 +- backend/src/status/statusList.ts | 73 +++- backend/src/utils/errors.test.ts | 29 ++ backend/src/utils/errors.ts | 44 ++ backend/src/utils/keys.ts | 30 +- backend/tsconfig.json | 4 +- docker-compose.yml | 2 - 36 files changed, 1206 insertions(+), 305 deletions(-) create mode 100644 backend/.oxlintrc.json create mode 100644 backend/src/auth/voterLimit.ts create mode 100644 backend/src/chain/signer.test.ts create mode 100644 backend/src/chain/signer.ts create mode 100644 backend/src/utils/errors.test.ts create mode 100644 backend/src/utils/errors.ts diff --git a/backend/.env.example b/backend/.env.example index d7a41af..38ff9cc 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -158,10 +158,3 @@ SELF_MOCK=1 # ── Admin ── # Bearer token protecting the credential revocation endpoint ADMIN_TOKEN= - -# ── Demo selective-disclosure attributes ── -# Placeholder identity attributes issued until World ID Credentials selective -# disclosure (passport NFC) is wired. See docs/dev/architecture.md. -DEMO_VC_COUNTRY=ES -DEMO_VC_REGION=Madrid - diff --git a/backend/.oxlintrc.json b/backend/.oxlintrc.json new file mode 100644 index 0000000..820bc39 --- /dev/null +++ b/backend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn", "import", "oxc", "node"], + "categories": { + "correctness": "error" + }, + "ignorePatterns": ["dist", "node_modules"] +} diff --git a/backend/DEVELOPMENT.md b/backend/DEVELOPMENT.md index 5287d0e..993c7f7 100644 --- a/backend/DEVELOPMENT.md +++ b/backend/DEVELOPMENT.md @@ -338,7 +338,7 @@ User → POST /api/worldid/request { returnTo? } → Set-Cookie: voter_pending → GET /api/worldid/request?wait=1 (held open; a reload may ask again) → POST /api/verify-human { ...worldIdProof } ├── verify World ID proof - └── issue SD-JWT VC (sub=nullifier; _sd: country/ageOver18/region; + └── issue SD-JWT VC (sub=nullifier; no identity attributes: none is verified here; credentialStatus → Status List 2021 entry) → httpOnly cookie (7d) → GET /api/identity/vault (unlock the existing identity, or find none) → POST /api/identity/vault { credentialId, blob, commitment } diff --git a/backend/package-lock.json b/backend/package-lock.json index c2eb986..f25aafb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,8 +25,13 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^26.6.2", + "oxlint": "^1.85.0", "tsx": "^4.23.15", "typescript": "^7.0.2" + }, + "engines": { + "node": ">=24", + "npm": ">=11" } }, "node_modules/@adraffy/ens-normalize": { @@ -1382,6 +1387,353 @@ "integrity": "sha512-YvbEeIuLE+TlR7wfzsfR3tAzNPFssbZ559QZactwGVunl1zgZG0smzArQwpgc3qNv0LrLdeCBzPd2ZcyfGyDHQ==", "license": "Apache-2.0" }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.85.0.tgz", + "integrity": "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.85.0.tgz", + "integrity": "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.85.0.tgz", + "integrity": "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.85.0.tgz", + "integrity": "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.85.0.tgz", + "integrity": "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.85.0.tgz", + "integrity": "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.85.0.tgz", + "integrity": "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.85.0.tgz", + "integrity": "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.85.0.tgz", + "integrity": "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.85.0.tgz", + "integrity": "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.85.0.tgz", + "integrity": "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.85.0.tgz", + "integrity": "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.85.0.tgz", + "integrity": "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.85.0.tgz", + "integrity": "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.85.0.tgz", + "integrity": "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.85.0.tgz", + "integrity": "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.85.0.tgz", + "integrity": "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.85.0.tgz", + "integrity": "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.85.0.tgz", + "integrity": "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@pcd/pcd-types": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@pcd/pcd-types/-/pcd-types-0.10.0.tgz", @@ -5018,6 +5370,55 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oxlint": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.85.0.tgz", + "integrity": "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.85.0", + "@oxlint/binding-android-arm64": "1.85.0", + "@oxlint/binding-darwin-arm64": "1.85.0", + "@oxlint/binding-darwin-x64": "1.85.0", + "@oxlint/binding-freebsd-x64": "1.85.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", + "@oxlint/binding-linux-arm-musleabihf": "1.85.0", + "@oxlint/binding-linux-arm64-gnu": "1.85.0", + "@oxlint/binding-linux-arm64-musl": "1.85.0", + "@oxlint/binding-linux-ppc64-gnu": "1.85.0", + "@oxlint/binding-linux-riscv64-gnu": "1.85.0", + "@oxlint/binding-linux-riscv64-musl": "1.85.0", + "@oxlint/binding-linux-s390x-gnu": "1.85.0", + "@oxlint/binding-linux-x64-gnu": "1.85.0", + "@oxlint/binding-linux-x64-musl": "1.85.0", + "@oxlint/binding-openharmony-arm64": "1.85.0", + "@oxlint/binding-win32-arm64-msvc": "1.85.0", + "@oxlint/binding-win32-ia32-msvc": "1.85.0", + "@oxlint/binding-win32-x64-msvc": "1.85.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, "node_modules/pako": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index 2a88e29..f1292be 100644 --- a/backend/package.json +++ b/backend/package.json @@ -3,10 +3,16 @@ "version": "1.0.0", "description": "The VC Issuer backend for emitting Verifiable Credentials (SD-JWT) mediated by World ID proofs (Phase 2).", "main": "index.js", + "engines": { + "node": ">=24", + "npm": ">=11" + }, "scripts": { "start": "node dist/index.js", "dev": "tsx watch --include .env src/index.ts", "build": "tsc", + "typecheck": "tsc --noEmit", + "lint": "oxlint src scripts", "test": "node --import tsx --test src/**/*.test.ts", "e2e:vault": "tsx scripts/e2e-vault.ts", "tunnel": "ngrok http 3000" @@ -38,6 +44,7 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^26.6.2", + "oxlint": "^1.85.0", "tsx": "^4.23.15", "typescript": "^7.0.2" } diff --git a/backend/src/auth/session.ts b/backend/src/auth/session.ts index 130c95a..732ece6 100644 --- a/backend/src/auth/session.ts +++ b/backend/src/auth/session.ts @@ -14,7 +14,7 @@ * enrol arbitrary identities and break Sybil resistance outright. */ import { sdJwt, type VotainCredentialPayload } from '../sd/issuer.js'; -import { isRevoked } from '../status/statusList.js'; +import { isRevoked, statusSlotOfHuman } from '../status/statusList.js'; import type { CredentialLevel } from './worldId.js'; export interface Session { @@ -51,9 +51,12 @@ export async function verifySession(vc: string | undefined): Promise 0 ? listed : await statusSlotOfHuman(payload.sub); + if (slot > 0 && (await isRevoked(slot))) return null; return { nullifier: payload.sub, personhood: payload.personhood, payload }; } diff --git a/backend/src/auth/voterLimit.ts b/backend/src/auth/voterLimit.ts new file mode 100644 index 0000000..b363cf2 --- /dev/null +++ b/backend/src/auth/voterLimit.ts @@ -0,0 +1,39 @@ +/** + * Authentication and a quota, keyed by the VOTER rather than by their address. + * + * Writes to `PlatformRegistry` are transactions the platform pays for on the + * voter's behalf. A per-IP limit protects nothing there: an address is cheap to + * change, and one voter behind many of them could keep the registrar busy and + * drain it. The quota therefore follows the World ID nullifier, which is the + * one thing a voter cannot mint more of, and the session is verified first so + * that the key the quota is counted under is one this server signed. + */ +import type { NextFunction, Request, Response } from 'express'; +import rateLimit from 'express-rate-limit'; + +import { readSessionCookie } from './cookie.js'; +import { verifySession, type Session } from './session.js'; + +/** Answers 401 without a valid session, and otherwise leaves it in `res.locals.session`. */ +export async function requireSession(req: Request, res: Response, next: NextFunction) { + const session = await verifySession(readSessionCookie(req)); + if (!session) return res.status(401).json({ error: 'Not authenticated' }); + res.locals.session = session; + next(); +} + +/** The session `requireSession` left behind. */ +export function sessionOf(res: Response): Session { + return res.locals.session as Session; +} + +/** A limiter counted per voter. Mount after `requireSession`. */ +export function perVoterLimit(windowMs: number, limit: number) { + return rateLimit({ + windowMs, + limit, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (_req, res) => `voter:${sessionOf(res).nullifier}`, + }); +} diff --git a/backend/src/auth/worldId.test.ts b/backend/src/auth/worldId.test.ts index 80151a4..58d903f 100644 --- a/backend/src/auth/worldId.test.ts +++ b/backend/src/auth/worldId.test.ts @@ -310,3 +310,29 @@ describe('the level a session records', () => { ); }); }); + +describe('verifyWorldIdProof, the action is the server\'s', () => { + test('refuses a proof made for another action, before asking the API', async () => { + const calls = stubApi(200, { success: true, results: [{ identifier: 'orb', success: true, nullifier: '0xa' }] }); + + const result = await verifyWorldIdProof({ + action: 'some-other-action', + responses: [{ identifier: 'orb', nullifier: '0xa' }], + }); + + assert.equal(result.ok, false); + assert.equal(calls.length, 0); + }); + + test('accepts a proof that names the configured action', async () => { + process.env.WORLD_ID_ACTION = 'vote-registration'; + stubApi(200, { success: true, results: [{ identifier: 'orb', success: true, nullifier: '0xa' }] }); + + const result = await verifyWorldIdProof({ + action: 'vote-registration', + responses: [{ identifier: 'orb', nullifier: '0xa' }], + }); + + assert.equal(result.ok, true); + }); +}); diff --git a/backend/src/auth/worldId.ts b/backend/src/auth/worldId.ts index 6539f34..be39043 100644 --- a/backend/src/auth/worldId.ts +++ b/backend/src/auth/worldId.ts @@ -26,6 +26,19 @@ * separately. See `eligibility/` and `usedPersonhoodNullifiers`. */ +/** + * The one World ID action this platform signs requests for and accepts proofs of. + * + * FIXED BY THE SERVER, never by the caller. A World ID nullifier is derived + * per (human, app, action), so a caller free to name the action could ask for + * "a", then "b", then "c", and get a fresh nullifier, and therefore a fresh + * platform identity and a fresh vote, from one World ID each time. The + * nullifier is only a Sybil boundary while the action is a constant. + */ +export function worldIdAction(): string { + return process.env.WORLD_ID_ACTION || 'vote-registration'; +} + /** * Identifiers that mean "verified at an Orb", across protocol versions. * World ID 3.0 proofs report `orb`; 4.0 reports `proof_of_human`; the @@ -72,6 +85,7 @@ interface WorldIdResponseEntry { export type WorldIdPayload = { responses?: WorldIdResponseEntry[]; nullifier_hash?: string; + action?: unknown; } & Record; interface VerifyResultEntry { @@ -213,6 +227,11 @@ export async function verifyWorldIdProof( const rpId = process.env.WORLD_ID_RP_ID; if (!rpId) throw new Error('WORLD_ID_RP_ID not configured'); + // Where the payload names its action it must be ours; see `worldIdAction`. + if (payload.action !== undefined && payload.action !== worldIdAction()) { + return { ok: false, error: 'proof is for a different action' }; + } + const declared = payload.responses ?? []; if (declared.length === 0) return { ok: false, error: 'proof carried no responses' }; diff --git a/backend/src/chain/election.ts b/backend/src/chain/election.ts index 9f2576e..44e968b 100644 --- a/backend/src/chain/election.ts +++ b/backend/src/chain/election.ts @@ -10,7 +10,8 @@ * Env: * CHAIN_RPC_URL RPC endpoint */ -import { Contract, JsonRpcProvider, ZeroAddress, isAddress } from 'ethers'; +import { Contract, ZeroAddress, isAddress, type JsonRpcProvider } from 'ethers'; +import { chainProvider } from './signer.js'; import { parsePolicy, policyHash, type EligibilityPolicy } from '../eligibility/policy.js'; const ELECTION_ABI = [ @@ -28,7 +29,7 @@ export function isChainConfigured(): boolean { } function getProvider(): JsonRpcProvider { - return new JsonRpcProvider(process.env.CHAIN_RPC_URL); + return chainProvider(); } export interface ElectionEligibility { diff --git a/backend/src/chain/registrar.ts b/backend/src/chain/registrar.ts index 42dd960..211dcae 100644 --- a/backend/src/chain/registrar.ts +++ b/backend/src/chain/registrar.ts @@ -11,9 +11,11 @@ * REGISTRY_ADDRESS PlatformRegistry deployment address * REGISTRAR_PRIVATE_KEY key owning PlatformRegistry */ -import { Contract, JsonRpcProvider, Wallet } from 'ethers'; +import { Contract, type TransactionReceipt, type TransactionResponse } from 'ethers'; import { contractAddress } from './deployments.js'; +import { chainProvider, submitInTurn } from './signer.js'; +import { chainFailure, errorMessage } from '../utils/errors.js'; const REGISTRY_ABI = [ 'function registerMember(uint256 nullifier, uint256 identityCommitment)', @@ -21,6 +23,7 @@ const REGISTRY_ABI = [ 'function commitmentOf(uint256 nullifier) view returns (uint256)', 'function registeredNullifiers(uint256 nullifier) view returns (bool)', 'function verifiedMembers(uint256 identityCommitment) view returns (bool)', + 'function nullifierOf(uint256 identityCommitment) view returns (uint256)', // The identity vault. See `identity/vault.ts` for why the ciphertext lives on // chain rather than on this server's disk. 'function addVaultEntry(uint256 nullifier, bytes credentialId, bytes blob)', @@ -52,33 +55,24 @@ export function isRegistrarConfigured(): boolean { ); } -function getRegistry(): Contract { - return getRegistryWriter(); -} - -/** - * The registry, signed by the registrar. Every write goes through this. - * - * The owner is a WRITER and never a reader: a voter has no wallet, by design, - * because a per-voter sending address would publicly link their enrollment to - * their ballot, so somebody has to submit on their behalf. - */ -export function getRegistryWriter(): Contract { - const provider = new JsonRpcProvider(process.env.CHAIN_RPC_URL); - const wallet = new Wallet(process.env.REGISTRAR_PRIVATE_KEY as string, provider); - return new Contract(registryAddress() as string, REGISTRY_ABI, wallet); +/** A read-only handle on the registry, over the shared provider. */ +export function getRegistryReader(): Contract { + return new Contract(registryAddress() as string, REGISTRY_ABI, chainProvider()); } /** - * The registry, read-only and unsigned. + * Sends one registry write and waits for it to land. * - * Reads need no key, and using the signing wallet for them would make the - * registrar look load-bearing where it is not: anyone with an RPC endpoint can - * read a voter's vault back, which is the entire point of moving it here. + * Every write goes through the registrar's queue, so concurrent requests + * (a vault entry here, a preferences change there) never race for a nonce. */ -export function getRegistryReader(): Contract { - const provider = new JsonRpcProvider(process.env.CHAIN_RPC_URL); - return new Contract(registryAddress() as string, REGISTRY_ABI, provider); +export async function writeRegistry( + send: (registry: Contract) => Promise, +): Promise { + const tx = await submitInTurn('REGISTRAR_PRIVATE_KEY', signer => + send(new Contract(registryAddress() as string, REGISTRY_ABI, signer)), + ); + return tx.wait(); } export interface RegistrationResult { @@ -110,14 +104,19 @@ export async function registerOnChain( } try { - const registry = getRegistry(); + const registry = getRegistryReader(); const nullifier = BigInt(nullifierHex); const commitment = BigInt(identityCommitment); // Idempotent re-verification: the same human presenting the same identity - // again is a success, nothing to write. + // again is a success, nothing to write. THE SAME HUMAN, checked, and not + // merely "somebody's active commitment": commitments are public, and + // reporting another voter's as this one's registration would tell the + // caller they were set up when they were not. if (await registry.verifiedMembers(commitment)) { - return { registered: true, alreadyRegistered: true }; + const owner: bigint = await registry.nullifierOf(commitment); + if (owner === nullifier) return { registered: true, alreadyRegistered: true }; + return { registered: false, error: 'that identity commitment belongs to another voter' }; } // The human is known but the commitment is new. Reporting success here (as @@ -132,13 +131,11 @@ export async function registerOnChain( }; } - const tx = await registry.registerMember(nullifier, commitment); - const receipt = await tx.wait(); + const receipt = await writeRegistry(r => r.registerMember(nullifier, commitment)); return { registered: true, txHash: receipt?.hash }; } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.error('On-chain registration failed:', message); - return { registered: false, error: message }; + console.error('On-chain registration failed:', errorMessage(error)); + return { registered: false, error: chainFailure(error) }; } } @@ -163,7 +160,7 @@ export async function rotateOnChain( } try { - const registry = getRegistry(); + const registry = getRegistryReader(); const nullifier = BigInt(nullifierHex); const commitment = BigInt(newCommitment); @@ -179,12 +176,10 @@ export async function rotateOnChain( return { registered: false, error: 'that identity commitment is already in use' }; } - const tx = await registry.rotateMember(nullifier, commitment); - const receipt = await tx.wait(); + const receipt = await writeRegistry(r => r.rotateMember(nullifier, commitment)); return { registered: true, txHash: receipt?.hash }; } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.error('rotateOnChain failed:', message); - return { registered: false, error: message }; + console.error('rotateOnChain failed:', errorMessage(error)); + return { registered: false, error: chainFailure(error) }; } } diff --git a/backend/src/chain/relayer.ts b/backend/src/chain/relayer.ts index 1008779..d4f619a 100644 --- a/backend/src/chain/relayer.ts +++ b/backend/src/chain/relayer.ts @@ -8,33 +8,33 @@ * ElectionPaymaster, which reimburses the gas from the organizer's tank inside * the same transaction. The relayer only fronts a small float. * - * Neither relayed call trusts the caller: `enroll` is gated on PlatformRegistry - * and `castVote` on a zero-knowledge proof, both verified on chain. A malicious - * relayer can withhold or delay a transaction, never forge one, and since - * relaying is permissionless on the contract a censored voter can always submit - * their own. + * Neither relayed call trusts the caller: enrolment is gated on a registry + * entry or a platform signature, and `castVote` on a zero-knowledge proof, all + * verified on chain. A malicious relayer can withhold or delay a transaction, + * never forge one, and since relaying is permissionless on the contract a + * censored voter can always submit their own. * * Env: * CHAIN_RPC_URL RPC endpoint * PAYMASTER_ADDRESS ElectionPaymaster deployment address * RELAYER_PRIVATE_KEY hot wallet with a small POL float */ -import { Contract, JsonRpcProvider, Wallet, isAddress } from 'ethers'; +import { Contract, isAddress } from 'ethers'; import { contractAddress } from './deployments.js'; +import { submitInTurn } from './signer.js'; +import { chainFailure, errorMessage } from '../utils/errors.js'; const PAYMASTER_ABI = [ 'function relayEnroll(address election, uint256 identityCommitment)', 'function relayEnrollAttested(address election, uint256 identityCommitment, uint256 personhoodNullifier, uint256 deadline, bytes signature)', - 'function relayEnrollPrivate(address election, uint256 identityCommitment, uint256 humanTag, uint256 deadline, bytes platformSignature, bytes eligibilitySignature)', + 'function relayEnrollPrivate(address election, uint256 identityCommitment, uint256 humanTag, uint256 documentTag, uint256 deadline, bytes platformSignature, bytes eligibilitySignature)', 'function relayVote(address election, bytes voteCiphertext, uint256 nullifier, uint256 merkleRoot, uint256 merkleDepth, uint256[2] pA, uint256[2][2] pB, uint256[2] pC)', // Declared so a revert this contract interface decodes arrives with a NAME - // rather than a bare four-byte selector, since the message built from it is - // what this server hands the browser. It does not cover every case: a failure - // during gas estimation is raised by the provider, which has no ABI, and - // reaches the caller as `unknown custom error` with the selector in `data`. - // The browser reads that selector itself; see `revertNameOf` in - // frontend/src/lib/relay.ts. + // rather than a bare four-byte selector. It does not cover every case: a + // failure during gas estimation is raised by the provider, which has no ABI, + // and reaches the caller with the selector only. The browser reads that + // selector itself; see `revertNameOf` in frontend/src/lib/relay.ts. 'error InsufficientBalance()', 'error EnrollmentNotOpen()', 'error AlreadyEnrolled()', @@ -50,6 +50,10 @@ const PAYMASTER_ABI = [ 'error UnknownOrExpiredRoot()', 'error InvalidProof()', 'error WrongPhase()', + 'error RevoteTooSoon(uint256 availableAt)', + 'error InvalidBallot()', + 'error MissingDocumentTag()', + 'error UnexpectedDocumentTag()', 'function organizerOf(address election) view returns (address)', 'function gasBalance(address organizer) view returns (uint256)', ]; @@ -65,12 +69,6 @@ export function isRelayerConfigured(): boolean { ); } -function getPaymaster(): Contract { - const provider = new JsonRpcProvider(process.env.CHAIN_RPC_URL); - const wallet = new Wallet(process.env.RELAYER_PRIVATE_KEY as string, provider); - return new Contract(paymasterAddress() as string, PAYMASTER_ABI, wallet); -} - export interface RelayResult { relayed: boolean; txHash?: string; @@ -88,31 +86,48 @@ export interface VoteCall { pC: [string, string]; } -/** Rejects anything that is not a well-formed address before spending gas. */ -function requireElection(election: string): void { - if (!isAddress(election)) throw new Error('election must be a valid address'); -} +type PaymasterMethod = 'relayEnroll' | 'relayEnrollAttested' | 'relayEnrollPrivate' | 'relayVote'; -export async function relayEnroll(election: string, commitment: string): Promise { +/** + * Simulates, submits in turn and waits for one relayed call. + * + * Simulated first because a reverting call still costs the relayer its gas, + * and `/relay/vote` is public: submitting blind would let anyone drain the + * float with junk. A local `staticCall` fails for free. + */ +async function relay( + method: PaymasterMethod, + election: string, + buildArgs: () => readonly unknown[], +): Promise { if (!isRelayerConfigured()) return { relayed: false, error: 'relayer not configured' }; + if (!isAddress(election)) return { relayed: false, error: 'election must be a valid address' }; + + let args: readonly unknown[]; + try { + args = buildArgs(); + } catch { + return { relayed: false, error: 'malformed arguments' }; + } try { - requireElection(election); - const paymaster = getPaymaster(); - // Simulate first: `/relay/vote` is public and a reverting call still costs - // the relayer its gas, so submitting blind lets anyone drain the float with - // junk. staticCall reverts locally and costs nothing. - await paymaster.relayEnroll.staticCall(election, BigInt(commitment)); - const tx = await paymaster.relayEnroll(election, BigInt(commitment)); + const tx = await submitInTurn('RELAYER_PRIVATE_KEY', async signer => { + const paymaster = new Contract(paymasterAddress() as string, PAYMASTER_ABI, signer); + await paymaster[method].staticCall(...args); + return paymaster[method](...args); + }); const receipt = await tx.wait(); return { relayed: true, txHash: receipt?.hash }; } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.error('relayEnroll failed:', message); - return { relayed: false, error: message }; + console.error(`${method} failed:`, errorMessage(error)); + return { relayed: false, error: chainFailure(error) }; } } +export function relayEnroll(election: string, commitment: string): Promise { + return relay('relayEnroll', election, () => [election, BigInt(commitment)]); +} + /** * Relays an enrollment into an election that declares an attribute policy. * @@ -120,36 +135,20 @@ export async function relayEnroll(election: string, commitment: string): Promise * a gated election refuses the plain one, so that a voter the attester turned * down cannot simply submit the transaction themselves. */ -export async function relayEnrollAttested( +export function relayEnrollAttested( election: string, commitment: string, personhoodNullifier: string, deadline: number, signature: string, ): Promise { - if (!isRelayerConfigured()) return { relayed: false, error: 'relayer not configured' }; - - try { - requireElection(election); - const paymaster = getPaymaster(); - const args = [ - election, - BigInt(commitment), - BigInt(personhoodNullifier), - BigInt(deadline), - signature, - ] as const; - // Same reason as relayEnroll: a reverting call still costs the relayer its - // gas, and an expired or malformed attestation reverts. - await paymaster.relayEnrollAttested.staticCall(...args); - const tx = await paymaster.relayEnrollAttested(...args); - const receipt = await tx.wait(); - return { relayed: true, txHash: receipt?.hash }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.error('relayEnrollAttested failed:', message); - return { relayed: false, error: message }; - } + return relay('relayEnrollAttested', election, () => [ + election, + BigInt(commitment), + BigInt(personhoodNullifier), + BigInt(deadline), + signature, + ]); } /** @@ -157,71 +156,38 @@ export async function relayEnrollAttested( * * The commitment was derived by the voter for this election alone and appears * in no registry, so the contract has nothing to look it up in: the platform's - * signature is what says a verified human is behind it, and the tag is what - * says they have not already enrolled here. Neither is recognisable in any - * other election. + * signature is what says a verified human is behind it, and the tags are what + * say neither that human nor their document has already enrolled here. */ -export async function relayEnrollPrivate( +export function relayEnrollPrivate( election: string, commitment: string, humanTag: string, + documentTag: string, deadline: number, platformSignature: string, eligibilitySignature: string, ): Promise { - if (!isRelayerConfigured()) return { relayed: false, error: 'relayer not configured' }; - - try { - requireElection(election); - const paymaster = getPaymaster(); - const args = [ - election, - BigInt(commitment), - BigInt(humanTag), - BigInt(deadline), - platformSignature, - eligibilitySignature, - ] as const; - // Same reason as the other two: a revert still costs the relayer its gas. - await paymaster.relayEnrollPrivate.staticCall(...args); - const tx = await paymaster.relayEnrollPrivate(...args); - const receipt = await tx.wait(); - return { relayed: true, txHash: receipt?.hash }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.error('relayEnrollPrivate failed:', message); - return { relayed: false, error: message }; - } + return relay('relayEnrollPrivate', election, () => [ + election, + BigInt(commitment), + BigInt(humanTag), + BigInt(documentTag), + BigInt(deadline), + platformSignature, + eligibilitySignature, + ]); } -export async function relayVote(call: VoteCall): Promise { - if (!isRelayerConfigured()) return { relayed: false, error: 'relayer not configured' }; - - try { - requireElection(call.election); - const paymaster = getPaymaster(); - const args = [ - call.election, - call.voteCiphertext, - BigInt(call.nullifier), - BigInt(call.merkleRoot), - BigInt(call.merkleDepth), - call.pA.map(BigInt), - call.pB.map(pair => pair.map(BigInt)), - call.pC.map(BigInt), - ] as const; - - // An invalid proof reverts on chain but the relayer still pays the gas, and - // this endpoint is unauthenticated by design. Simulating first turns that - // drain vector into a free local failure. - await paymaster.relayVote.staticCall(...args); - - const tx = await paymaster.relayVote(...args); - const receipt = await tx.wait(); - return { relayed: true, txHash: receipt?.hash }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.error('relayVote failed:', message); - return { relayed: false, error: message }; - } +export function relayVote(call: VoteCall): Promise { + return relay('relayVote', call.election, () => [ + call.election, + call.voteCiphertext, + BigInt(call.nullifier), + BigInt(call.merkleRoot), + BigInt(call.merkleDepth), + call.pA.map(BigInt), + call.pB.map(pair => pair.map(BigInt)), + call.pC.map(BigInt), + ]); } diff --git a/backend/src/chain/signer.test.ts b/backend/src/chain/signer.test.ts new file mode 100644 index 0000000..7ca272a --- /dev/null +++ b/backend/src/chain/signer.test.ts @@ -0,0 +1,54 @@ +import { test, describe, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { Wallet } from 'ethers'; + +import { resetChainClients, submitInTurn } from './signer.js'; + +/** + * Two voters in the same second used to be handed the same nonce, and one of + * them was told their enrolment failed. What is pinned here is the ordering: + * submissions signed with one key run one at a time, in arrival order, and a + * failed one does not block the queue behind it. + */ +beforeEach(() => { + process.env.CHAIN_RPC_URL = 'http://127.0.0.1:1'; + process.env.TEST_SIGNER_KEY = Wallet.createRandom().privateKey; + resetChainClients(); +}); + +const pause = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +describe('submitInTurn', () => { + test('runs submissions for one key one at a time, in arrival order', async () => { + const log: string[] = []; + const job = (name: string, ms: number) => async () => { + log.push(`${name}:start`); + await pause(ms); + log.push(`${name}:end`); + return name; + }; + + const results = await Promise.all([ + submitInTurn('TEST_SIGNER_KEY', job('a', 20)), + submitInTurn('TEST_SIGNER_KEY', job('b', 1)), + submitInTurn('TEST_SIGNER_KEY', job('c', 1)), + ]); + + assert.deepEqual(results, ['a', 'b', 'c']); + assert.deepEqual(log, ['a:start', 'a:end', 'b:start', 'b:end', 'c:start', 'c:end']); + }); + + test('a failed submission is reported and does not stall the next', async () => { + const failing = submitInTurn('TEST_SIGNER_KEY', async () => { + throw new Error('reverted'); + }); + const next = submitInTurn('TEST_SIGNER_KEY', async () => 'ok'); + + await assert.rejects(failing, /reverted/); + assert.equal(await next, 'ok'); + }); + + test('refuses a key that is not configured', async () => { + await assert.rejects(submitInTurn('NOT_A_CONFIGURED_KEY', async () => 1), /not configured/); + }); +}); diff --git a/backend/src/chain/signer.ts b/backend/src/chain/signer.ts new file mode 100644 index 0000000..8a4ec6e --- /dev/null +++ b/backend/src/chain/signer.ts @@ -0,0 +1,86 @@ +/** + * One provider and one queue per hot wallet, for every transaction this server sends. + * + * WHY A QUEUE. The relayer and the registrar each sign with one key, and every + * request used to build its own provider and wallet and ask the chain for the + * next nonce. Two voters arriving in the same second were handed the same + * nonce: one transaction went through and the other failed with "nonce too + * low" or "replacement underpriced", for no fault of the voter. Submissions are + * now made one at a time per key, in arrival order, with the nonce tracked + * here, and only the wait for a receipt runs concurrently. + * + * WHY ONE PROVIDER. A `JsonRpcProvider` detects the network on first use, which + * is a round trip, and each one holds its own polling state. Building one per + * request paid that on every call and multiplied the load on the RPC endpoint. + * + * A failed submission resets the nonce from the chain, so a transaction that + * never left cannot leave a gap that stalls every later one. + */ +import { JsonRpcProvider, NonceManager, Wallet } from 'ethers'; + +let provider: { url: string; instance: JsonRpcProvider } | null = null; + +/** The shared read provider for `CHAIN_RPC_URL`. */ +export function chainProvider(): JsonRpcProvider { + const url = process.env.CHAIN_RPC_URL; + if (!url) throw new Error('CHAIN_RPC_URL not configured'); + if (provider?.url !== url) provider = { url, instance: new JsonRpcProvider(url) }; + return provider.instance; +} + +interface Lane { + signer: NonceManager; + tail: Promise; +} + +const lanes = new Map(); + +function laneFor(privateKey: string): Lane { + const address = new Wallet(privateKey).address; + let lane = lanes.get(address); + if (!lane) { + lane = { signer: new NonceManager(new Wallet(privateKey, chainProvider())), tail: Promise.resolve() }; + lanes.set(address, lane); + } + return lane; +} + +/** The nonce-tracking signer for the key in `envVar`. */ +export function signerFrom(envVar: string): NonceManager { + const key = process.env[envVar]; + if (!key) throw new Error(`${envVar} not configured`); + return laneFor(key).signer; +} + +/** + * Runs `submit` alone among the submissions signed with the key in `envVar`. + * + * `submit` should send and return the transaction, not wait for it: the next + * one in line can go as soon as this one is in the mempool. + */ +export async function submitInTurn( + envVar: string, + submit: (signer: NonceManager) => Promise, +): Promise { + const key = process.env[envVar]; + if (!key) throw new Error(`${envVar} not configured`); + const lane = laneFor(key); + + const run = lane.tail.then(async () => { + try { + return await submit(lane.signer); + } catch (error) { + lane.signer.reset(); + throw error; + } + }); + // The next submission waits for this one to settle, whatever its outcome. + lane.tail = run.catch(() => undefined); + return run; +} + +/** For tests: forget every cached provider and signer. */ +export function resetChainClients(): void { + provider = null; + lanes.clear(); +} diff --git a/backend/src/eligibility/attester.ts b/backend/src/eligibility/attester.ts index 3df1947..493f4ea 100644 --- a/backend/src/eligibility/attester.ts +++ b/backend/src/eligibility/attester.ts @@ -43,6 +43,7 @@ const PRIVATE_EIP712_TYPES = { PrivateEnrollment: [ { name: 'identityCommitment', type: 'uint256' }, { name: 'humanTag', type: 'uint256' }, + { name: 'documentTag', type: 'uint256' }, { name: 'deadline', type: 'uint256' }, ], } as const; @@ -162,9 +163,38 @@ export function humanTagFor( return BigInt(scoped).toString(); } +/** + * The value that answers "has this DOCUMENT already enrolled here". + * + * `humanTag` is derived from the World ID account, and below Orb an account is + * not a person: somebody holding several could pass the same passport check + * once per account. This tag is derived from the nullifier the document proof + * carried, under the same epoch key and for the same reason: the contract can + * refuse it the second time, and nobody without the key can recognise it. + * Separated from `humanTagFor` by a domain label, so the two can never collide. + */ +export function documentTagFor( + documentNullifier: string, + electionAddress: string, + createdAtSeconds: number, +): string { + const scoped = solidityPackedKeccak256( + ['bytes32', 'string', 'uint256', 'address'], + [ + '0x' + tagKey(epochOf(createdAtSeconds)).toString('hex'), + 'votain/document-tag/v1', + BigInt(documentNullifier), + getAddress(electionAddress), + ], + ); + return BigInt(scoped).toString(); +} + export interface PrivateEnrollment { /** Decimal string, as the contract and the relay both expect it. */ humanTag: string; + /** Decimal string; "0" on an election that requires no document. */ + documentTag: string; deadline: number; signature: string; } @@ -182,6 +212,7 @@ export async function signPrivateEnrollment( chainId: bigint, identityCommitment: string, humanTag: string, + documentTag: string = '0', nowSeconds: number = Math.floor(Date.now() / 1000), ): Promise { const wallet = getAttesterWallet(); @@ -204,11 +235,12 @@ export async function signPrivateEnrollment( { identityCommitment: BigInt(identityCommitment), humanTag: BigInt(humanTag), + documentTag: BigInt(documentTag), deadline, }, ); - return { humanTag, deadline, signature }; + return { humanTag, documentTag, deadline, signature }; } export interface EnrollAttestation { diff --git a/backend/src/eligibility/enrolment.ts b/backend/src/eligibility/enrolment.ts index 7e3d179..5dd7e44 100644 --- a/backend/src/eligibility/enrolment.ts +++ b/backend/src/eligibility/enrolment.ts @@ -28,13 +28,16 @@ import type { CredentialLevel } from '../auth/worldId.js'; import { attesterAddress, isAttesterConfigured, + documentTagFor, humanTagFor, signPrivateEnrollment, } from './attester.js'; -import { getSession, consumeSession } from './sessions.js'; +import { getSession } from './sessions.js'; export interface EnrolmentAuthorisation { humanTag: string; + /** "0" unless the election requires a document. */ + documentTag: string; deadline: number; signature: string; /** The organizer's gatekeeper's signature over the same digest, or "0x". */ @@ -90,9 +93,8 @@ export async function authorisePrivateEnrolment( } let eligibilitySignature = '0x'; - let gated = false; + let documentNullifier: string | null = null; if (mode.eligibilityAttester !== ZERO) { - gated = true; if (mode.eligibilityAttester.toLowerCase() !== ours) { return { status: 400, @@ -100,19 +102,27 @@ export async function authorisePrivateEnrolment( }; } - const refusal = await consumePassedAttributeCheck(request); - if (refusal) return refusal; + const passed = await passedAttributeCheck(request); + if ('error' in passed) return passed; + documentNullifier = passed.documentNullifier; } const [chainId, now] = await Promise.all([getChainId(), attestationBaseTime()]); // `mode.createdAt` and not the clock: the tag key is chosen by the epoch the // election was DEPLOYED in, which is the one date about it that cannot move. const humanTag = humanTagFor(request.voter.nullifier, request.election, mode.createdAt); + // One document, one leaf: carried to the contract, which refuses a document + // tag it has already seen here. See `documentTagFor`. + const documentTag = + documentNullifier === null + ? '0' + : documentTagFor(documentNullifier, request.election, mode.createdAt); const signed = await signPrivateEnrollment( request.election, chainId, request.identityCommitment, humanTag, + documentTag, now, ); @@ -120,22 +130,25 @@ export async function authorisePrivateEnrolment( // its own field anyway, because the contract asks two separate questions and // a deployment where the two keys differ has to be able to answer them // separately. - if (gated) eligibilitySignature = signed.signature; + if (documentNullifier !== null) eligibilitySignature = signed.signature; return { ...signed, eligibilitySignature }; } /** - * The attribute half, for an election that asks for one. + * The passport check this voter passed for this election, and the document + * nullifier it carried. * - * Consumed before anything is signed and re-read from the chain at the last - * moment, exactly as the older attestation endpoint does it: the check that ran - * when the session opened was against a policy read minutes ago and a cookie - * the voter has had every chance to swap since. + * NOT CONSUMED HERE. It used to be deleted before signing, so a relay that + * failed afterwards (an RPC hiccup, an empty gas tank) sent the voter back to + * scan their passport again. Nothing is lost by leaving it: the contract + * refuses the same human tag and the same document tag a second time, which is + * the guarantee consuming it was standing in for. The route consumes it once + * the enrolment has actually landed, and otherwise it expires on its own. */ -async function consumePassedAttributeCheck( +async function passedAttributeCheck( request: EnrolmentRequest, -): Promise { +): Promise<{ documentNullifier: string } | EnrolmentRefusal> { if (!request.sessionId) { return { status: 400, error: 'sessionId is required for a gated election' }; } @@ -151,12 +164,13 @@ async function consumePassedAttributeCheck( if (session.status !== 'passed') { return { status: 409, error: 'session has not passed verification' }; } - - consumeSession(request.sessionId); + if (!session.personhoodNullifier) { + return { status: 500, error: 'passed session carried no document nullifier' }; + } const { policy } = await readElectionEligibility(request.election); if (!meetsPersonhood(policy, request.voter)) { return { status: 403, error: 'orb_required' }; } - return null; + return { documentNullifier: session.personhoodNullifier }; } diff --git a/backend/src/eligibility/policy.ts b/backend/src/eligibility/policy.ts index b57c8bb..c3c9075 100644 --- a/backend/src/eligibility/policy.ts +++ b/backend/src/eligibility/policy.ts @@ -222,7 +222,7 @@ export function checkAttributes( // Trailing NUL bytes are how an undisclosed field comes back from the circuit, // and they are not an answer. - const nationality = (attributes.nationality ?? '').replace(/\u0000+/g, '').trim().toUpperCase(); + const nationality = (attributes.nationality ?? '').replaceAll('\u0000', '').trim().toUpperCase(); // Only an ALLOWLIST needs the value. That is the case the voter was asked to // reveal it for, and there is no other way to check it: an exclusion list diff --git a/backend/src/eligibility/privateEnrolment.test.ts b/backend/src/eligibility/privateEnrolment.test.ts index f3c0e23..8e545c3 100644 --- a/backend/src/eligibility/privateEnrolment.test.ts +++ b/backend/src/eligibility/privateEnrolment.test.ts @@ -2,7 +2,7 @@ import { test, describe, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { Wallet, verifyTypedData } from 'ethers'; -import { humanTagFor, signPrivateEnrollment, ATTESTATION_TTL_SECONDS } from './attester.js'; +import { documentTagFor, humanTagFor, signPrivateEnrollment, ATTESTATION_TTL_SECONDS } from './attester.js'; /** * The tag that lets an election refuse the same person twice without anyone @@ -105,6 +105,22 @@ describe('the human tag', () => { }); }); +const DOC_TAG = '424242'; + +describe('the document tag', () => { + test('is stable for one document in one election, and differs everywhere else', () => { + const tag = documentTagFor('987654321', ELECTION, CREATED); + assert.equal(documentTagFor('987654321', ELECTION, CREATED), tag); + assert.notEqual(documentTagFor('987654321', OTHER_ELECTION, CREATED), tag); + assert.notEqual(documentTagFor('987654322', ELECTION, CREATED), tag); + }); + + test('never equals the human tag of the same number', () => { + // A domain label separates them, so one cannot stand in for the other. + assert.notEqual(documentTagFor(WORLD_ID, ELECTION, CREATED), humanTagFor(WORLD_ID, ELECTION, CREATED)); + }); +}); + describe('signing a private enrolment', () => { test('recovers to the attester, over the values the contract will hash', async () => { const tag = humanTagFor(WORLD_ID, ELECTION, CREATED); @@ -114,6 +130,7 @@ describe('signing a private enrolment', () => { CHAIN_ID, COMMITMENT, tag, + DOC_TAG, now, ); @@ -126,10 +143,11 @@ describe('signing a private enrolment', () => { PrivateEnrollment: [ { name: 'identityCommitment', type: 'uint256' }, { name: 'humanTag', type: 'uint256' }, + { name: 'documentTag', type: 'uint256' }, { name: 'deadline', type: 'uint256' }, ], }, - { identityCommitment: BigInt(COMMITMENT), humanTag: BigInt(tag), deadline }, + { identityCommitment: BigInt(COMMITMENT), humanTag: BigInt(tag), documentTag: BigInt(DOC_TAG), deadline }, signature, ); @@ -144,6 +162,7 @@ describe('signing a private enrolment', () => { CHAIN_ID, COMMITMENT, tag, + DOC_TAG, now, ); @@ -154,10 +173,11 @@ describe('signing a private enrolment', () => { PrivateEnrollment: [ { name: 'identityCommitment', type: 'uint256' }, { name: 'humanTag', type: 'uint256' }, + { name: 'documentTag', type: 'uint256' }, { name: 'deadline', type: 'uint256' }, ], }, - { identityCommitment: BigInt(COMMITMENT), humanTag: BigInt(tag), deadline }, + { identityCommitment: BigInt(COMMITMENT), humanTag: BigInt(tag), documentTag: BigInt(DOC_TAG), deadline }, signature, ); diff --git a/backend/src/eligibility/sessions.ts b/backend/src/eligibility/sessions.ts index 29a10c1..796981a 100644 --- a/backend/src/eligibility/sessions.ts +++ b/backend/src/eligibility/sessions.ts @@ -25,6 +25,9 @@ export const SESSION_TTL_MS = 15 * 60 * 1000; /** Bound so a flood of unfinished sessions cannot grow the heap without limit. */ const MAX_SESSIONS = 5_000; +/** Checks one voter may have open at once, across all elections. */ +const MAX_SESSIONS_PER_VOTER = 5; + export type SessionStatus = 'pending' | 'passed' | 'failed'; export interface EligibilitySession { @@ -55,8 +58,15 @@ export function createSession(election: string, voter: string): EligibilitySessi const now = Date.now(); sweep(now); - // Oldest first, because Map preserves insertion order and every entry is - // inserted with the timestamp it carries. + // A voter's own sessions are bounded first, so one account opening checks in + // a loop evicts only its own and never anybody else's in-flight passport scan. + const own = [...sessions.values()].filter(existing => existing.voter === voter); + for (const stale of own.slice(0, Math.max(0, own.length - (MAX_SESSIONS_PER_VOTER - 1)))) { + sessions.delete(stale.id); + } + + // Then the global ceiling, oldest first, because Map preserves insertion + // order and every entry is inserted with the timestamp it carries. while (sessions.size >= MAX_SESSIONS) { const oldest = sessions.keys().next(); if (oldest.done) break; diff --git a/backend/src/identity/preferences.ts b/backend/src/identity/preferences.ts index 8a16d48..697a4d9 100644 --- a/backend/src/identity/preferences.ts +++ b/backend/src/identity/preferences.ts @@ -19,7 +19,7 @@ * * Env: as `chain/registrar.ts`. */ -import { getRegistryReader, getRegistryWriter, isRegistrarConfigured } from '../chain/registrar.js'; +import { getRegistryReader, isRegistrarConfigured, writeRegistry } from '../chain/registrar.js'; import { toHex, fromHex, VaultUnavailableError } from './vault.js'; /** @@ -85,7 +85,5 @@ export async function setPreferences(nullifier: string, blob: string): Promise MAX_PREFERENCES_BYTES) throw new PreferencesTooLargeError(); - const registry = getRegistryWriter(); - const tx = await registry.setPreferences(nullifier, toHex(blob)); - await tx.wait(); + await writeRegistry(r => r.setPreferences(nullifier, toHex(blob))); } diff --git a/backend/src/identity/vault.ts b/backend/src/identity/vault.ts index a7223aa..48e9696 100644 --- a/backend/src/identity/vault.ts +++ b/backend/src/identity/vault.ts @@ -32,7 +32,7 @@ * REGISTRY_ADDRESS PlatformRegistry deployment address * REGISTRAR_PRIVATE_KEY key owning PlatformRegistry */ -import { getRegistryReader, getRegistryWriter, isRegistrarConfigured } from '../chain/registrar.js'; +import { getRegistryReader, isRegistrarConfigured, writeRegistry } from '../chain/registrar.js'; export interface VaultEntry { /** Base64url WebAuthn credential id. Public. */ @@ -138,13 +138,11 @@ export async function putVaultEntry( await removeVaultEntry(nullifier, entry.credentialId); } - const registry = getRegistryWriter(); - const tx = await registry.addVaultEntry( + await writeRegistry(r => r.addVaultEntry( nullifier, toHex(entry.credentialId), toHex(entry.blob), - ); - await tx.wait(); + )); return (await getVault(nullifier)) as VaultRecord; } @@ -173,10 +171,8 @@ export async function clearVault(nullifier: string): Promise { requireChain(); const current = await getVault(nullifier); if (!current) return; - const registry = getRegistryWriter(); for (const entry of current.entries) { - const tx = await registry.removeVaultEntry(nullifier, toHex(entry.credentialId)); - await tx.wait(); + await writeRegistry(r => r.removeVaultEntry(nullifier, toHex(entry.credentialId))); } } @@ -185,9 +181,7 @@ export async function removeVaultEntry( credentialId: string, ): Promise { requireChain(); - const registry = getRegistryWriter(); - const tx = await registry.removeVaultEntry(nullifier, toHex(credentialId)); - await tx.wait(); + await writeRegistry(r => r.removeVaultEntry(nullifier, toHex(credentialId))); return getVault(nullifier); } @@ -205,9 +199,7 @@ export async function resetVault( ): Promise { requireChain(); - const registry = getRegistryWriter(); - const tx = await registry.resetVault(nullifier, toHex(entry.credentialId), toHex(entry.blob)); - await tx.wait(); + await writeRegistry(r => r.resetVault(nullifier, toHex(entry.credentialId), toHex(entry.blob))); const record = (await getVault(nullifier)) as VaultRecord; if (record.commitment !== commitment) throw new CommitmentMismatchError(); diff --git a/backend/src/index.ts b/backend/src/index.ts index 3854340..ea750b3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,5 +1,5 @@ import './env.js'; -import express from 'express'; +import express, { type NextFunction, type Request, type Response } from 'express'; import cors from 'cors'; import cookieParser from 'cookie-parser'; import rateLimit from 'express-rate-limit'; @@ -13,6 +13,7 @@ import enrolmentRouter from './routes/enrolment.js'; import preferencesRouter from './routes/preferences.js'; const app = express(); +app.disable('x-powered-by'); const port = process.env.PORT || 3000; const isDev = process.env.NODE_ENV !== 'production'; @@ -71,7 +72,21 @@ if (corsOrigins.length > 0) { app.use(cors({ origin: corsOrigins, credentials: true })); } -app.use(express.json()); +/** + * Headers every response carries. This server answers JSON to scripts, never + * pages to people, so the policy is the strictest there is: nothing may be + * framed, sniffed, embedded or loaded from it. + */ +app.use((_req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'"); + res.setHeader('Cross-Origin-Resource-Policy', 'same-site'); + next(); +}); + +app.use(express.json({ limit: '64kb' })); app.use(cookieParser()); // Global API rate limit + a stricter one for the expensive verification path @@ -80,6 +95,12 @@ app.use( '/api/verify-human', rateLimit({ windowMs: 60_000, limit: 10, standardHeaders: true, legacyHeaders: false }), ); +// Opening a World ID request holds a slot in a bounded in-memory store for +// five minutes, so one address must not be able to fill it for everybody. +app.post( + '/api/worldid/request', + rateLimit({ windowMs: 60_000, limit: 10, standardHeaders: true, legacyHeaders: false }), +); app.use('/api', verifyRouter); app.use('/api', credentialsRouter); @@ -90,10 +111,31 @@ app.use('/api', eligibilityRouter); app.use('/api', enrolmentRouter); app.use('/api', preferencesRouter); -app.get('/health', (req, res) => { +app.get('/health', (_req, res) => { res.json({ status: 'OK', message: 'Votain VC Issuer Backend is running' }); }); +app.use('/api', (_req, res) => { + res.status(404).json({ error: 'Not found' }); +}); + +/** + * The last word on anything a route threw and did not answer. + * + * Express's own handler writes an HTML page with the stack trace outside + * production. This one logs the detail here and tells the caller only that + * something failed, in the JSON every other answer uses. + */ +app.use((error: unknown, _req: Request, res: Response, next: NextFunction) => { + if (res.headersSent) return next(error); + const status = (error as { status?: number; type?: string })?.status; + if (status && status >= 400 && status < 500) { + return res.status(status).json({ error: 'Bad request' }); + } + console.error('Unhandled error:', error); + return res.status(500).json({ error: 'Internal server error' }); +}); + /** * WHICH INTERFACE TO ANSWER ON, and why it is not simply "all of them". * diff --git a/backend/src/routes/credentials.ts b/backend/src/routes/credentials.ts index 1472841..e962b12 100644 --- a/backend/src/routes/credentials.ts +++ b/backend/src/routes/credentials.ts @@ -4,11 +4,26 @@ * - SD-JWT presentation verification (selective disclosure) */ import { Router, Request, Response } from 'express'; +import { createHash, timingSafeEqual } from 'node:crypto'; import { statusListCredential, revokeIndex, isRevoked, DEFAULT_LIST_ID } from '../status/statusList.js'; import { sdJwt, issuerPublicKeyPem } from '../sd/issuer.js'; +import { chainFailure, errorMessage } from '../utils/errors.js'; const router = Router(); +/** + * Compares a bearer header against the admin token in constant time. + * + * Both sides are hashed first so the comparison never depends on, or leaks, + * the token's length. + */ +function isAdmin(header: string | undefined): boolean { + const token = process.env.ADMIN_TOKEN; + if (!token || !header) return false; + const digest = (value: string) => createHash('sha256').update(value).digest(); + return timingSafeEqual(digest(header), digest(`Bearer ${token}`)); +} + // ──────────────────────────────────────────────── // GET /credentials/status/:listId: StatusList2021Credential // ──────────────────────────────────────────────── @@ -24,15 +39,20 @@ router.get('/credentials/status/:listId', async (req: Request, res: Response) => // POST /credentials/status/:listId/revoke: admin-only revocation // ──────────────────────────────────────────────── router.post('/credentials/status/:listId/revoke', async (req: Request, res: Response) => { - const adminToken = process.env.ADMIN_TOKEN; - if (!adminToken || req.headers.authorization !== `Bearer ${adminToken}`) { + if (!isAdmin(req.headers.authorization)) { return res.status(401).json({ error: 'Unauthorized' }); } const { index } = req.body as { index?: number }; - if (typeof index !== 'number' || index < 0) { - return res.status(400).json({ error: 'index (number) required' }); + // Slots start at 1: zero names nobody, and the contract refuses it. + if (typeof index !== 'number' || !Number.isSafeInteger(index) || index < 1) { + return res.status(400).json({ error: 'index must be a positive integer' }); + } + try { + await revokeIndex(index); + } catch (error: unknown) { + console.error('Revocation failed:', errorMessage(error)); + return res.status(502).json({ error: chainFailure(error) }); } - await revokeIndex(index); return res.status(200).json({ revoked: true, index }); }); @@ -69,15 +89,16 @@ router.post('/present', async (req: Request, res: Response) => { return res.status(401).json({ verified: false, reason: 'revoked' }); } + // A credential with no expiry would be valid forever; every one this + // issuer signs has one, so its absence means it is not one of ours. const now = Math.floor(Date.now() / 1000); - if (typeof payload.exp === 'number' && payload.exp < now) { + if (typeof payload.exp !== 'number' || payload.exp < now) { return res.status(401).json({ verified: false, reason: 'expired' }); } return res.status(200).json({ verified: true, claims: payload }); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; - return res.status(401).json({ verified: false, reason: message }); + } catch { + return res.status(401).json({ verified: false, reason: 'invalid presentation' }); } }); diff --git a/backend/src/routes/eligibility.ts b/backend/src/routes/eligibility.ts index b6cd6d0..deb5b0c 100644 --- a/backend/src/routes/eligibility.ts +++ b/backend/src/routes/eligibility.ts @@ -30,12 +30,9 @@ import { isChainConfigured, } from '../chain/election.js'; import { - effectivePersonhood, isEmptyPolicy, meetsPersonhood, - type EligibilityPolicy, } from '../eligibility/policy.js'; -import type { CredentialLevel } from '../auth/worldId.js'; import { isSelfConfigured, isMockMode, @@ -58,6 +55,7 @@ import { markSession, consumeSession, } from '../eligibility/sessions.js'; +import { clientMessage } from '../utils/errors.js'; const router = Router(); @@ -125,8 +123,7 @@ router.get('/eligibility/:election', async (req: Request, res: Response) => { policyHash, }); } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return res.status(400).json({ error: message }); + return res.status(400).json({ error: clientMessage(error) }); } }); @@ -141,8 +138,7 @@ router.post('/eligibility/:election/session', eligibilityLimiter, async (req: Re try { eligibility = await readElectionEligibility(election); } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return res.status(400).json({ error: message }); + return res.status(400).json({ error: clientMessage(error) }); } if (isEmptyPolicy(eligibility.policy)) { diff --git a/backend/src/routes/identity.ts b/backend/src/routes/identity.ts index 7c10db3..d65f53a 100644 --- a/backend/src/routes/identity.ts +++ b/backend/src/routes/identity.ts @@ -23,6 +23,8 @@ import { registerOnChain, rotateOnChain, isRegistrarConfigured } from '../chain/ import { verifySession } from '../auth/session.js'; import { readSessionCookie } from '../auth/cookie.js'; import { verifyWorldIdProof, type WorldIdPayload } from '../auth/worldId.js'; +import { perVoterLimit, requireSession, sessionOf } from '../auth/voterLimit.js'; +import { chainFailure, errorMessage } from '../utils/errors.js'; const router = Router(); @@ -35,6 +37,34 @@ const recoverLimiter = rateLimit({ legacyHeaders: false, }); +/** + * Vault writes are registry transactions the platform pays for, so they are + * counted per voter. A voter adds a passkey a handful of times in their life; + * ten an hour is a ceiling nobody honest meets. + */ +const vaultWriteQuota = perVoterLimit(60 * 60 * 1000, 10); + +/** + * The ceilings `PlatformRegistry` enforces, checked here first so an oversized + * request is refused before it costs a simulation. A WebAuthn credential id is + * at most 1023 bytes by specification; a sealed secret is an IV, a recovery + * phrase and a tag. + */ +const MAX_CREDENTIAL_ID_BYTES = 1023; +const MAX_VAULT_BLOB_BYTES = 512; +const MAX_VAULT_ENTRIES = 8; + +/** Why a base64url vault field is unacceptable, or null when it is fine. */ +function vaultFieldProblem(value: unknown, maxBytes: number, name: string): string | null { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value)) { + return `${name} must be base64url`; + } + if (Buffer.from(value, 'base64url').length > maxBytes) { + return `${name} must be at most ${maxBytes} bytes`; + } + return null; +} + /** Reads the voter's World ID nullifier from their verified session cookie. */ async function sessionNullifier(req: Request): Promise { const session = await verifySession(readSessionCookie(req)); @@ -74,9 +104,8 @@ router.get('/identity/vault', async (req: Request, res: Response) => { // ──────────────────────────────────────────────── // POST /identity/vault: register a passkey for this identity // ──────────────────────────────────────────────── -router.post('/identity/vault', async (req: Request, res: Response) => { - const nullifier = await sessionNullifier(req); - if (!nullifier) return res.status(401).json({ error: 'Not authenticated' }); +router.post('/identity/vault', requireSession, vaultWriteQuota, async (req: Request, res: Response) => { + const nullifier = sessionOf(res).nullifier; const { credentialId, blob, commitment } = req.body as { credentialId?: string; @@ -95,6 +124,12 @@ router.post('/identity/vault', async (req: Request, res: Response) => { if (Boolean(credentialId) !== Boolean(blob)) { return res.status(400).json({ error: 'credentialId and blob go together' }); } + if (credentialId && blob) { + const problem = + vaultFieldProblem(credentialId, MAX_CREDENTIAL_ID_BYTES, 'credentialId') ?? + vaultFieldProblem(blob, MAX_VAULT_BLOB_BYTES, 'blob'); + if (problem) return res.status(400).json({ error: problem }); + } // Ahead of the read, not after it: `getVault` throws without a configured // chain, so every 503 this route can return had to be decided before it. @@ -170,10 +205,9 @@ router.post('/identity/vault', async (req: Request, res: Response) => { registrationTx: probe.txHash, }); } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.error('Registered, but the vault entry did not get written:', message); + console.error('Registered, but the vault entry did not get written:', errorMessage(error)); return res.status(502).json({ - error: message, + error: chainFailure(error), code: 'vault_write_failed', onchainRegistered: true, registrationTx: probe.txHash, @@ -181,6 +215,14 @@ router.post('/identity/vault', async (req: Request, res: Response) => { } } + const replacing = existing?.entries.some(e => e.credentialId === credentialId) ?? false; + if (!replacing && (existing?.entries.length ?? 0) >= MAX_VAULT_ENTRIES) { + return res.status(409).json({ + error: `At most ${MAX_VAULT_ENTRIES} passkeys can open one identity`, + code: 'too_many_passkeys', + }); + } + let record; try { record = await putVaultEntry(nullifier, commitment, { credentialId, blob }); @@ -208,9 +250,8 @@ router.post('/identity/vault', async (req: Request, res: Response) => { // ──────────────────────────────────────────────── // DELETE /identity/vault/:credentialId: unlink a passkey // ──────────────────────────────────────────────── -router.delete('/identity/vault/:credentialId', async (req: Request, res: Response) => { - const nullifier = await sessionNullifier(req); - if (!nullifier) return res.status(401).json({ error: 'Not authenticated' }); +router.delete('/identity/vault/:credentialId', requireSession, vaultWriteQuota, async (req: Request, res: Response) => { + const nullifier = sessionOf(res).nullifier; const record = await getVault(nullifier); if (!record) return res.status(404).json({ error: 'No vault for this voter' }); @@ -267,6 +308,12 @@ router.post('/identity/recover', recoverLimiter, async (req: Request, res: Respo if (Boolean(credentialId) !== Boolean(blob)) { return res.status(400).json({ error: 'credentialId and blob go together' }); } + if (credentialId && blob) { + const problem = + vaultFieldProblem(credentialId, MAX_CREDENTIAL_ID_BYTES, 'credentialId') ?? + vaultFieldProblem(blob, MAX_VAULT_BLOB_BYTES, 'blob'); + if (problem) return res.status(400).json({ error: problem }); + } const proof = await verifyWorldIdProof(worldIdProof); if (!proof.ok || !proof.nullifier) { diff --git a/backend/src/routes/organizerDomains.ts b/backend/src/routes/organizerDomains.ts index 27b7914..fd5ec1a 100644 --- a/backend/src/routes/organizerDomains.ts +++ b/backend/src/routes/organizerDomains.ts @@ -14,7 +14,6 @@ */ import { Router, Request, Response } from 'express'; import rateLimit from 'express-rate-limit'; -import { verifyMessage } from 'ethers'; import { checkDomain, expectedRecord, diff --git a/backend/src/routes/preferences.ts b/backend/src/routes/preferences.ts index e5c154f..52b4976 100644 --- a/backend/src/routes/preferences.ts +++ b/backend/src/routes/preferences.ts @@ -14,7 +14,7 @@ * looked for into one this server hands out on request. */ import { Router, Request, Response } from 'express'; -import rateLimit from 'express-rate-limit'; +import { perVoterLimit, requireSession, sessionOf } from '../auth/voterLimit.js'; import { verifySession } from '../auth/session.js'; import { readSessionCookie } from '../auth/cookie.js'; import { @@ -30,19 +30,15 @@ import { VaultUnavailableError } from '../identity/vault.js'; const router = Router(); /** - * Writes are a transaction each, so they are limited more tightly than reads. + * Writes are a transaction each, paid for by the platform, so they are + * limited per VOTER (see `auth/voterLimit`), not per address. * * The browser already batches: saving an election is instant and local, and the * blob is pushed once the voter stops clicking. This is the backstop for a * client that does not, and 20 an hour is far above what the debounce produces * and far below what would cost the relayer anything. */ -const writeLimiter = rateLimit({ - windowMs: 60 * 60 * 1000, - limit: 20, - standardHeaders: true, - legacyHeaders: false, -}); +const writeQuota = perVoterLimit(60 * 60 * 1000, 20); /** Reads the voter's World ID nullifier from their verified session cookie. */ async function sessionNullifier(req: Request): Promise { @@ -73,9 +69,8 @@ router.get('/preferences', async (req: Request, res: Response) => { // ──────────────────────────────────────────────── // PUT /preferences: replace them // ──────────────────────────────────────────────── -router.put('/preferences', writeLimiter, async (req: Request, res: Response) => { - const nullifier = await sessionNullifier(req); - if (!nullifier) return res.status(401).json({ error: 'Not authenticated' }); +router.put('/preferences', requireSession, writeQuota, async (req: Request, res: Response) => { + const nullifier = sessionOf(res).nullifier; const { blob } = req.body as { blob?: unknown }; // An empty string is valid and means "clear": a voter who unsaves their last diff --git a/backend/src/routes/relay.ts b/backend/src/routes/relay.ts index 69fc49d..df32629 100644 --- a/backend/src/routes/relay.ts +++ b/backend/src/routes/relay.ts @@ -31,6 +31,10 @@ import { verifySession } from '../auth/session.js'; import { readSessionCookie } from '../auth/cookie.js'; import { authorisePrivateEnrolment, isRefusal } from '../eligibility/enrolment.js'; import { readEnrolmentMode } from '../chain/election.js'; +import { consumeSession } from '../eligibility/sessions.js'; + +/** A 2048-bit Paillier ciphertext is at most 512 bytes; see ElectionV4.MAX_BALLOT_BYTES. */ +const MAX_BALLOT_HEX = 2 + 512 * 2; const router = Router(); @@ -96,11 +100,14 @@ router.post('/relay/enroll', relayLimiter, async (req: Request, res: Response) = election, identityCommitment, authorisation.humanTag, + authorisation.documentTag, authorisation.deadline, authorisation.signature, authorisation.eligibilitySignature, ); if (!relayed.relayed) return res.status(400).json({ error: relayed.error }); + // Only now: a failed relay leaves the passport check usable for a retry. + if (sessionId) consumeSession(sessionId); return res.status(200).json({ txHash: relayed.txHash }); } @@ -111,6 +118,9 @@ router.post('/relay/enroll', relayLimiter, async (req: Request, res: Response) = if (attested && !/^\d+$/.test(personhoodNullifier ?? '')) { return res.status(400).json({ error: 'personhoodNullifier must be a decimal string' }); } + if (attested && (!Number.isSafeInteger(deadline) || typeof signature !== 'string')) { + return res.status(400).json({ error: 'deadline must be an integer and signature a string' }); + } const result = attested ? await relayEnrollAttested( @@ -138,6 +148,11 @@ router.post('/relay/vote', relayLimiter, async (req: Request, res: Response) => if (missing.length > 0) { return res.status(400).json({ error: `missing fields: ${missing.join(', ')}` }); } + // Refused here as well as on chain, so an oversized body never reaches the + // simulation, let alone the relayer's float. + if (typeof body.voteCiphertext !== 'string' || body.voteCiphertext.length > MAX_BALLOT_HEX) { + return res.status(400).json({ error: 'voteCiphertext is not a ballot' }); + } const result = await relayVote(body as VoteCall); if (!result.relayed) return res.status(400).json({ error: result.error }); diff --git a/backend/src/routes/verify.ts b/backend/src/routes/verify.ts index 1f196bd..f757814 100644 --- a/backend/src/routes/verify.ts +++ b/backend/src/routes/verify.ts @@ -16,7 +16,7 @@ import { readPendingVerification, endPendingVerification, } from '../auth/worldIdBridge.js'; -import { verifyWorldIdProof, type WorldIdPayload } from '../auth/worldId.js'; +import { verifyWorldIdProof, worldIdAction, type WorldIdPayload } from '../auth/worldId.js'; const router = Router(); @@ -77,7 +77,8 @@ router.post('/logout', (_req: Request, res: Response) => { /** POST /worldid/request — opens one, and names it in an httpOnly cookie. */ router.post('/worldid/request', async (req: Request, res: Response) => { try { - const { action, returnTo } = req.body as { action?: string; returnTo?: unknown }; + // No `action` from the body, whatever it says: see `worldIdAction`. + const { returnTo } = req.body as { returnTo?: unknown }; // One verification per browser at a time. Without this, pressing the // button twice leaves the first one orphaned in the store with nothing @@ -85,7 +86,7 @@ router.post('/worldid/request', async (req: Request, res: Response) => { endPendingVerification(readPendingCookie(req)); const { pendingId, connectorURI } = await startPendingVerification( - action ?? process.env.WORLD_ID_ACTION ?? 'vote-registration', + worldIdAction(), // KEPT FOR A CLIENT THAT CAN USE IT, which the web frontend is not. // `return_to` is a deep link, and World's own example is a custom // scheme: `myapp://verify-done`. A native app registers one of those and @@ -104,9 +105,8 @@ router.post('/worldid/request', async (req: Request, res: Response) => { setPendingCookie(res, pendingId); return res.status(200).json({ connectorURI }); } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Error opening World ID request:', error); - return res.status(500).json({ error: 'Internal server error', message }); + return res.status(500).json({ error: 'Could not open a World ID request' }); } }); @@ -177,7 +177,6 @@ router.post('/verify-human', async (req: Request, res: Response) => { const nullifier_hash = verified.nullifier ?? ''; - // Revocation entry for this credential // The slot belongs to the HUMAN and is assigned by `registerMember`, so // signing in costs no transaction. Zero until they are registered, which is // the state a first-time voter is in while the vault write is still to come; @@ -197,18 +196,27 @@ router.post('/verify-human', async (req: Request, res: Response) => { // accepts any credential: Orbs were withdrawn from Spain, and gating the // front door on one would lock out the voters this project is for. personhood: verified.level, - // Selective-disclosure identity attributes. Demo issuer values until - // World ID Credentials selective disclosure is wired (see docs). - country: process.env.DEMO_VC_COUNTRY ?? 'ES', - ageOver18: true, - region: process.env.DEMO_VC_REGION ?? 'Madrid', - credentialStatus: { - id: `${baseUrl}/api/credentials/status/${DEFAULT_LIST_ID}#${statusIndex}`, - type: 'StatusList2021Entry', - statusPurpose: 'revocation', - statusListIndex: String(statusIndex), - statusListCredential: `${baseUrl}/api/credentials/status/${DEFAULT_LIST_ID}`, - }, + // NO IDENTITY ATTRIBUTES. This credential used to assert a country, a + // region and `ageOver18: true` for everybody, as placeholders. A signed + // credential is a statement by the issuer, and nothing here has verified + // any of those facts: age and nationality are proved per election, from + // the voter's own document, through `eligibility/`. The selective + // disclosure frame stays for when a verified source provides them. + // + // A status entry only once the human HAS a slot. Slot 0 names nobody and + // could never be revoked; a first-time voter is registered moments later, + // and `verifySession` then checks the slot their registration assigned. + ...(statusIndex > 0 + ? { + credentialStatus: { + id: `${baseUrl}/api/credentials/status/${DEFAULT_LIST_ID}#${statusIndex}`, + type: 'StatusList2021Entry' as const, + statusPurpose: 'revocation' as const, + statusListIndex: String(statusIndex), + statusListCredential: `${baseUrl}/api/credentials/status/${DEFAULT_LIST_ID}`, + }, + } + : {}), }; const issuedCredential = await sdJwt.issue(credentialPayload, SELECTIVE_DISCLOSURE_FRAME); @@ -231,9 +239,8 @@ router.post('/verify-human', async (req: Request, res: Response) => { }); } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Error verifying human:', error); - return res.status(500).json({ error: 'Internal server error', message }); + return res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/backend/src/sd/issuer.ts b/backend/src/sd/issuer.ts index c2da687..a841fcf 100644 --- a/backend/src/sd/issuer.ts +++ b/backend/src/sd/issuer.ts @@ -1,10 +1,11 @@ /** * Shared SD-JWT issuer/verifier instance (EdDSA / Ed25519). * - * Selective disclosure: identity attributes (country, ageOver18, region) are - * issued as _sd claims so the voter can disclose only what an election's - * eligibility check needs. Sources: World ID Credentials when available; - * demo values otherwise (source-agnostic schema, see docs). + * Selective disclosure: identity attributes (country, ageOver18, region) would + * be issued as _sd claims so the voter could disclose only what a check needs. + * None is issued today, because no source this server trusts has verified + * them: eligibility is proved per election from the voter's own document (see + * `eligibility/`). The schema stays source-agnostic for when one exists. */ import crypto from 'crypto'; import { diff --git a/backend/src/status/statusList.ts b/backend/src/status/statusList.ts index 7db938b..24b6c7d 100644 --- a/backend/src/status/statusList.ts +++ b/backend/src/status/statusList.ts @@ -33,9 +33,13 @@ */ import { gzipSync, gunzipSync } from 'node:zlib'; -import { getRegistryReader, getRegistryWriter, isRegistrarConfigured } from '../chain/registrar.js'; +import { getRegistryReader, isRegistrarConfigured, writeRegistry } from '../chain/registrar.js'; -const LIST_SIZE_BITS = 131_072; // 16 KB bitstring, spec-recommended minimum +/** 16 KB bitstring, the spec-recommended minimum. Grows past it, never truncates. */ +const MIN_LIST_SIZE_BITS = 131_072; + +/** How many slots are read from the chain at once while building the list. */ +const READ_CONCURRENCY = 25; export const DEFAULT_LIST_ID = 'voters-1'; /** @@ -48,6 +52,8 @@ export const DEFAULT_LIST_ID = 'voters-1'; const CACHE_TTL_MS = 30_000; let cache: { revoked: Set; count: number; at: number } | null = null; +/** The scan in progress, so a burst of requests at expiry pays for one. */ +let inFlight: Promise<{ revoked: Set; count: number }> | null = null; /** * One slot's verdict, for the session path. @@ -62,11 +68,18 @@ async function readRevocations(): Promise<{ revoked: Set; count: number const registry = getRegistryReader(); const count = Number(await registry.memberCount()); - // One call per slot handed out. Bounded by the number of registered humans, - // and only paid when the cache has expired. + // One call per slot handed out, a batch at a time. Bounded by the number of + // registered humans, and only paid when the cache has expired. const revoked = new Set(); - for (let index = 1; index <= count; index++) { - if (await registry.revokedStatus(index)) revoked.add(index); + for (let start = 1; start <= count; start += READ_CONCURRENCY) { + const batch = Array.from( + { length: Math.min(READ_CONCURRENCY, count - start + 1) }, + (_, i) => start + i, + ); + const flags = await Promise.all(batch.map(index => registry.revokedStatus(index) as Promise)); + flags.forEach((isRevoked, i) => { + if (isRevoked) revoked.add(batch[i]); + }); } return { revoked, count }; } @@ -75,15 +88,22 @@ async function snapshot(): Promise<{ revoked: Set; count: number }> { if (!isRegistrarConfigured()) return { revoked: new Set(), count: 0 }; if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache; - const fresh = await readRevocations(); - cache = { ...fresh, at: Date.now() }; - return fresh; + inFlight ??= readRevocations() + .then(fresh => { + cache = { ...fresh, at: Date.now() }; + return fresh; + }) + .finally(() => { + inFlight = null; + }); + return inFlight; } /** Drops both caches, so a revocation made here is honoured immediately. */ export function refreshRevocations(): void { cache = null; slotCache.clear(); + humanSlots.clear(); } /** @@ -98,10 +118,31 @@ export async function statusIndexFor(nullifier: string): Promise { return Number(await getRegistryReader().statusIndexOf(nullifier)); } +/** + * The slot of a human, for a credential that does not carry one. + * + * A first-time voter signs in before they are registered, so their credential + * names no slot. Their registration assigns one moments later, and from then + * on it can be revoked: this is how the session path finds it. A slot never + * changes once assigned, so a found one is cached for good; "not registered + * yet" is cached only for the usual TTL. + */ +const humanSlots = new Map(); +const MAX_CACHED_HUMANS = 50_000; + +export async function statusSlotOfHuman(nullifier: string): Promise { + const cached = humanSlots.get(nullifier); + if (cached && (cached.slot > 0 || Date.now() - cached.at < CACHE_TTL_MS)) return cached.slot; + + const slot = await statusIndexFor(nullifier); + if (humanSlots.size >= MAX_CACHED_HUMANS) humanSlots.clear(); + humanSlots.set(nullifier, { slot, at: Date.now() }); + return slot; +} + export async function revokeIndex(index: number): Promise { - if (!isRegistrarConfigured()) return; - const tx = await getRegistryWriter().revokeStatus(index); - await tx.wait(); + if (!isRegistrarConfigured()) throw new Error('registrar not configured'); + await writeRegistry(r => r.revokeStatus(index)); refreshRevocations(); } @@ -120,8 +161,12 @@ export async function isRevoked(index: number): Promise { /** gzip+base64url bitstring with revoked bits set, per StatusList2021. */ export async function encodedList(): Promise { - const { revoked } = await snapshot(); - const bytes = Buffer.alloc(LIST_SIZE_BITS / 8); + const { revoked, count } = await snapshot(); + // Slot `count` is the highest handed out. A fixed 16 KB list silently lost + // every revocation past slot 131071, since writing beyond a Buffer is a + // no-op; the list now grows, in whole bytes, to hold every slot. + const bits = Math.max(MIN_LIST_SIZE_BITS, count + 1); + const bytes = Buffer.alloc(Math.ceil(bits / 8)); for (const index of revoked) { bytes[Math.floor(index / 8)] |= 1 << (7 - (index % 8)); } diff --git a/backend/src/utils/errors.test.ts b/backend/src/utils/errors.test.ts new file mode 100644 index 0000000..cdeb3a6 --- /dev/null +++ b/backend/src/utils/errors.test.ts @@ -0,0 +1,29 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; + +import { chainFailure, clientMessage } from './errors.js'; + +describe('what a client is told about a failed transaction', () => { + test('the revert name, when ethers decoded one', () => { + assert.equal(chainFailure({ revert: { name: 'AlreadyEnrolled' } }), 'reverted: AlreadyEnrolled'); + }); + + test('the selector, which the browser can still name', () => { + assert.equal( + chainFailure({ code: 'CALL_EXCEPTION', data: '0x6BA214A7' + '00'.repeat(32) }), + 'reverted: data="0x6ba214a7"', + ); + }); + + test('never the provider text, which can carry the RPC URL and its key', () => { + const leaky = Object.assign(new Error('could not coalesce error (url=https://rpc.example/v2/SECRET)'), { + code: 'SERVER_ERROR', + }); + assert.equal(clientMessage(leaky), 'transaction failed'); + assert.ok(!clientMessage(leaky).includes('SECRET')); + }); + + test("this server's own words pass through", () => { + assert.equal(clientMessage(new Error('election must be a valid address')), 'election must be a valid address'); + }); +}); diff --git a/backend/src/utils/errors.ts b/backend/src/utils/errors.ts new file mode 100644 index 0000000..b252f2c --- /dev/null +++ b/backend/src/utils/errors.ts @@ -0,0 +1,44 @@ +/** + * Error text, once. + * + * `errorMessage` is for this server's own log. `chainFailure` is what a client + * is told when a transaction fails, and it says less on purpose: an ethers + * error carries the RPC URL, the raw request and the provider's own wording, + * none of which a browser needs. What the browser does need is the NAME of the + * revert, or its four-byte selector, because that is what `revertNameOf` in + * the frontend turns into a sentence the voter can act on. + */ +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +interface EthersLikeError { + code?: string; + data?: unknown; + revert?: { name?: string } | null; + info?: { error?: { data?: unknown } }; +} + +export function chainFailure(error: unknown): string { + const err = (error ?? {}) as EthersLikeError; + if (typeof err.revert?.name === 'string' && err.revert.name) return `reverted: ${err.revert.name}`; + + const data = typeof err.data === 'string' ? err.data : err.info?.error?.data; + if (typeof data === 'string' && /^0x[0-9a-fA-F]{8}/.test(data)) { + return `reverted: data="${data.slice(0, 10).toLowerCase()}"`; + } + if (err.code === 'INSUFFICIENT_FUNDS') return 'the relayer is out of funds'; + if (err.code === 'CALL_EXCEPTION') return 'reverted'; + return 'transaction failed'; +} + +/** + * A message safe to hand a client: this server's own words as they are, and + * anything raised by ethers or the RPC reduced to `chainFailure`, since those + * can carry the RPC endpoint and its API key. + */ +export function clientMessage(error: unknown): string { + const code = (error as { code?: unknown } | null)?.code; + if (typeof code === 'string' && /^[A-Z_]+$/.test(code)) return chainFailure(error); + return errorMessage(error); +} diff --git a/backend/src/utils/keys.ts b/backend/src/utils/keys.ts index 625bd25..571543f 100644 --- a/backend/src/utils/keys.ts +++ b/backend/src/utils/keys.ts @@ -1,37 +1,33 @@ import crypto from 'crypto'; -// For Phase 2 local development, we simulate a simple ED25519 keypair for the Issuer. -// In production, the private key would be securely loaded from KMS or .env +/** + * The issuer's Ed25519 key pair, from `ISSUER_PRIVATE_KEY` (PKCS#8 DER, base64). + * + * Fails at start-up rather than at the first sign-in: a server that cannot + * sign credentials has nothing useful to offer. + */ export function getIssuerKeyPair() { const privateKeyBase64 = process.env.ISSUER_PRIVATE_KEY; if (!privateKeyBase64) { throw new Error('ISSUER_PRIVATE_KEY not set in .env'); } - + const privateKey = crypto.createPrivateKey({ key: Buffer.from(privateKeyBase64, 'base64'), format: 'der', - type: 'pkcs8' + type: 'pkcs8', }); // createPublicKey derives the public key from a private key PEM. @types/node 26 // narrowed the overloads and no longer accepts a KeyObject argument directly. const publicKey = crypto.createPublicKey( - privateKey.export({ format: 'pem', type: 'pkcs8' }) as string + privateKey.export({ format: 'pem', type: 'pkcs8' }) as string, ); - + return { publicKey, privateKey }; } -// Helper to sign the SD-JWT (mocking the exact algorithm/header requirements based on @sd-jwt/core config) -// The actual implementation depends on the exact @sd-jwt/core and jsonwebtoken signature approach. -export const issueSigner = async ( - data: string, - privateKey: crypto.KeyObject -): Promise => { - const signature = crypto.sign( - null, // null = Ed25519 handles the hash internally - Buffer.from(data, 'utf-8'), - privateKey - ); +/** Signs SD-JWT input with Ed25519, which hashes internally, as base64url. */ +export const issueSigner = async (data: string, privateKey: crypto.KeyObject): Promise => { + const signature = crypto.sign(null, Buffer.from(data, 'utf-8'), privateKey); return signature.toString('base64url'); }; diff --git a/backend/tsconfig.json b/backend/tsconfig.json index bf903fe..463301b 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -7,7 +7,9 @@ "outDir": "./dist", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, - "strict": true + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true }, "include": ["src/**/*"] } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 4eaa87b..8ada416 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -136,8 +136,6 @@ services: # design, so it has to be this deployment's public address. SELF_ENDPOINT: ${SELF_ENDPOINT} SELF_MOCK: ${SELF_MOCK} - DEMO_VC_COUNTRY: ${DEMO_VC_COUNTRY} - DEMO_VC_REGION: ${DEMO_VC_REGION} # Cheap and honest: /health touches nothing and allocates nothing, so a # pass means the process is up, not that the chain or World ID are. From 82fdbc427714223848052ff320739674fe536f79 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 13:38:37 +0000 Subject: [PATCH 04/32] fix(frontend): verifiable results, no invented receipt, safer identities - Results are checked in the reader's browser against the chain (TallyCheck), the organizer's tally publishes its proof, and the quorum is read from the contract. - The vote confirmation never shows a made-up reference number. - Per-election identities are cached per master identity, and a failed RPC no longer caches an election as publicly enrolling. - Recovery phrase derivation is stretched (PBKDF2, 600k rounds). - Wallets that sign non-deterministically get an exportable random tally key instead of one that could never be derived again. - Paginated election list and bounded concurrency for history and receipts. - oxlint; render-loop-safe state; Playwright smoke test works standalone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi --- frontend/.gitignore | 4 + frontend/.oxlintrc.json | 8 + frontend/DEVELOPMENT.md | 13 +- frontend/e2e/smoke.spec.ts | 40 +- frontend/package-lock.json | 513 ++++++++++++++++++ frontend/package.json | 7 + frontend/playwright.config.ts | 10 +- .../components/ui/CommitmentFingerprint.tsx | 2 +- .../src/components/ui/ElectionSummary.tsx | 13 +- frontend/src/components/ui/TallyCheck.tsx | 92 +++- frontend/src/contexts/AuthProvider.tsx | 15 +- frontend/src/data/seed.ts | 10 - frontend/src/hooks/usePageLimit.ts | 11 +- frontend/src/i18n/locales/ar.json | 24 +- frontend/src/i18n/locales/de.json | 24 +- frontend/src/i18n/locales/en.json | 24 +- frontend/src/i18n/locales/es.json | 24 +- frontend/src/i18n/locales/fr.json | 24 +- frontend/src/i18n/locales/hi.json | 24 +- frontend/src/i18n/locales/it.json | 24 +- frontend/src/i18n/locales/ja.json | 24 +- frontend/src/i18n/locales/ko.json | 24 +- frontend/src/i18n/locales/nl.json | 24 +- frontend/src/i18n/locales/pt.json | 24 +- frontend/src/i18n/locales/ru.json | 24 +- frontend/src/i18n/locales/zh.json | 24 +- frontend/src/lib/chainElections.ts | 45 +- frontend/src/lib/contracts.ts | 3 +- frontend/src/lib/deployments.ts | 1 - frontend/src/lib/electionIdentity.test.ts | 10 + frontend/src/lib/electionIdentity.ts | 15 +- frontend/src/lib/eligibility.ts | 2 +- frontend/src/lib/organizer.ts | 32 +- frontend/src/lib/organizerKey.test.ts | 53 ++ frontend/src/lib/organizerKey.ts | 52 +- frontend/src/lib/paillier.test.ts | 44 +- frontend/src/lib/paillier.ts | 50 +- frontend/src/lib/recoveryPhrase.ts | 34 +- frontend/src/lib/relay.ts | 14 +- frontend/src/lib/relayErrors.test.ts | 7 + frontend/src/lib/semaphore.ts | 5 + frontend/src/lib/tally.ts | 88 ++- frontend/src/lib/tallyAudit.ts | 62 +++ frontend/src/lib/utils.ts | 24 + frontend/src/lib/voteHistory.test.ts | 5 + frontend/src/lib/voting.ts | 43 +- frontend/src/lib/worldId.test.ts | 6 +- frontend/src/lib/worldId.ts | 8 +- .../pages/organizer/ElectionManagement.tsx | 28 +- frontend/src/pages/public/Discover.tsx | 19 +- .../src/pages/shared/SavedElections.test.tsx | 2 +- frontend/src/pages/voter/Onboarding.tsx | 13 +- frontend/src/pages/voter/VoteConfirmation.tsx | 51 +- 53 files changed, 1349 insertions(+), 417 deletions(-) create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/src/lib/organizerKey.test.ts create mode 100644 frontend/src/lib/tallyAudit.ts diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..81bc72f 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,3 +22,7 @@ dist-ssr *.njsproj *.sln *.sw? + +# Playwright output +test-results +playwright-report diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..5657ab8 --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "unicorn", "import", "oxc"], + "categories": { + "correctness": "error" + }, + "ignorePatterns": ["dist", "node_modules", "public"] +} diff --git a/frontend/DEVELOPMENT.md b/frontend/DEVELOPMENT.md index 89e8551..727173a 100644 --- a/frontend/DEVELOPMENT.md +++ b/frontend/DEVELOPMENT.md @@ -575,11 +575,14 @@ delete the copies, and says why. Worth stating for the thesis: this is also why the key must never be published as an audit artefact. Handing it to a verifier would let them confirm the tally, and -also read every individual ballot. A system that wants third-party verification -of the count publishes a proof of correct decryption instead, which Votain does -not implement. The count is verifiable here in the weaker sense that it is -immutable and its inputs are public, not in the sense that a stranger can -recompute it. +also read every individual ballot. Votain publishes a proof of correct +decryption instead (`src/lib/tallyProof.ts`): Paillier lets the key holder +recover the randomness of any ciphertext, so the organizer publishes the +randomness that opens the sum of the valid final ballots to exactly the +published counts, and opens each ballot excluded as invalid. A stranger checks +both against the `VoteCast` events with the public key alone, which is what the +results screen (`TallyCheck`) and `scripts-tally --verify` do. Nothing about any +valid ballot is revealed. ## Radix Selects are controlled, so a tap can close them diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index a2bb1c4..30f3983 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -36,20 +36,38 @@ const ORGANIZER_ROUTES = [ const ALL_ROUTES = [...PUBLIC_ROUTES, ...VOTER_ROUTES, ...ORGANIZER_ROUTES]; +/** + * No backend: every API call answers as a server with no session would. The + * smoke test is about the screens rendering, and a real issuer would make it + * depend on World ID and a chain it has no business needing. + */ +test.beforeEach(async ({ page }) => { + await page.route('**/api/**', route => + route.fulfill({ status: 401, contentType: 'application/json', body: '{"authenticated":false}' }), + ); +}); + +/** Console errors that are the stub above doing its job, not the app failing. */ +const expected = (message: string) => + message.includes('Warning:') || + message.includes('React DevTools') || + message.includes('status of 401'); + for (const { path, label } of ALL_ROUTES) { test(`[smoke] ${label} — ${path}`, async ({ page }) => { - await page.goto(path); - // No JS errors in console during navigation + // Listening before navigating, or the errors of the first paint are missed. const errors: string[] = []; page.on('console', msg => { if (msg.type() === 'error') errors.push(msg.text()); }); + page.on('pageerror', error => errors.push(error.message)); + await page.goto(path); // Wait for app to hydrate await page.waitForLoadState('networkidle'); // Page should not show an unhandled crash (React error boundary) await expect(page.locator('body')).not.toContainText('Something went wrong'); // No uncaught JS errors that would block render - expect(errors.filter(e => !e.includes('Warning:') && !e.includes('React DevTools'))).toHaveLength(0); + expect(errors.filter(e => !expected(e))).toHaveLength(0); }); } @@ -59,18 +77,18 @@ test('[smoke] 404 page', async ({ page }) => { await expect(page.locator('body')).toContainText('not found'); }); -test('[smoke] Discover — search and filter', async ({ page }) => { +test('[smoke] Discover — lists the demo elections', async ({ page }) => { await page.goto('/discover'); await page.waitForLoadState('networkidle'); - // Election cards should be visible - await expect(page.locator('[data-testid="election-card"]').or(page.locator('.election-card'))).toHaveCount(0).or( - expect(page.locator('h2, h3')).not.toHaveCount(0) - ); + // The demo catalogue renders a heading per election card. + await expect(page.locator('h2, h3').first()).toBeVisible(); }); -test('[smoke] Organizer Create Election — wizard navigation', async ({ page }) => { +test('[smoke] Organizer Create Election — not offered without a session', async ({ page }) => { await page.goto('/organizer/elections/new'); await page.waitForLoadState('networkidle'); - // Should show stepper with first step - await expect(page.locator('body')).toContainText('Info'); + // Without an organizer session the wizard is not offered: the visitor is + // sent back out (to the landing page today) rather than shown a broken form. + await expect(page).not.toHaveURL(/\/organizer\/elections\/new$/); + await expect(page.locator('body')).not.toContainText('Something went wrong'); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9b5a0ab..e592415 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,6 +37,7 @@ "tailwind-merge": "^3.7.0" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@tailwindcss/vite": "^4.3.3", "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", @@ -48,12 +49,17 @@ "autoprefixer": "^10.6.1", "globals": "^17.12.0", "jsdom": "^30.1.1", + "oxlint": "^1.85.0", "postcss": "^8.5.28", "tailwindcss": "^4.3.3", "typescript": "^7.0.2", "vite": "^8.3.0", "vite-plugin-node-polyfills": "^0.28.0", "vitest": "^5.0.1" + }, + "engines": { + "node": ">=24", + "npm": ">=11" } }, "node_modules/@adobe/css-tools": { @@ -1714,6 +1720,353 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.85.0.tgz", + "integrity": "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.85.0.tgz", + "integrity": "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.85.0.tgz", + "integrity": "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.85.0.tgz", + "integrity": "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.85.0.tgz", + "integrity": "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.85.0.tgz", + "integrity": "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.85.0.tgz", + "integrity": "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.85.0.tgz", + "integrity": "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.85.0.tgz", + "integrity": "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.85.0.tgz", + "integrity": "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.85.0.tgz", + "integrity": "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.85.0.tgz", + "integrity": "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.85.0.tgz", + "integrity": "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.85.0.tgz", + "integrity": "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.85.0.tgz", + "integrity": "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.85.0.tgz", + "integrity": "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.85.0.tgz", + "integrity": "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.85.0.tgz", + "integrity": "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.85.0.tgz", + "integrity": "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@phosphor-icons/webcomponents": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@phosphor-icons/webcomponents/-/webcomponents-2.1.5.tgz", @@ -1723,6 +2076,22 @@ "lit": "^3" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", @@ -3964,6 +4333,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", @@ -9218,6 +9653,55 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/oxlint": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.85.0.tgz", + "integrity": "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.85.0", + "@oxlint/binding-android-arm64": "1.85.0", + "@oxlint/binding-darwin-arm64": "1.85.0", + "@oxlint/binding-darwin-x64": "1.85.0", + "@oxlint/binding-freebsd-x64": "1.85.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", + "@oxlint/binding-linux-arm-musleabihf": "1.85.0", + "@oxlint/binding-linux-arm64-gnu": "1.85.0", + "@oxlint/binding-linux-arm64-musl": "1.85.0", + "@oxlint/binding-linux-ppc64-gnu": "1.85.0", + "@oxlint/binding-linux-riscv64-gnu": "1.85.0", + "@oxlint/binding-linux-riscv64-musl": "1.85.0", + "@oxlint/binding-linux-s390x-gnu": "1.85.0", + "@oxlint/binding-linux-x64-gnu": "1.85.0", + "@oxlint/binding-linux-x64-musl": "1.85.0", + "@oxlint/binding-openharmony-arm64": "1.85.0", + "@oxlint/binding-win32-arm64-msvc": "1.85.0", + "@oxlint/binding-win32-ia32-msvc": "1.85.0", + "@oxlint/binding-win32-x64-msvc": "1.85.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -9427,6 +9911,35 @@ "node": ">=10" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 62be5e2..71c2b20 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,10 +4,15 @@ "version": "1.0.0", "type": "module", "license": "AGPL-3.0-only", + "engines": { + "node": ">=24", + "npm": ">=11" + }, "scripts": { "dev": "vite --host", "build": "tsc -b && vite build", "typecheck": "tsc -b --noEmit", + "lint": "oxlint src e2e", "preview": "vite preview", "test": "vitest run", "test:e2e": "playwright test", @@ -44,6 +49,7 @@ "tailwind-merge": "^3.7.0" }, "devDependencies": { + "@playwright/test": "^1.63.0", "@tailwindcss/vite": "^4.3.3", "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", @@ -55,6 +61,7 @@ "autoprefixer": "^10.6.1", "globals": "^17.12.0", "jsdom": "^30.1.1", + "oxlint": "^1.85.0", "postcss": "^8.5.28", "tailwindcss": "^4.3.3", "typescript": "^7.0.2", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 8b408e8..77597d0 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -11,6 +11,11 @@ export default defineConfig({ baseURL: 'http://localhost:5173', trace: 'on-first-retry', screenshot: 'only-on-failure', + // A machine with its own Chromium (a sandbox, a distro package) can point + // here instead of downloading the revision this Playwright pins. + ...(process.env.PLAYWRIGHT_CHROMIUM_PATH + ? { launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH } } + : {}), }, projects: [ { @@ -19,11 +24,12 @@ export default defineConfig({ }, { name: 'tablet', - use: { ...devices['iPad Mini'], viewport: { width: 768, height: 1024 } }, + // Chromium device profiles, so one browser install covers all three. + use: { ...devices['Galaxy Tab S4'], viewport: { width: 768, height: 1024 } }, }, { name: 'mobile', - use: { ...devices['iPhone 12'], viewport: { width: 375, height: 667 } }, + use: { ...devices['Pixel 5'], viewport: { width: 375, height: 667 } }, }, ], webServer: { diff --git a/frontend/src/components/ui/CommitmentFingerprint.tsx b/frontend/src/components/ui/CommitmentFingerprint.tsx index 4b4aae5..5517497 100644 --- a/frontend/src/components/ui/CommitmentFingerprint.tsx +++ b/frontend/src/components/ui/CommitmentFingerprint.tsx @@ -64,7 +64,7 @@ export function fingerprintOf(value: string): FingerprintShape { const bits = hash32(value.toLowerCase()); const hue = hash32(`${value.toLowerCase()}:hue`) % 360; - const cells: boolean[] = new Array(GRID * ROWS).fill(false); + const cells: boolean[] = Array.from({ length: GRID * ROWS }, () => false); for (let row = 0; row < ROWS; row++) { for (let col = 0; col < COLUMNS; col++) { const on = ((bits >>> (row * COLUMNS + col)) & 1) === 1; diff --git a/frontend/src/components/ui/ElectionSummary.tsx b/frontend/src/components/ui/ElectionSummary.tsx index db8c10a..0313484 100644 --- a/frontend/src/components/ui/ElectionSummary.tsx +++ b/frontend/src/components/ui/ElectionSummary.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { EyeOff, Fuel, Info, Percent, Repeat2, Users, Vote } from 'lucide-react'; -import { useRef, useState } from 'react'; +import { useState } from 'react'; import { cn } from '../../lib/utils'; import { Badge } from './Badge'; import { BlockchainBadge } from './BlockchainBadge'; @@ -235,11 +235,12 @@ export function ElectionSchedule({ /** * The sentence still being shown, which outlives the press that closed it. * - * A ref and not state: nothing should re-render because a hint finished - * closing, and the value is only ever read in the same pass that writes it. + * State set during render, React's pattern for remembering a previous + * render: it only changes when a DIFFERENT hint opens, which re-renders + * anyway, so nothing re-renders because a hint finished closing. */ - const lastHint = useRef(null); - if (openHint) lastHint.current = openHint; + const [lastHint, setLastHint] = useState(null); + if (openHint && openHint !== lastHint) setLastHint(openHint); const funding = useElectionFunding(election.contractAddress); const voteCost = useVoteCost(); const reservedBallots = Math.floor(funding.reserved / voteCost.matic); @@ -345,7 +346,7 @@ export function ElectionSchedule({ } - const shownHint = figures.find(f => f.id === (openHint ?? lastHint.current))?.hint; + const shownHint = figures.find(f => f.id === (openHint ?? lastHint))?.hint; return ( diff --git a/frontend/src/components/ui/TallyCheck.tsx b/frontend/src/components/ui/TallyCheck.tsx index 4e1cdb5..2d642b4 100644 --- a/frontend/src/components/ui/TallyCheck.tsx +++ b/frontend/src/components/ui/TallyCheck.tsx @@ -1,52 +1,86 @@ +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ShieldAlert, ShieldCheck } from 'lucide-react'; +import { isAddress } from 'ethers'; +import { Loader2, ShieldAlert, ShieldCheck, ShieldQuestion } from 'lucide-react'; import { Card } from './Card'; import { cn } from '../../lib/utils'; -import type { Election } from '../../data/seed'; +import { hasPublishedResults, type Election } from '../../data/seed'; +import { isChainConfigured } from '../../lib/deployments'; +import { auditPublishedTally, type TallyAudit } from '../../lib/tallyAudit'; /** - * The half of a published result that anyone can check with no key. + * A published result, checked against the ballots on chain with no key. * - * Each voter's surviving ballot contributes exactly one to exactly one - * counter, so the counters have to add up to the number of voters the contract - * itself counted. Both numbers are public, so this catches invented or dropped - * ballots without anybody being trusted. It does NOT catch votes moved between - * options, which keeps the total intact and needs a proof of correct - * decryption. + * Runs `verifyTally` in the reader's own browser: the published counters have + * to be exactly what the valid final ballots add up to, and every ballot left + * out has to be opened and shown to be invalid. Anyone can repeat it and reach + * the same answer, so nobody has to take the organizer's word for the result. * - * WHY IT IS A COMPONENT. It lived inline on the public results page, which - * meant the organizer, the one person who produced the numbers, was the only - * reader who never saw the check applied to them. Publishing your own result - * and being shown that it verifies is worth more to an honest organizer than - * to anyone else. + * WHY IT IS A COMPONENT. The organizer, the one person who produced the + * numbers, is also a reader: publishing your own result and being shown that + * it verifies is worth more to an honest organizer than to anyone else. * - * Renders nothing until results are published, since there is nothing to - * check before that. + * Renders nothing until results are published, or on the demo data, which has + * no chain to check against. */ export function TallyCheck({ election, className }: { election: Election; className?: string }) { const { t } = useTranslation(); - const check = election.tallyCheck; - if (!check) return null; + const address = election.contractAddress; + const checkable = hasPublishedResults(election) && isChainConfigured() && isAddress(address); + // Keyed by the address it answers for, so a result never outlives the + // election it was about and "pending" needs no state of its own. + const [settled, setSettled] = useState<{ address: string; audit: TallyAudit | null } | null>(null); + + useEffect(() => { + if (!checkable) return; + let cancelled = false; + auditPublishedTally(address) + .then(result => { if (!cancelled) setSettled({ address, audit: result }); }) + .catch(() => { if (!cancelled) setSettled({ address, audit: null }); }); + return () => { cancelled = true; }; + }, [checkable, address]); + + const audit: TallyAudit | 'pending' | null = + settled?.address === address ? settled.audit : 'pending'; + if (!checkable || audit === null) return null; + + if (audit === 'pending') { + return ( + +

+ + {t('results.check_pending')} +

+
+ ); + } + + const Icon = + audit.status === 'verified' ? ShieldCheck : audit.status === 'failed' ? ShieldAlert : ShieldQuestion; + const tone = + audit.status === 'verified' ? 'text-success' : audit.status === 'failed' ? 'text-error' : 'text-warning'; return (
- {check.matches ? ( - - ) : ( - - )} +

- {t(check.matches ? 'results.check_ok_title' : 'results.check_bad_title')} + {t(`results.check_${audit.status}_title`)}

- {t(check.matches ? 'results.check_ok_body' : 'results.check_bad_body', { - declared: check.declared, - voters: check.voters, - })} + {audit.status === 'verified' + ? t('results.check_verified_body', { valid: audit.validBallots, voters: audit.voters }) + : t(`results.check_${audit.status}_body`)}

-

{t('results.check_limit')}

+ {audit.status === 'verified' && audit.invalidBallots > 0 && ( +

+ {t('results.check_excluded', { excluded: audit.invalidBallots })} +

+ )} + {audit.status === 'failed' && ( +

{audit.reason}

+ )}
diff --git a/frontend/src/contexts/AuthProvider.tsx b/frontend/src/contexts/AuthProvider.tsx index c3e5575..0250f9c 100644 --- a/frontend/src/contexts/AuthProvider.tsx +++ b/frontend/src/contexts/AuthProvider.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, type ReactNode } from 'react'; +import { useState, useEffect, useEffectEvent, type ReactNode } from 'react'; import { clearSessionSecrets } from '../lib/passkeyPrf'; import { clearOrganizerKeyCache } from '../lib/organizerKey'; import { clearIdentity } from '../lib/semaphore'; @@ -165,6 +165,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { // that had ended: the first anyone heard of it was an action failing. It now // runs again whenever the tab comes back, and a timer wakes it when the // credential's own expiry arrives. + // Effect events: the effects below run once and keep listening, and these + // let them call the latest version of each handler without re-subscribing. + const onSessionEnded = useEffectEvent(endExpiredVoterSession); + const onMembershipCheck = useEffectEvent(reconcileMembership); + useEffect(() => { let cancelled = false; let expiryTimer: number | undefined; @@ -183,7 +188,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { // they have no way to check it. const believed = localStorage.getItem(VOTER_KEY) === 'true'; // Definitive "not authenticated": clear any (possibly spoofed) flag. - endExpiredVoterSession(); + onSessionEnded(); if (believed) announceSessionExpired(); return null; } @@ -214,7 +219,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { // this person a credential; being a member says the chain holds // them, and the two part company whenever the registry is replaced // under a live session, which on a local chain is every restart. - if (data.nullifier) void reconcileMembership(data.nullifier); + if (data.nullifier) void onMembershipCheck(data.nullifier); // The credential's own deadline, from the credential. A tab open // across it ends the session at the right moment instead of carrying @@ -223,7 +228,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (remaining === null) return; window.clearTimeout(expiryTimer); expiryTimer = window.setTimeout( - () => (remaining === 0 ? endExpiredVoterSession() : reconcile()), + () => (remaining === 0 ? onSessionEnded() : reconcile()), remaining, ); }) @@ -252,7 +257,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { * credential REVOKED before its expiry. */ useEffect(() => { - const onExpired = () => endExpiredVoterSession(); + const onExpired = () => onSessionEnded(); window.addEventListener(SESSION_EXPIRED_EVENT, onExpired); return () => window.removeEventListener(SESSION_EXPIRED_EVENT, onExpired); }, []); diff --git a/frontend/src/data/seed.ts b/frontend/src/data/seed.ts index 5bdace8..272a304 100644 --- a/frontend/src/data/seed.ts +++ b/frontend/src/data/seed.ts @@ -116,16 +116,6 @@ export interface Election { castVotes: number; /** People who voted at least once. What a published tally has to add up to. */ distinctVoters?: number; - /** - * The public half of verifying a published result. - * - * Present only once results are published. Needs no decryption key: it - * compares the counters the organizer published against the number of voters - * the contract counted, which anyone can read. It catches invented or dropped - * ballots. It cannot catch votes moved between options, since that keeps the - * total intact. - */ - tallyCheck?: { declared: number; voters: number; matches: boolean }; contractAddress: string; ipfsCid?: string; /** Attribute restrictions on enrolment, verified against the contract's hash. */ diff --git a/frontend/src/hooks/usePageLimit.ts b/frontend/src/hooks/usePageLimit.ts index c04ef01..da42055 100644 --- a/frontend/src/hooks/usePageLimit.ts +++ b/frontend/src/hooks/usePageLimit.ts @@ -17,7 +17,7 @@ * a different tab, is starting a different list, and leaving the limit where it * was would silently show them a hundred rows of it. */ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; export const DEFAULT_PAGE_SIZE = 12; @@ -35,9 +35,14 @@ export function usePageLimit( ): PageLimitState { const [want, setWant] = useState(pageSize); - useEffect(() => { + // Back to the first page when the list becomes a different list. Set during + // render rather than in an effect, so the first render of the new list is + // already the right length instead of flashing the old length for a frame. + const [shownFor, setShownFor] = useState({ resetKey, pageSize }); + if (shownFor.resetKey !== resetKey || shownFor.pageSize !== pageSize) { + setShownFor({ resetKey, pageSize }); setWant(pageSize); - }, [resetKey, pageSize]); + } return { visible: items.slice(0, want), diff --git a/frontend/src/i18n/locales/ar.json b/frontend/src/i18n/locales/ar.json index b378ad6..3c7217d 100644 --- a/frontend/src/i18n/locales/ar.json +++ b/frontend/src/i18n/locales/ar.json @@ -228,11 +228,14 @@ "export_json": "تصدير سجل التدقيق", "view_ipfs": "عرض على IPFS", "not_available": "النتائج غير متاحة بعد.", - "check_ok_title": "تمت المطابقة مع السجل العام", - "check_ok_body": "مجموع النتائج المنشورة {{declared}}، وسجل العقد {{voters}} ناخبا. لم تختلق أي ورقة ولم تسقط.", - "check_bad_title": "لا يطابق السجل العام", - "check_bad_body": "مجموع النتائج المنشورة {{declared}}، لكن العقد سجل {{voters}} ناخبا. يجب أن يكون الرقمان متطابقين.", - "check_limit": "لا يحتاج هذا إلى مفتاح ولا إلى ثقة بأحد: يستطيع أي شخص تكراره. لكنه لا يكشف نقل الأصوات بين الخيارات، لأن المجموع يبقى كما هو." + "check_pending": "جارٍ التحقق من النتيجة المنشورة مقابل أوراق الاقتراع على السلسلة…", + "check_verified_title": "تم التحقق مقابل أوراق الاقتراع على السلسلة", + "check_verified_body": "الأعداد المنشورة هي بالضبط مجموع أوراق الاقتراع الصالحة وعددها {{valid}}، من أصل {{voters}} ناخبين. يمكن لأي شخص إعادة هذا التحقق دون مفتاح ودون الثقة بأحد.", + "check_excluded": "أوراق اقتراع مستبعدة لأنها لا تحمل خيارًا صالحًا واحدًا: {{excluded}}. تُفتح كل واحدة منها في الإثبات المنشور ليرى الجميع السبب.", + "check_failed_title": "لا يطابق أوراق الاقتراع على السلسلة", + "check_failed_body": "تعذّر التحقق من الأعداد المنشورة مقابل أوراق الاقتراع المسجلة على السلسلة. لا تعتمد على هذه النتيجة.", + "check_unproven_title": "نُشرت دون إثبات", + "check_unproven_body": "نُشرت هذه النتيجة بواسطة عقد يسبق إثباتات الفرز، لذا لا يمكن التحقق منها مقابل أوراق الاقتراع." }, "verify_receipt": { "title": "تحقّق من صوت", @@ -291,7 +294,8 @@ "reference": "رقم المرجع", "view_tx": "عرض السجل العام", "back_elections": "العودة إلى الانتخابات", - "check_receipt": "تحقّق من إيصالي" + "check_receipt": "تحقّق من إيصالي", + "demo_reference": "بيانات تجريبية: لم تُسجَّل أي ورقة اقتراع، لذا لا يوجد مرجع للتحقق منه." }, "change_vote": { "title": "تغيير صوتك", @@ -482,7 +486,10 @@ "open_voting_early": "افتح التصويت الآن", "open_voting_title": "بدء التصويت قبل موعده؟", "open_voting_desc": "أُغلق التسجيل ولم يحن التصويت بعد. سيتمكن كل المسجّلين من التصويت من الآن. تاريخ الإغلاق لا يتغير.", - "voting_opened_done": "التصويت مفتوح" + "voting_opened_done": "التصويت مفتوح", + "void_refused": "تعهّدت هذه الانتخابات بعدم إلغائها، وقد صوّت عدد كافٍ من الأشخاص لنشر نتيجتها، لذا لا يمكن إبطالها. استورد مفتاح فك التشفير لنشر النتيجة.", + "key_device_only": "تعذّر اشتقاق مفتاح فك تشفير هذه الانتخابات من محفظتك، لذا فهو موجود على هذا الجهاز فقط. صدّره الآن واحفظه بأمان: بدونه لا يمكن فرز الأصوات.", + "tally_excluded": "أوراق اقتراع مستبعدة لعدم صلاحيتها: {{excluded}}. تُفتح كل واحدة في الإثبات المنشور ليتمكن أي شخص من التحقق منها." }, "gas": { "title": "إدارة الغاز", @@ -620,7 +627,8 @@ "wallet_request_rejected": "لقد ألغيت الطلب في محفظتك. لم يحدث أي شيء.", "passkey_cancelled": "أغلقت طلب مفتاح المرور. لم يُنشأ أي شيء: يمكنك المحاولة مرة أخرى.", "list_unreadable": "تعذّرت قراءة هذه القائمة من السلسلة، وهذا ليس كأنها فارغة. تحقق من الاتصال ثم أعد المحاولة.", - "passkey_unproven": "أُنشئ مفتاح المرور، لكننا لم نتأكد بعد من قدرته على فكّ التشفير. اضغط مرة أخرى: سُيسأل المفتاح نفسه، دون إنشاء مفتاح جديد." + "passkey_unproven": "أُنشئ مفتاح المرور، لكننا لم نتأكد بعد من قدرته على فكّ التشفير. اضغط مرة أخرى: سُيسأل المفتاح نفسه، دون إنشاء مفتاح جديد.", + "revote_too_soon": "لقد غيّرت صوتك منذ أقل من ساعة. ورقتك الأخيرة هي التي تُحتسب، ويمكنك تغييرها مرة أخرى بعد انقضاء الساعة." }, "demo": { "banner": "بيانات تجريبية: لا توجد عقود منشورة. لا شيء هنا على السلسلة." diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index bfa7f56..63f7dde 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -220,11 +220,14 @@ "export_json": "Prüfprotokoll exportieren", "view_ipfs": "Auf IPFS anzeigen", "not_available": "Ergebnisse noch nicht verfügbar.", - "check_ok_title": "Mit dem öffentlichen Register abgeglichen", - "check_ok_body": "Die veröffentlichten Zählwerte ergeben {{declared}}, und der Vertrag hat {{voters}} Wählende erfasst. Keine Stimme wurde erfunden oder verworfen.", - "check_bad_title": "Stimmt nicht mit dem öffentlichen Register überein", - "check_bad_body": "Die veröffentlichten Zählwerte ergeben {{declared}}, der Vertrag hat aber {{voters}} Wählende erfasst. Beide Zahlen sollten gleich sein.", - "check_limit": "Dafür braucht es weder Schlüssel noch Vertrauen: jede Person kann es wiederholen. Ob Stimmen zwischen Optionen verschoben wurden, zeigt es nicht, denn das ändert die Summe nicht." + "check_pending": "Das veröffentlichte Ergebnis wird mit den Stimmzetteln auf der Chain abgeglichen…", + "check_verified_title": "Mit den Stimmzetteln auf der Chain verifiziert", + "check_verified_body": "Die veröffentlichten Zahlen ergeben sich genau aus den {{valid}} gültigen Stimmzetteln von {{voters}} Wählenden. Jede Person kann diese Prüfung ohne Schlüssel und ohne Vertrauen wiederholen.", + "check_excluded": "Ausgeschlossene Stimmzettel ohne genau eine gültige Wahl: {{excluded}}. Jeder wird im veröffentlichten Nachweis geöffnet, sodass alle sehen können, warum.", + "check_failed_title": "Stimmt nicht mit den Stimmzetteln auf der Chain überein", + "check_failed_body": "Die veröffentlichten Zahlen ließen sich nicht mit den auf der Chain erfassten Stimmzetteln verifizieren. Verlassen Sie sich nicht auf dieses Ergebnis.", + "check_unproven_title": "Ohne Nachweis veröffentlicht", + "check_unproven_body": "Dieses Ergebnis stammt von einem Vertrag, der älter ist als die Auszählungsnachweise, und kann daher nicht mit den Stimmzetteln abgeglichen werden." }, "verify_receipt": { "title": "Eine Stimme prüfen", @@ -279,7 +282,8 @@ "reference": "Referenznummer", "view_tx": "Öffentlichen Eintrag ansehen", "back_elections": "Zurück zu den Wahlen", - "check_receipt": "Meine Quittung prüfen" + "check_receipt": "Meine Quittung prüfen", + "demo_reference": "Demodaten: Es wurde kein Stimmzettel erfasst, daher gibt es keine Referenz zum Prüfen." }, "change_vote": { "title": "Ihre Stimme ändern", @@ -466,7 +470,10 @@ "open_voting_early": "Abstimmung jetzt öffnen", "open_voting_title": "Abstimmung vorzeitig beginnen?", "open_voting_desc": "Die Anmeldung ist geschlossen und die Abstimmung noch nicht fällig. Alle Angemeldeten können ab sofort abstimmen. Das Enddatum bleibt unverändert.", - "voting_opened_done": "Die Abstimmung ist offen" + "voting_opened_done": "Die Abstimmung ist offen", + "void_refused": "Diese Wahl hat zugesagt, nicht abgesagt zu werden, und es haben genug Menschen abgestimmt, um ihr Ergebnis zu veröffentlichen. Sie kann daher nicht für ungültig erklärt werden. Importieren Sie den Entschlüsselungsschlüssel, um das Ergebnis zu veröffentlichen.", + "key_device_only": "Der Entschlüsselungsschlüssel dieser Wahl konnte nicht aus Ihrer Wallet abgeleitet werden und existiert nur auf diesem Gerät. Exportieren Sie ihn jetzt und bewahren Sie ihn sicher auf: Ohne ihn kann die Wahl nicht ausgezählt werden.", + "tally_excluded": "Als ungültig ausgeschlossene Stimmzettel: {{excluded}}. Jeder wird im veröffentlichten Nachweis geöffnet, damit alle ihn prüfen können." }, "gas": { "title": "Gas-Verwaltung", @@ -600,7 +607,8 @@ "wallet_request_rejected": "Du hast die Anfrage in deiner Wallet abgebrochen. Es wurde nichts ausgeführt.", "passkey_cancelled": "Du hast den Passkey-Dialog abgebrochen. Es wurde nichts erstellt: du kannst es erneut versuchen.", "list_unreadable": "Diese Liste konnte nicht von der Chain gelesen werden, was nicht dasselbe ist wie leer zu sein. Prüfen Sie die Verbindung und versuchen Sie es erneut.", - "passkey_unproven": "Der Passkey wurde erstellt, aber wir konnten noch nicht bestätigen, dass er entschlüsseln kann. Drück noch einmal: es wird derselbe gefragt, ohne einen neuen zu erstellen." + "passkey_unproven": "Der Passkey wurde erstellt, aber wir konnten noch nicht bestätigen, dass er entschlüsseln kann. Drück noch einmal: es wird derselbe gefragt, ohne einen neuen zu erstellen.", + "revote_too_soon": "Sie haben Ihre Stimme vor weniger als einer Stunde geändert. Es zählt Ihr letzter Stimmzettel; nach Ablauf der Stunde können Sie sie erneut ändern." }, "demo": { "banner": "Demo-Daten: keine Verträge bereitgestellt. Nichts hiervon ist on-chain." diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c30ae35..5d44d3c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -220,11 +220,14 @@ "export_json": "Export audit trail", "view_ipfs": "View on IPFS", "not_available": "Results not yet available.", - "check_ok_title": "Checked against the public record", - "check_ok_body": "The published counts add up to {{declared}}, and the contract recorded {{voters}} voters. No ballot was invented or dropped.", - "check_bad_title": "Does not match the public record", - "check_bad_body": "The published counts add up to {{declared}}, but the contract recorded {{voters}} voters. These should be the same number.", - "check_limit": "This needs no key and no trust: anyone can repeat it. It cannot tell whether votes were moved between options, which keeps the total unchanged." + "check_pending": "Checking the published result against the ballots on chain…", + "check_verified_title": "Verified against the ballots on chain", + "check_verified_body": "The published counts are exactly what the {{valid}} valid ballots add up to, out of {{voters}} voters. Anyone can repeat this check with no key and no trust.", + "check_excluded": "Ballots excluded because they held no single valid choice: {{excluded}}. Each one is opened in the published proof, so anyone can see why.", + "check_failed_title": "Does not match the ballots on chain", + "check_failed_body": "The published counts could not be verified against the ballots recorded on chain. Do not rely on this result.", + "check_unproven_title": "Published without a proof", + "check_unproven_body": "This result was published by a contract that predates tally proofs, so it cannot be checked against the ballots." }, "verify_receipt": { "title": "Verify a vote", @@ -279,7 +282,8 @@ "reference": "Reference number", "view_tx": "See the public record", "back_elections": "Back to elections", - "check_receipt": "Check my receipt" + "check_receipt": "Check my receipt", + "demo_reference": "Demo data: no ballot was recorded, so there is no reference to check." }, "change_vote": { "title": "Change your vote", @@ -466,7 +470,10 @@ "open_voting_early": "Open voting now", "open_voting_title": "Start voting ahead of schedule?", "open_voting_desc": "Enrolment is already closed and voting is not due yet. Everyone enrolled will be able to vote from this moment. The closing date does not change.", - "voting_opened_done": "Voting is open" + "voting_opened_done": "Voting is open", + "void_refused": "This election promised it would not be called off, and enough people voted for its result to be published, so it cannot be voided. Import the decryption key to publish the result.", + "key_device_only": "This election's decryption key could not be derived from your wallet, so it exists only on this device. Export it now and keep it safe: without it the election cannot be counted.", + "tally_excluded": "Ballots excluded as invalid: {{excluded}}. Each is opened in the published proof so anyone can check it." }, "gas": { "title": "Gas Management", @@ -600,7 +607,8 @@ "wallet_request_rejected": "You cancelled the request in your wallet. Nothing was done.", "passkey_cancelled": "You dismissed the passkey prompt. Nothing was created: you can try again.", "list_unreadable": "This list could not be read from the chain, which is not the same as it being empty. Check your connection and try again.", - "passkey_unproven": "The passkey was created, but we could not confirm yet that it can decrypt. Press again: the same one is asked, and no new passkey is created." + "passkey_unproven": "The passkey was created, but we could not confirm yet that it can decrypt. Press again: the same one is asked, and no new passkey is created.", + "revote_too_soon": "You changed your vote less than an hour ago. Your latest ballot is the one that counts; you can change it again once the hour is up." }, "demo": { "banner": "Demo data: no contracts deployed. Nothing here is on-chain." diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 486c25a..03292a2 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -222,11 +222,14 @@ "export_json": "Exportar registro de auditoría", "view_ipfs": "Ver en IPFS", "not_available": "Los resultados aún no están disponibles.", - "check_ok_title": "Comprobado con el registro público", - "check_ok_body": "Los recuentos publicados suman {{declared}} y el contrato registró {{voters}} votantes. No se inventó ni se descartó ninguna papeleta.", - "check_bad_title": "No cuadra con el registro público", - "check_bad_body": "Los recuentos publicados suman {{declared}}, pero el contrato registró {{voters}} votantes. Deberían ser el mismo número.", - "check_limit": "Esto no necesita ninguna clave ni confiar en nadie: cualquiera puede repetirlo. No detecta si se movieron votos entre opciones, porque eso no altera el total." + "check_pending": "Comprobando el resultado publicado con las papeletas de la cadena…", + "check_verified_title": "Verificado con las papeletas de la cadena", + "check_verified_body": "Los recuentos publicados son exactamente lo que suman las {{valid}} papeletas válidas, de {{voters}} votantes. Cualquiera puede repetir esta comprobación sin clave y sin confiar en nadie.", + "check_excluded": "Papeletas excluidas por no contener una única opción válida: {{excluded}}. Cada una se abre en la prueba publicada, así que cualquiera puede ver por qué.", + "check_failed_title": "No cuadra con las papeletas de la cadena", + "check_failed_body": "Los recuentos publicados no se han podido verificar con las papeletas registradas en la cadena. No te fíes de este resultado.", + "check_unproven_title": "Publicado sin prueba", + "check_unproven_body": "Este resultado lo publicó un contrato anterior a las pruebas de recuento, así que no se puede comprobar con las papeletas." }, "verify_receipt": { "title": "Verifica un voto", @@ -282,7 +285,8 @@ "reference": "Número de referencia", "view_tx": "Ver el registro público", "back_elections": "Volver a las elecciones", - "check_receipt": "Comprobar mi recibo" + "check_receipt": "Comprobar mi recibo", + "demo_reference": "Datos de demostración: no se registró ninguna papeleta, así que no hay referencia que comprobar." }, "change_vote": { "title": "Cambiar tu voto", @@ -470,7 +474,10 @@ "open_voting_early": "Abrir la votación ahora", "open_voting_title": "¿Empezar la votación antes de tiempo?", "open_voting_desc": "La inscripción ya está cerrada y la votación aún no toca. Todos los inscritos podrán votar desde este momento. La fecha de cierre no cambia.", - "voting_opened_done": "Votación abierta" + "voting_opened_done": "Votación abierta", + "void_refused": "Esta elección prometió que no se anularía y han votado suficientes personas para publicar su resultado, así que no se puede anular. Importa la clave de descifrado para publicar el resultado.", + "key_device_only": "La clave de descifrado de esta elección no se pudo derivar de tu wallet, así que solo existe en este dispositivo. Expórtala ahora y guárdala bien: sin ella no se puede hacer el recuento.", + "tally_excluded": "Papeletas excluidas por no válidas: {{excluded}}. Cada una se abre en la prueba publicada para que cualquiera pueda comprobarlo." }, "gas": { "title": "Gestión de Gas", @@ -605,7 +612,8 @@ "wallet_request_rejected": "Cancelaste la petición en tu cartera. No se ha hecho nada.", "passkey_cancelled": "Cancelaste el diálogo de la passkey. No se ha creado nada: puedes intentarlo otra vez.", "list_unreadable": "Esta lista no se ha podido leer de la cadena, que no es lo mismo que esté vacía. Revisa la conexión e inténtalo otra vez.", - "passkey_unproven": "La passkey se creó, pero aún no hemos podido confirmar que sirva para descifrar. Pulsa otra vez: se le preguntará a esa misma, sin crear ninguna nueva." + "passkey_unproven": "La passkey se creó, pero aún no hemos podido confirmar que sirva para descifrar. Pulsa otra vez: se le preguntará a esa misma, sin crear ninguna nueva.", + "revote_too_soon": "Cambiaste tu voto hace menos de una hora. Cuenta tu última papeleta; podrás cambiarlo otra vez cuando pase la hora." }, "demo": { "banner": "Datos de demostración: sin contratos desplegados. Nada de esto está en cadena." diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 79b0fc1..abc1721 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -222,11 +222,14 @@ "export_json": "Exporter la piste d'audit", "view_ipfs": "Voir sur IPFS", "not_available": "Résultats pas encore disponibles.", - "check_ok_title": "Vérifié avec le registre public", - "check_ok_body": "Les décomptes publiés totalisent {{declared}} et le contrat a enregistré {{voters}} votants. Aucun bulletin n'a été inventé ni écarté.", - "check_bad_title": "Ne correspond pas au registre public", - "check_bad_body": "Les décomptes publiés totalisent {{declared}}, mais le contrat a enregistré {{voters}} votants. Ces deux nombres devraient être identiques.", - "check_limit": "Aucune clé ni confiance requise : n'importe qui peut le refaire. Cela ne détecte pas un déplacement de voix entre options, qui laisse le total inchangé." + "check_pending": "Vérification du résultat publié avec les bulletins enregistrés sur la chaîne…", + "check_verified_title": "Vérifié avec les bulletins sur la chaîne", + "check_verified_body": "Les décomptes publiés correspondent exactement à la somme des {{valid}} bulletins valides, sur {{voters}} votants. Tout le monde peut refaire cette vérification sans clé et sans faire confiance à personne.", + "check_excluded": "Bulletins exclus faute de contenir un seul choix valide : {{excluded}}. Chacun est ouvert dans la preuve publiée, pour que chacun puisse voir pourquoi.", + "check_failed_title": "Ne correspond pas aux bulletins sur la chaîne", + "check_failed_body": "Les décomptes publiés n'ont pas pu être vérifiés avec les bulletins enregistrés sur la chaîne. Ne vous fiez pas à ce résultat.", + "check_unproven_title": "Publié sans preuve", + "check_unproven_body": "Ce résultat a été publié par un contrat antérieur aux preuves de dépouillement, il ne peut donc pas être vérifié avec les bulletins." }, "verify_receipt": { "title": "Vérifier un vote", @@ -282,7 +285,8 @@ "reference": "Numéro de référence", "view_tx": "Voir le registre public", "back_elections": "Retour aux élections", - "check_receipt": "Vérifier mon reçu" + "check_receipt": "Vérifier mon reçu", + "demo_reference": "Données de démonstration : aucun bulletin n'a été enregistré, il n'y a donc pas de référence à vérifier." }, "change_vote": { "title": "Changer votre vote", @@ -470,7 +474,10 @@ "open_voting_early": "Ouvrir le vote", "open_voting_title": "Commencer le vote en avance ?", "open_voting_desc": "Les inscriptions sont closes et le vote n’est pas encore dû. Tous les inscrits pourront voter dès maintenant. La date de clôture ne change pas.", - "voting_opened_done": "Le vote est ouvert" + "voting_opened_done": "Le vote est ouvert", + "void_refused": "Cette élection s'est engagée à ne pas être annulée, et assez de personnes ont voté pour que son résultat soit publié : elle ne peut donc pas être invalidée. Importez la clé de déchiffrement pour publier le résultat.", + "key_device_only": "La clé de déchiffrement de cette élection n'a pas pu être dérivée de votre portefeuille : elle n'existe que sur cet appareil. Exportez-la maintenant et conservez-la en lieu sûr, sans elle l'élection ne peut pas être dépouillée.", + "tally_excluded": "Bulletins exclus comme invalides : {{excluded}}. Chacun est ouvert dans la preuve publiée pour que tout le monde puisse le vérifier." }, "gas": { "title": "Gestion du gaz", @@ -605,7 +612,8 @@ "wallet_request_rejected": "Vous avez annulé la demande dans votre portefeuille. Rien n’a été fait.", "passkey_cancelled": "Vous avez fermé la demande de passkey. Rien n’a été créé : vous pouvez réessayer.", "list_unreadable": "Cette liste n’a pas pu être lue depuis la chaîne, ce qui n’est pas la même chose qu’une liste vide. Vérifiez la connexion et réessayez.", - "passkey_unproven": "La passkey a été créée, mais nous n’avons pas encore pu confirmer qu’elle peut déchiffrer. Appuyez à nouveau : c’est la même qui sera interrogée, sans en créer de nouvelle." + "passkey_unproven": "La passkey a été créée, mais nous n’avons pas encore pu confirmer qu’elle peut déchiffrer. Appuyez à nouveau : c’est la même qui sera interrogée, sans en créer de nouvelle.", + "revote_too_soon": "Vous avez modifié votre vote il y a moins d'une heure. Votre dernier bulletin est celui qui compte ; vous pourrez le modifier à nouveau une fois l'heure écoulée." }, "demo": { "banner": "Données de démonstration: aucun contrat déployé. Rien ici n'est sur la blockchain." diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index afac9ce..53a72c5 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -220,11 +220,14 @@ "export_json": "ऑडिट ट्रेल निर्यात करें", "view_ipfs": "IPFS पर देखें", "not_available": "परिणाम अभी उपलब्ध नहीं हैं।", - "check_ok_title": "सार्वजनिक रिकॉर्ड से मिलान किया गया", - "check_ok_body": "प्रकाशित गिनती का जोड़ {{declared}} है, और अनुबंध ने {{voters}} मतदाता दर्ज किए। कोई मत न गढ़ा गया, न हटाया गया।", - "check_bad_title": "सार्वजनिक रिकॉर्ड से मेल नहीं खाता", - "check_bad_body": "प्रकाशित गिनती का जोड़ {{declared}} है, पर अनुबंध ने {{voters}} मतदाता दर्ज किए। ये संख्याएँ समान होनी चाहिए।", - "check_limit": "इसके लिए न कोई कुंजी चाहिए, न किसी पर भरोसा: कोई भी दोहरा सकता है। यह नहीं बता सकता कि विकल्पों के बीच वोट बदले गए, क्योंकि उससे कुल वही रहता है।" + "check_pending": "प्रकाशित परिणाम की चेन पर दर्ज मतपत्रों से जाँच की जा रही है…", + "check_verified_title": "चेन पर दर्ज मतपत्रों से सत्यापित", + "check_verified_body": "प्रकाशित गिनती ठीक उतनी है जितना {{voters}} मतदाताओं के {{valid}} मान्य मतपत्रों का योग है। कोई भी बिना किसी कुंजी और बिना किसी पर भरोसा किए यह जाँच दोहरा सकता है।", + "check_excluded": "एक भी मान्य विकल्प न होने के कारण बाहर किए गए मतपत्र: {{excluded}}। हर एक प्रकाशित प्रमाण में खोला गया है, ताकि कोई भी कारण देख सके।", + "check_failed_title": "चेन पर दर्ज मतपत्रों से मेल नहीं खाता", + "check_failed_body": "प्रकाशित गिनती को चेन पर दर्ज मतपत्रों से सत्यापित नहीं किया जा सका। इस परिणाम पर भरोसा न करें।", + "check_unproven_title": "बिना प्रमाण के प्रकाशित", + "check_unproven_body": "यह परिणाम गिनती-प्रमाणों से पहले के एक अनुबंध द्वारा प्रकाशित किया गया था, इसलिए इसे मतपत्रों से जाँचा नहीं जा सकता।" }, "verify_receipt": { "title": "किसी मत की पुष्टि करें", @@ -279,7 +282,8 @@ "reference": "संदर्भ नंबर", "view_tx": "सार्वजनिक रिकॉर्ड देखें", "back_elections": "चुनावों पर वापस जाएं", - "check_receipt": "मेरी रसीद जाँचें" + "check_receipt": "मेरी रसीद जाँचें", + "demo_reference": "डेमो डेटा: कोई मतपत्र दर्ज नहीं हुआ, इसलिए जाँचने के लिए कोई संदर्भ नहीं है।" }, "change_vote": { "title": "अपना वोट बदलें", @@ -466,7 +470,10 @@ "open_voting_early": "मतदान अभी खोलें", "open_voting_title": "मतदान समय से पहले शुरू करें?", "open_voting_desc": "पंजीकरण बंद हो चुका है और मतदान अभी बाक़ी है। सभी पंजीकृत लोग अभी से मत दे सकेंगे। समाप्ति तिथि नहीं बदलती।", - "voting_opened_done": "मतदान खुला है" + "voting_opened_done": "मतदान खुला है", + "void_refused": "इस चुनाव ने वादा किया था कि इसे रद्द नहीं किया जाएगा, और परिणाम प्रकाशित करने लायक पर्याप्त लोगों ने मतदान किया है, इसलिए इसे अमान्य नहीं किया जा सकता। परिणाम प्रकाशित करने के लिए डिक्रिप्शन कुंजी आयात करें।", + "key_device_only": "इस चुनाव की डिक्रिप्शन कुंजी आपके वॉलेट से नहीं बनाई जा सकी, इसलिए यह केवल इसी डिवाइस पर है। इसे अभी निर्यात करें और सुरक्षित रखें: इसके बिना चुनाव की गिनती नहीं हो सकती।", + "tally_excluded": "अमान्य होने के कारण बाहर किए गए मतपत्र: {{excluded}}। हर एक प्रकाशित प्रमाण में खोला गया है ताकि कोई भी इसकी जाँच कर सके।" }, "gas": { "title": "गैस प्रबंधन", @@ -600,7 +607,8 @@ "wallet_request_rejected": "आपने अपने वॉलेट में अनुरोध रद्द कर दिया। कुछ नहीं हुआ।", "passkey_cancelled": "आपने पासकी का संवाद बंद कर दिया। कुछ नहीं बना: आप दोबारा कोशिश कर सकते हैं।", "list_unreadable": "यह सूची चेन से नहीं पढ़ी जा सकी, जो खाली होने जैसा नहीं है। कनेक्शन जाँचें और फिर से कोशिश करें।", - "passkey_unproven": "पासकी बन गई, पर अभी यह पक्का नहीं हो सका कि वह डिक्रिप्ट कर सकती है। दोबारा दबाएँ: उसी से पूछा जाएगा, नई नहीं बनेगी।" + "passkey_unproven": "पासकी बन गई, पर अभी यह पक्का नहीं हो सका कि वह डिक्रिप्ट कर सकती है। दोबारा दबाएँ: उसी से पूछा जाएगा, नई नहीं बनेगी।", + "revote_too_soon": "आपने एक घंटे से कम समय पहले अपना मत बदला है। आपका अंतिम मतपत्र ही गिना जाएगा; घंटा पूरा होने पर आप इसे फिर से बदल सकते हैं।" }, "demo": { "banner": "डेमो डेटा: कोई अनुबंध तैनात नहीं। यहाँ कुछ भी ऑन-चेन नहीं है।" diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 5d7f674..15a051c 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -222,11 +222,14 @@ "export_json": "Esporta registro audit", "view_ipfs": "Vedi su IPFS", "not_available": "Risultati non ancora disponibili.", - "check_ok_title": "Verificato con il registro pubblico", - "check_ok_body": "I conteggi pubblicati sommano {{declared}} e il contratto ha registrato {{voters}} votanti. Nessuna scheda è stata inventata o scartata.", - "check_bad_title": "Non corrisponde al registro pubblico", - "check_bad_body": "I conteggi pubblicati sommano {{declared}}, ma il contratto ha registrato {{voters}} votanti. Dovrebbero essere lo stesso numero.", - "check_limit": "Non serve alcuna chiave né fiducia: chiunque può ripeterlo. Non rileva voti spostati tra opzioni, perché il totale resta uguale." + "check_pending": "Verifica del risultato pubblicato con le schede sulla chain…", + "check_verified_title": "Verificato con le schede sulla chain", + "check_verified_body": "I conteggi pubblicati sono esattamente la somma delle {{valid}} schede valide, su {{voters}} votanti. Chiunque può ripetere questa verifica senza chiavi e senza fidarsi di nessuno.", + "check_excluded": "Schede escluse perché non contenevano un'unica scelta valida: {{excluded}}. Ognuna è aperta nella prova pubblicata, così chiunque può vedere perché.", + "check_failed_title": "Non corrisponde alle schede sulla chain", + "check_failed_body": "Non è stato possibile verificare i conteggi pubblicati con le schede registrate sulla chain. Non fare affidamento su questo risultato.", + "check_unproven_title": "Pubblicato senza prova", + "check_unproven_body": "Questo risultato è stato pubblicato da un contratto precedente alle prove di scrutinio, quindi non può essere verificato con le schede." }, "verify_receipt": { "title": "Verifica un voto", @@ -282,7 +285,8 @@ "reference": "Numero di riferimento", "view_tx": "Vedi il registro pubblico", "back_elections": "Torna alle elezioni", - "check_receipt": "Verifica la mia ricevuta" + "check_receipt": "Verifica la mia ricevuta", + "demo_reference": "Dati dimostrativi: non è stata registrata alcuna scheda, quindi non c'è un riferimento da verificare." }, "change_vote": { "title": "Cambia il tuo voto", @@ -470,7 +474,10 @@ "open_voting_early": "Apri il voto ora", "open_voting_title": "Iniziare il voto in anticipo?", "open_voting_desc": "Le iscrizioni sono chiuse e il voto non è ancora previsto. Tutti gli iscritti potranno votare da adesso. La data di chiusura non cambia.", - "voting_opened_done": "Il voto è aperto" + "voting_opened_done": "Il voto è aperto", + "void_refused": "Questa elezione ha promesso di non essere annullata e hanno votato abbastanza persone da poterne pubblicare il risultato, quindi non può essere invalidata. Importa la chiave di decifratura per pubblicare il risultato.", + "key_device_only": "Non è stato possibile derivare dal tuo wallet la chiave di decifratura di questa elezione, quindi esiste solo su questo dispositivo. Esportala ora e conservala al sicuro: senza di essa l'elezione non può essere scrutinata.", + "tally_excluded": "Schede escluse perché non valide: {{excluded}}. Ognuna è aperta nella prova pubblicata perché chiunque possa verificarla." }, "gas": { "title": "Gestione Gas", @@ -605,7 +612,8 @@ "wallet_request_rejected": "Hai annullato la richiesta nel tuo wallet. Non è stato fatto nulla.", "passkey_cancelled": "Hai chiuso la richiesta della passkey. Non è stato creato nulla: puoi riprovare.", "list_unreadable": "Non è stato possibile leggere questa lista dalla catena, che non è lo stesso che sia vuota. Controlla la connessione e riprova.", - "passkey_unproven": "La passkey è stata creata, ma non abbiamo ancora potuto confermare che sappia decifrare. Premi di nuovo: viene interrogata la stessa, senza crearne una nuova." + "passkey_unproven": "La passkey è stata creata, ma non abbiamo ancora potuto confermare che sappia decifrare. Premi di nuovo: viene interrogata la stessa, senza crearne una nuova.", + "revote_too_soon": "Hai cambiato il tuo voto meno di un'ora fa. Conta la tua ultima scheda; potrai cambiarlo di nuovo allo scadere dell'ora." }, "demo": { "banner": "Dati dimostrativi: nessun contratto distribuito. Niente qui è on-chain." diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index 64d9230..9a7c44c 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -218,11 +218,14 @@ "export_json": "監査証跡をエクスポート", "view_ipfs": "IPFSで表示", "not_available": "結果はまだ利用できません。", - "check_ok_title": "公開記録と照合済み", - "check_ok_body": "公開された集計の合計は {{declared}} で、コントラクトが記録した投票者数は {{voters}} 人です。票の水増しも取りこぼしもありません。", - "check_bad_title": "公開記録と一致しません", - "check_bad_body": "公開された集計の合計は {{declared}} ですが、コントラクトが記録した投票者数は {{voters}} 人です。本来は同じ数になるはずです。", - "check_limit": "鍵も信頼も要らず、誰でも同じ確認ができます。ただし選択肢のあいだで票が移された場合は合計が変わらないため、この確認では分かりません。" + "check_pending": "公開された結果をチェーン上の投票用紙と照合しています…", + "check_verified_title": "チェーン上の投票用紙と照合済み", + "check_verified_body": "公開された集計は、{{voters}}人の投票者による{{valid}}件の有効な投票用紙の合計と完全に一致します。誰でも鍵なしで、誰も信頼することなくこの確認を繰り返せます。", + "check_excluded": "有効な選択が一つだけ含まれていなかったため除外された投票用紙:{{excluded}}件。それぞれ公開された証明の中で開示されており、誰でも理由を確認できます。", + "check_failed_title": "チェーン上の投票用紙と一致しません", + "check_failed_body": "公開された集計をチェーン上に記録された投票用紙と照合できませんでした。この結果を信頼しないでください。", + "check_unproven_title": "証明なしで公開", + "check_unproven_body": "この結果は集計証明の導入以前のコントラクトで公開されたため、投票用紙と照合できません。" }, "verify_receipt": { "title": "投票を確認する", @@ -276,7 +279,8 @@ "reference": "参照番号", "view_tx": "公開記録を見る", "back_elections": "選挙一覧に戻る", - "check_receipt": "受領証を確認する" + "check_receipt": "受領証を確認する", + "demo_reference": "デモデータ:投票用紙は記録されていないため、確認できる参照番号はありません。" }, "change_vote": { "title": "投票を変更", @@ -462,7 +466,10 @@ "open_voting_early": "投票を今すぐ開始", "open_voting_title": "予定より早く投票を始めますか?", "open_voting_desc": "登録は締め切られ、投票はまだ先です。登録済みの全員が今から投票できます。締切日は変わりません。", - "voting_opened_done": "投票を開始しました" + "voting_opened_done": "投票を開始しました", + "void_refused": "この選挙は中止しないと約束しており、結果を公開できるだけの人数が投票したため、無効にすることはできません。結果を公開するには復号鍵をインポートしてください。", + "key_device_only": "この選挙の復号鍵はウォレットから導出できなかったため、この端末にのみ存在します。今すぐエクスポートして安全に保管してください。鍵がなければ集計できません。", + "tally_excluded": "無効として除外された投票用紙:{{excluded}}件。それぞれ公開された証明の中で開示され、誰でも確認できます。" }, "gas": { "title": "ガス管理", @@ -595,7 +602,8 @@ "wallet_request_rejected": "ウォレットでリクエストをキャンセルしました。何も実行されていません。", "passkey_cancelled": "パスキーの要求を閉じました。何も作成されていません。もう一度お試しください。", "list_unreadable": "この一覧をチェーンから読み取れませんでした。空だったわけではありません。接続を確かめてやり直してください。", - "passkey_unproven": "パスキーは作成されましたが、復号に使えるかまだ確認できていません。もう一度押してください。同じパスキーに尋ねます。新しいものは作成されません。" + "passkey_unproven": "パスキーは作成されましたが、復号に使えるかまだ確認できていません。もう一度押してください。同じパスキーに尋ねます。新しいものは作成されません。", + "revote_too_soon": "投票を変更してから1時間経っていません。最新の投票が有効です。1時間経過すると再び変更できます。" }, "demo": { "banner": "デモデータ:コントラクト未デプロイ。ここにあるものはオンチェーンではありません。" diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index 8232937..6de43fb 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -218,11 +218,14 @@ "export_json": "감사 추적 내보내기", "view_ipfs": "IPFS에서 보기", "not_available": "결과가 아직 없습니다.", - "check_ok_title": "공개 기록과 대조 완료", - "check_ok_body": "공개된 집계의 합은 {{declared}}이고, 컨트랙트가 기록한 투표자는 {{voters}}명입니다. 만들어 낸 표도, 빠뜨린 표도 없습니다.", - "check_bad_title": "공개 기록과 일치하지 않음", - "check_bad_body": "공개된 집계의 합은 {{declared}}이지만, 컨트랙트가 기록한 투표자는 {{voters}}명입니다. 두 숫자는 같아야 합니다.", - "check_limit": "열쇠도 신뢰도 필요 없으며 누구나 다시 확인할 수 있습니다. 다만 선택지 사이에서 표를 옮긴 경우는 합계가 그대로여서 드러나지 않습니다." + "check_pending": "공개된 결과를 체인상의 투표지와 대조하는 중…", + "check_verified_title": "체인상의 투표지와 대조 완료", + "check_verified_body": "공개된 집계는 투표자 {{voters}}명의 유효 투표지 {{valid}}장을 합한 값과 정확히 일치합니다. 누구나 키 없이, 누구도 신뢰하지 않고 이 확인을 반복할 수 있습니다.", + "check_excluded": "유효한 선택이 하나만 담겨 있지 않아 제외된 투표지: {{excluded}}장. 각각 공개된 증명에서 열려 있어 누구나 그 이유를 볼 수 있습니다.", + "check_failed_title": "체인상의 투표지와 일치하지 않음", + "check_failed_body": "공개된 집계를 체인에 기록된 투표지와 대조해 검증할 수 없었습니다. 이 결과를 신뢰하지 마세요.", + "check_unproven_title": "증명 없이 공개됨", + "check_unproven_body": "이 결과는 집계 증명 도입 이전의 컨트랙트가 공개한 것이라 투표지와 대조할 수 없습니다." }, "verify_receipt": { "title": "투표 확인하기", @@ -276,7 +279,8 @@ "reference": "참조 번호", "view_tx": "공개 기록 보기", "back_elections": "선거 목록으로 돌아가기", - "check_receipt": "내 영수증 확인" + "check_receipt": "내 영수증 확인", + "demo_reference": "데모 데이터: 기록된 투표지가 없으므로 확인할 참조 번호가 없습니다." }, "change_vote": { "title": "투표 변경", @@ -462,7 +466,10 @@ "open_voting_early": "지금 투표 열기", "open_voting_title": "예정보다 먼저 투표를 시작하시겠습니까?", "open_voting_desc": "등록은 이미 마감되었고 투표는 아직 시작 전입니다. 등록한 모든 사람이 지금부터 투표할 수 있습니다. 마감일은 바뀌지 않습니다.", - "voting_opened_done": "투표가 열렸습니다" + "voting_opened_done": "투표가 열렸습니다", + "void_refused": "이 선거는 취소하지 않겠다고 약속했고 결과를 공개할 만큼 충분한 사람이 투표했으므로 무효로 할 수 없습니다. 결과를 공개하려면 복호화 키를 가져오세요.", + "key_device_only": "이 선거의 복호화 키를 지갑에서 도출할 수 없어 이 기기에만 있습니다. 지금 내보내 안전하게 보관하세요. 키가 없으면 선거를 집계할 수 없습니다.", + "tally_excluded": "무효로 제외된 투표지: {{excluded}}장. 각각 공개된 증명에서 열려 있어 누구나 확인할 수 있습니다." }, "gas": { "title": "가스 관리", @@ -595,7 +602,8 @@ "wallet_request_rejected": "지갑에서 요청을 취소했습니다. 아무 작업도 수행되지 않았습니다.", "passkey_cancelled": "패스키 요청을 닫았습니다. 아무것도 생성되지 않았습니다. 다시 시도할 수 있습니다.", "list_unreadable": "이 목록을 체인에서 읽지 못했습니다. 비어 있는 것과는 다릅니다. 연결을 확인하고 다시 시도하세요.", - "passkey_unproven": "패스키는 생성되었지만 복호화에 쓸 수 있는지 아직 확인하지 못했습니다. 다시 누르세요. 같은 패스키에 물어보며, 새로 만들지 않습니다." + "passkey_unproven": "패스키는 생성되었지만 복호화에 쓸 수 있는지 아직 확인하지 못했습니다. 다시 누르세요. 같은 패스키에 물어보며, 새로 만들지 않습니다.", + "revote_too_soon": "한 시간이 지나기 전에 투표를 변경했습니다. 마지막 투표지가 집계되며, 한 시간이 지나면 다시 변경할 수 있습니다." }, "demo": { "banner": "데모 데이터: 배포된 컨트랙트 없음. 여기 있는 것은 온체인이 아닙니다." diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 194ea62..77bc997 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -220,11 +220,14 @@ "export_json": "Audittrail exporteren", "view_ipfs": "Bekijk op IPFS", "not_available": "Resultaten nog niet beschikbaar.", - "check_ok_title": "Gecontroleerd met het openbare register", - "check_ok_body": "De gepubliceerde tellingen komen op {{declared}}, en het contract registreerde {{voters}} stemmers. Geen stem is verzonnen of weggelaten.", - "check_bad_title": "Komt niet overeen met het openbare register", - "check_bad_body": "De gepubliceerde tellingen komen op {{declared}}, maar het contract registreerde {{voters}} stemmers. Dat horen dezelfde getallen te zijn.", - "check_limit": "Hier is geen sleutel of vertrouwen voor nodig: iedereen kan het herhalen. Het ziet niet of stemmen tussen opties zijn verschoven, want dat laat het totaal gelijk." + "check_pending": "De gepubliceerde uitslag wordt gecontroleerd tegen de stembiljetten op de chain…", + "check_verified_title": "Geverifieerd tegen de stembiljetten op de chain", + "check_verified_body": "De gepubliceerde aantallen zijn precies de optelsom van de {{valid}} geldige stembiljetten, van {{voters}} kiezers. Iedereen kan deze controle herhalen, zonder sleutel en zonder iemand te hoeven vertrouwen.", + "check_excluded": "Stembiljetten uitgesloten omdat ze niet precies één geldige keuze bevatten: {{excluded}}. Elk wordt geopend in het gepubliceerde bewijs, zodat iedereen kan zien waarom.", + "check_failed_title": "Klopt niet met de stembiljetten op de chain", + "check_failed_body": "De gepubliceerde aantallen konden niet worden geverifieerd tegen de stembiljetten op de chain. Vertrouw niet op deze uitslag.", + "check_unproven_title": "Gepubliceerd zonder bewijs", + "check_unproven_body": "Deze uitslag is gepubliceerd door een contract van vóór de telbewijzen en kan dus niet tegen de stembiljetten worden gecontroleerd." }, "verify_receipt": { "title": "Een stem verifiëren", @@ -279,7 +282,8 @@ "reference": "Referentienummer", "view_tx": "Bekijk het openbare register", "back_elections": "Terug naar verkiezingen", - "check_receipt": "Mijn bewijs controleren" + "check_receipt": "Mijn bewijs controleren", + "demo_reference": "Demogegevens: er is geen stembiljet vastgelegd, dus er is geen referentie om te controleren." }, "change_vote": { "title": "Je stem wijzigen", @@ -466,7 +470,10 @@ "open_voting_early": "Stemming nu openen", "open_voting_title": "Stemming eerder beginnen?", "open_voting_desc": "De inschrijving is gesloten en de stemming is nog niet aan de beurt. Alle ingeschrevenen kunnen vanaf nu stemmen. De sluitingsdatum verandert niet.", - "voting_opened_done": "De stemming is open" + "voting_opened_done": "De stemming is open", + "void_refused": "Deze verkiezing heeft beloofd niet te worden afgeblazen, en er hebben genoeg mensen gestemd om de uitslag te publiceren. Ze kan dus niet ongeldig worden verklaard. Importeer de ontsleutelingssleutel om de uitslag te publiceren.", + "key_device_only": "De ontsleutelingssleutel van deze verkiezing kon niet uit je wallet worden afgeleid en bestaat alleen op dit apparaat. Exporteer hem nu en bewaar hem goed: zonder hem kan de verkiezing niet worden geteld.", + "tally_excluded": "Als ongeldig uitgesloten stembiljetten: {{excluded}}. Elk wordt geopend in het gepubliceerde bewijs, zodat iedereen het kan controleren." }, "gas": { "title": "Gasbeheer", @@ -600,7 +607,8 @@ "wallet_request_rejected": "Je hebt het verzoek in je wallet geannuleerd. Er is niets gebeurd.", "passkey_cancelled": "Je hebt het passkey-verzoek gesloten. Er is niets aangemaakt: je kunt het opnieuw proberen.", "list_unreadable": "Deze lijst kon niet van de chain gelezen worden, wat niet hetzelfde is als leeg zijn. Controleer de verbinding en probeer het opnieuw.", - "passkey_unproven": "De passkey is aangemaakt, maar we konden nog niet bevestigen dat hij kan ontsleutelen. Druk nogmaals: dezelfde wordt gevraagd, er wordt geen nieuwe aangemaakt." + "passkey_unproven": "De passkey is aangemaakt, maar we konden nog niet bevestigen dat hij kan ontsleutelen. Druk nogmaals: dezelfde wordt gevraagd, er wordt geen nieuwe aangemaakt.", + "revote_too_soon": "Je hebt je stem minder dan een uur geleden gewijzigd. Je laatste stembiljet telt; je kunt het opnieuw wijzigen zodra het uur voorbij is." }, "demo": { "banner": "Demogegevens: geen contracten uitgerold. Niets hiervan staat on-chain." diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 2b3f9ee..cc75f4b 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -222,11 +222,14 @@ "export_json": "Exportar trilha de auditoria", "view_ipfs": "Ver no IPFS", "not_available": "Resultados ainda não disponíveis.", - "check_ok_title": "Confirmado com o registo público", - "check_ok_body": "As contagens publicadas somam {{declared}} e o contrato registou {{voters}} votantes. Nenhum boletim foi inventado nem descartado.", - "check_bad_title": "Não corresponde ao registo público", - "check_bad_body": "As contagens publicadas somam {{declared}}, mas o contrato registou {{voters}} votantes. Deveriam ser o mesmo número.", - "check_limit": "Não precisa de chave nem de confiar em ninguém: qualquer pessoa pode repetir. Não deteta votos movidos entre opções, porque isso não altera o total." + "check_pending": "A verificar o resultado publicado com os boletins na blockchain…", + "check_verified_title": "Verificado com os boletins na blockchain", + "check_verified_body": "As contagens publicadas são exatamente a soma dos {{valid}} boletins válidos, de {{voters}} votantes. Qualquer pessoa pode repetir esta verificação sem chave e sem confiar em ninguém.", + "check_excluded": "Boletins excluídos por não conterem uma única escolha válida: {{excluded}}. Cada um é aberto na prova publicada, para que qualquer pessoa veja porquê.", + "check_failed_title": "Não corresponde aos boletins na blockchain", + "check_failed_body": "Não foi possível verificar as contagens publicadas com os boletins registados na blockchain. Não confie neste resultado.", + "check_unproven_title": "Publicado sem prova", + "check_unproven_body": "Este resultado foi publicado por um contrato anterior às provas de apuramento, por isso não pode ser verificado com os boletins." }, "verify_receipt": { "title": "Verifica um voto", @@ -282,7 +285,8 @@ "reference": "Número de referência", "view_tx": "Ver o registo público", "back_elections": "Voltar às eleições", - "check_receipt": "Verificar o meu recibo" + "check_receipt": "Verificar o meu recibo", + "demo_reference": "Dados de demonstração: nenhum boletim foi registado, por isso não há referência para verificar." }, "change_vote": { "title": "Mudar seu voto", @@ -470,7 +474,10 @@ "open_voting_early": "Abrir a votação agora", "open_voting_title": "Começar a votação mais cedo?", "open_voting_desc": "As inscrições já fecharam e a votação ainda não é devida. Todos os inscritos poderão votar a partir de agora. A data de fecho não muda.", - "voting_opened_done": "Votação aberta" + "voting_opened_done": "Votação aberta", + "void_refused": "Esta eleição comprometeu-se a não ser cancelada e votaram pessoas suficientes para publicar o resultado, por isso não pode ser anulada. Importe a chave de desencriptação para publicar o resultado.", + "key_device_only": "A chave de desencriptação desta eleição não pôde ser derivada da sua carteira, por isso só existe neste dispositivo. Exporte-a agora e guarde-a em segurança: sem ela a eleição não pode ser apurada.", + "tally_excluded": "Boletins excluídos por serem inválidos: {{excluded}}. Cada um é aberto na prova publicada para que qualquer pessoa o possa verificar." }, "gas": { "title": "Gestão de Gas", @@ -605,7 +612,8 @@ "wallet_request_rejected": "Cancelou o pedido na sua carteira. Não foi feito nada.", "passkey_cancelled": "Fechou o pedido da passkey. Não foi criado nada: pode tentar de novo.", "list_unreadable": "Não foi possível ler esta lista a partir da cadeia, o que não é o mesmo que estar vazia. Verifica a ligação e tenta de novo.", - "passkey_unproven": "A passkey foi criada, mas ainda não conseguimos confirmar que consegue decifrar. Prima outra vez: é a mesma que será consultada, sem criar nenhuma nova." + "passkey_unproven": "A passkey foi criada, mas ainda não conseguimos confirmar que consegue decifrar. Prima outra vez: é a mesma que será consultada, sem criar nenhuma nova.", + "revote_too_soon": "Alterou o seu voto há menos de uma hora. Conta o seu último boletim; poderá alterá-lo novamente quando passar a hora." }, "demo": { "banner": "Dados de demonstração: sem contratos implantados. Nada aqui está on-chain." diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index a8f31d8..92646d0 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -224,11 +224,14 @@ "export_json": "Экспорт журнала аудита", "view_ipfs": "Просмотр на IPFS", "not_available": "Результаты ещё не доступны.", - "check_ok_title": "Сверено с публичной записью", - "check_ok_body": "Опубликованные счётчики дают в сумме {{declared}}, а контракт зафиксировал {{voters}} проголосовавших. Ни один бюллетень не добавлен и не отброшен.", - "check_bad_title": "Не совпадает с публичной записью", - "check_bad_body": "Опубликованные счётчики дают в сумме {{declared}}, но контракт зафиксировал {{voters}} проголосовавших. Эти числа должны совпадать.", - "check_limit": "Не нужны ни ключ, ни доверие: повторить может любой. Это не покажет, переносили ли голоса между вариантами, ведь сумма при этом не меняется." + "check_pending": "Проверяем опубликованный результат по бюллетеням в блокчейне…", + "check_verified_title": "Проверено по бюллетеням в блокчейне", + "check_verified_body": "Опубликованные итоги в точности равны сумме {{valid}} действительных бюллетеней из {{voters}} проголосовавших. Любой может повторить эту проверку без ключа и без доверия к кому-либо.", + "check_excluded": "Бюллетени, исключённые из-за отсутствия единственного действительного выбора: {{excluded}}. Каждый раскрыт в опубликованном доказательстве, чтобы любой мог увидеть причину.", + "check_failed_title": "Не совпадает с бюллетенями в блокчейне", + "check_failed_body": "Опубликованные итоги не удалось проверить по бюллетеням, записанным в блокчейне. Не полагайтесь на этот результат.", + "check_unproven_title": "Опубликовано без доказательства", + "check_unproven_body": "Этот результат опубликован контрактом, созданным до появления доказательств подсчёта, поэтому его нельзя проверить по бюллетеням." }, "verify_receipt": { "title": "Проверить голос", @@ -285,7 +288,8 @@ "reference": "Референс-номер", "view_tx": "Посмотреть публичную запись", "back_elections": "Вернуться к выборам", - "check_receipt": "Проверить мою квитанцию" + "check_receipt": "Проверить мою квитанцию", + "demo_reference": "Демонстрационные данные: бюллетень не был записан, поэтому проверять нечего." }, "change_vote": { "title": "Изменить ваш голос", @@ -474,7 +478,10 @@ "open_voting_early": "Открыть голосование", "open_voting_title": "Начать голосование раньше срока?", "open_voting_desc": "Регистрация уже закрыта, а голосование ещё не началось. Все зарегистрировавшиеся смогут голосовать прямо сейчас. Дата закрытия не меняется.", - "voting_opened_done": "Голосование открыто" + "voting_opened_done": "Голосование открыто", + "void_refused": "Эти выборы обещали, что не будут отменены, и проголосовало достаточно людей для публикации результата, поэтому их нельзя аннулировать. Импортируйте ключ расшифровки, чтобы опубликовать результат.", + "key_device_only": "Ключ расшифровки этих выборов не удалось получить из вашего кошелька, поэтому он хранится только на этом устройстве. Экспортируйте его сейчас и сохраните: без него подсчёт голосов невозможен.", + "tally_excluded": "Бюллетени, исключённые как недействительные: {{excluded}}. Каждый раскрыт в опубликованном доказательстве, чтобы любой мог это проверить." }, "gas": { "title": "Управление газом", @@ -610,7 +617,8 @@ "wallet_request_rejected": "Вы отменили запрос в кошельке. Ничего не выполнено.", "passkey_cancelled": "Вы отклонили запрос passkey. Ничего не создано: можно попробовать снова.", "list_unreadable": "Этот список не удалось прочитать из сети, а это не то же самое, что пустой список. Проверьте соединение и повторите.", - "passkey_unproven": "Passkey создан, но мы ещё не смогли убедиться, что он умеет расшифровывать. Нажмите ещё раз: спросят тот же самый, новый создаваться не будет." + "passkey_unproven": "Passkey создан, но мы ещё не смогли убедиться, что он умеет расшифровывать. Нажмите ещё раз: спросят тот же самый, новый создаваться не будет.", + "revote_too_soon": "Вы изменили свой голос меньше часа назад. Учитывается ваш последний бюллетень; изменить его снова можно будет, когда пройдёт час." }, "demo": { "banner": "Демонстрационные данные: контракты не развёрнуты. Ничего из этого не в блокчейне." diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index 7fee909..73e2e2c 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -218,11 +218,14 @@ "export_json": "导出审计记录", "view_ipfs": "在 IPFS 上查看", "not_available": "结果尚未公布。", - "check_ok_title": "已与公开记录核对", - "check_ok_body": "公布的计数合计为 {{declared}},合约记录了 {{voters}} 名投票人。没有凭空增加,也没有丢弃任何选票。", - "check_bad_title": "与公开记录不符", - "check_bad_body": "公布的计数合计为 {{declared}},但合约记录了 {{voters}} 名投票人。这两个数字本应相同。", - "check_limit": "这不需要密钥,也不需要信任任何人,任何人都可以复核。但它看不出选票是否在选项之间被挪动,因为那样总数不变。" + "check_pending": "正在将公布的结果与链上的选票进行核对…", + "check_verified_title": "已与链上的选票核对", + "check_verified_body": "公布的计票结果恰好等于 {{voters}} 名投票者的 {{valid}} 张有效选票之和。任何人都可以在没有密钥、无需信任任何人的情况下重复此核对。", + "check_excluded": "因未包含唯一有效选项而被排除的选票:{{excluded}} 张。每一张都在公布的证明中公开,任何人都能看到原因。", + "check_failed_title": "与链上的选票不符", + "check_failed_body": "无法根据链上记录的选票核实公布的计票结果。请勿依赖此结果。", + "check_unproven_title": "未附证明即公布", + "check_unproven_body": "此结果由计票证明出现之前的合约公布,因此无法与选票核对。" }, "verify_receipt": { "title": "验证一笔投票", @@ -276,7 +279,8 @@ "reference": "参考编号", "view_tx": "查看公开记录", "back_elections": "返回选举列表", - "check_receipt": "查看我的回执" + "check_receipt": "查看我的回执", + "demo_reference": "演示数据:未记录任何选票,因此没有可核对的参考编号。" }, "change_vote": { "title": "更改您的投票", @@ -462,7 +466,10 @@ "open_voting_early": "立即开放投票", "open_voting_title": "提前开始投票?", "open_voting_desc": "登记已结束,投票尚未开始。所有已登记的人从现在起即可投票。截止日期不变。", - "voting_opened_done": "投票已开放" + "voting_opened_done": "投票已开放", + "void_refused": "此选举承诺不会被取消,且投票人数已足以公布结果,因此不能作废。请导入解密密钥以公布结果。", + "key_device_only": "无法从你的钱包派生此选举的解密密钥,因此它只存在于此设备上。请立即导出并妥善保管:没有它就无法计票。", + "tally_excluded": "作为无效选票被排除:{{excluded}} 张。每一张都在公布的证明中公开,任何人都可以核查。" }, "gas": { "title": "Gas 管理", @@ -595,7 +602,8 @@ "wallet_request_rejected": "你在钱包中取消了请求,未执行任何操作。", "passkey_cancelled": "你关闭了通行密钥请求。没有创建任何内容,可以再试一次。", "list_unreadable": "无法从链上读取此列表,这并不等于列表为空。请检查连接后重试。", - "passkey_unproven": "通行密钥已创建,但还无法确认它能用于解密。请再按一次:会向同一个密钥提问,不会创建新的。" + "passkey_unproven": "通行密钥已创建,但还无法确认它能用于解密。请再按一次:会向同一个密钥提问,不会创建新的。", + "revote_too_soon": "你在不到一小时前更改了投票。以你最新的选票为准;一小时后可以再次更改。" }, "demo": { "banner": "演示数据:未部署合约。此处内容均不在链上。" diff --git a/frontend/src/lib/chainElections.ts b/frontend/src/lib/chainElections.ts index 735fc51..39b41ed 100644 --- a/frontend/src/lib/chainElections.ts +++ b/frontend/src/lib/chainElections.ts @@ -24,6 +24,10 @@ import { } from "./electionIdentity"; import type { Candidate, Election, ElectionPhase, VotingType } from "../data/seed"; import { getVoterPersonhood } from "./voterSession"; +import { mapWithConcurrency } from "./utils"; + +/** Addresses asked of the factory per call. */ +const ADDRESS_PAGE = 500; import { asPersonhoodLevel, effectivePersonhood, @@ -107,6 +111,7 @@ export async function fetchElection(address: string): Promise { cancellable, createdAt, platformAttester, + privacyQuorumOnChain, ] = await Promise.all([ c.name(), c.organizer(), @@ -131,6 +136,9 @@ export async function fetchElection(address: string): Promise { // of ours its tree would hold. Elections from before private enrolment have // no such function and revert rather than answering zero. (c.platformAttester() as Promise).catch(() => ZERO_ADDRESS), + // The floor `publishResults` enforces. The metadata keeps a copy for + // display, but only the contract's own value is binding. + (c.privacyQuorum() as Promise).catch(() => null), ]); const meta = parseMetadata(metadataJson); @@ -176,7 +184,6 @@ export async function fetchElection(address: string): Promise { candidates.push({ id: `option-${Number(numOptions)}`, name: i18n.t("election.blank_vote") }); let ipfsCid: string | undefined; - let tallyCheck: Election["tallyCheck"]; if (resultsPublished) { const [cid, tally, outcome, winnerIndex] = await Promise.all([ c.resultsCid(), @@ -189,26 +196,8 @@ export async function fetchElection(address: string): Promise { candidates.forEach((cand, i) => { cand.votes = counts[i] ?? 0; }); - /** - * The half of the result anyone can check without the decryption key. - * - * Each voter's surviving ballot contributes exactly one to exactly one - * counter, so a published tally has to add up to the number of people the - * chain recorded as having voted. Comparing the two needs nothing secret, - * which is the point: until now the only party who could tell whether the - * published numbers were the real ones was the organizer who produced them. - * - * What it does NOT prove is that the counters were split correctly. An - * organizer who moves votes between options keeps the total intact and this - * still agrees. Closing that needs a proof of correct decryption, which is - * written up as future work. - */ - const declared = counts.reduce((sum, n) => sum + n, 0); - tallyCheck = { - declared, - voters: Number(distinctVoters), - matches: declared === Number(distinctVoters), - }; + // Whether these counters are what the ballots hold is checked on the + // results screen, against the proof published with them: see `TallyCheck`. // Outcome: 1=WINNER, 2=TIE if (Number(outcome) === 1) candidates[Number(winnerIndex)].isWinner = true; @@ -311,12 +300,12 @@ export async function fetchElection(address: string): Promise { totalEnrolled: Number(memberCount), castVotes: Number(voteCount), distinctVoters: Number(distinctVoters), - tallyCheck, ipfsCid, eligibilityPolicy, votingType: VOTING_TYPE_MAP[Number(votingType)] ?? "simple_plurality", thresholdValue: Number(thresholdValue), - privacyQuorum: meta.privacyQuorum ?? (Number(thresholdValue) || 0), + privacyQuorum: + privacyQuorumOnChain !== null ? Number(privacyQuorumOnChain) : (meta.privacyQuorum ?? 0), fixedSchedule: Boolean(fixedSchedule), cancellable: Boolean(cancellable), createdAt: new Date(Number(createdAt) * 1000), @@ -351,8 +340,14 @@ export async function fetchElectionAddresses(): Promise { const factory = getFactory(); const total = Number(await factory.electionsCount()); if (total === 0) return []; - const list = (await factory.getElections(0, total)) as string[]; - return [...list].reverse(); + // In pages, because one call returning every address grows without bound + // and an RPC endpoint caps the size of a response long before the chain + // caps the number of elections. + const offsets = Array.from({ length: Math.ceil(total / ADDRESS_PAGE) }, (_, i) => i * ADDRESS_PAGE); + const pages = await mapWithConcurrency(offsets, 4, offset => + factory.getElections(offset, ADDRESS_PAGE) as Promise, + ); + return pages.flat().reverse(); } /** Hydrates the given addresses, preserving the order they were given in. */ diff --git a/frontend/src/lib/contracts.ts b/frontend/src/lib/contracts.ts index f606d49..0f4e608 100644 --- a/frontend/src/lib/contracts.ts +++ b/frontend/src/lib/contracts.ts @@ -69,11 +69,12 @@ export const ELECTION_ABI = [ "function closeEnrollmentEarly()", "function closeVotingEarly()", "function markVoided()", - "function publishResults(string ipfsCid, uint256[] tallyResults)", + "function publishResults(string ipfsCid, uint256[] tallyResults, uint256 invalidBallots, bytes tallyProof)", // events "event MemberEnrolled(uint256 indexed identityCommitment, uint256 index, uint256 merkleTreeRoot)", "event VoteCast(uint256 indexed nullifier, bytes voteCiphertext, uint256 nonce, uint256 timestamp)", "event ResultsPublished(string ipfsCid, uint256[] tally, uint8 outcome, uint256 winnerIndex)", + "event TallyProofPublished(uint256 invalidBallots, bytes proof)", ] as const; export const PAYMASTER_ABI = [ diff --git a/frontend/src/lib/deployments.ts b/frontend/src/lib/deployments.ts index 738d6a5..ce1363c 100644 --- a/frontend/src/lib/deployments.ts +++ b/frontend/src/lib/deployments.ts @@ -23,7 +23,6 @@ export interface Deployment { }; config?: { entryPoint?: string; - trustedForwarder?: string; }; } diff --git a/frontend/src/lib/electionIdentity.test.ts b/frontend/src/lib/electionIdentity.test.ts index 75f5f6f..79fb470 100644 --- a/frontend/src/lib/electionIdentity.test.ts +++ b/frontend/src/lib/electionIdentity.test.ts @@ -90,3 +90,13 @@ describe('identityForElection', () => { expect(storedElectionCommitment(ELECTION)).toBeNull(); }); }); + +describe('identityForElection, across identities in one tab', () => { + it("never hands one identity's derivation to another", async () => { + // The cache used to be keyed by election alone, so after a recovery the old + // identity's commitment kept coming back for elections already visited. + const before = await identityForElection(new Identity('before-recovery'), ELECTION); + const after = await identityForElection(new Identity('after-recovery'), ELECTION); + expect(after.commitment).not.toBe(before.commitment); + }); +}); diff --git a/frontend/src/lib/electionIdentity.ts b/frontend/src/lib/electionIdentity.ts index c02d86c..a4adb65 100644 --- a/frontend/src/lib/electionIdentity.ts +++ b/frontend/src/lib/electionIdentity.ts @@ -36,8 +36,17 @@ const INFO_PREFIX = "votain/election-identity/v1:"; */ const COMMITMENTS_KEY = "votain_election_commitments"; -/** In-memory, so a page that enrols and then votes derives once. */ +/** + * In-memory, so a page that enrols and then votes derives once. + * + * Keyed by the MASTER as well as the election. It used to be keyed by the + * election alone, so after a recovery or a different phrase in the same tab + * the old identity's derivations kept being handed out, and the voter enrolled + * and voted as somebody they no longer were. + */ const derived = new Map(); +const derivedKey = (master: Identity, electionAddress: string): string => + `${master.commitment}:${keyFor(electionAddress)}`; const keyFor = (electionAddress: string): string => electionAddress.toLowerCase(); @@ -79,7 +88,7 @@ export async function identityForElection( master: Identity, electionAddress: string, ): Promise { - const cached = derived.get(keyFor(electionAddress)); + const cached = derived.get(derivedKey(master, electionAddress)); if (cached) return cached; const ikm = await crypto.subtle.importKey( @@ -105,7 +114,7 @@ export async function identityForElection( const seed = Array.from(new Uint8Array(bits), b => b.toString(16).padStart(2, "0")).join(""); const identity = new Identity(seed); - derived.set(keyFor(electionAddress), identity); + derived.set(derivedKey(master, electionAddress), identity); rememberCommitment(electionAddress, identity.commitment); return identity; } diff --git a/frontend/src/lib/eligibility.ts b/frontend/src/lib/eligibility.ts index adbb2cf..f3ee9d8 100644 --- a/frontend/src/lib/eligibility.ts +++ b/frontend/src/lib/eligibility.ts @@ -167,7 +167,7 @@ async function request(path: string, init?: RequestInit): Promise { const response = await fetch(`${backendBase()}${path}`, { ...init, credentials: "include", - headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, + headers: { "Content-Type": "application/json", ...init?.headers }, }); // A 401 here is the session, not the request: every one of these carries the // cookie, so the server refusing all of them means there is nothing to carry. diff --git a/frontend/src/lib/organizer.ts b/frontend/src/lib/organizer.ts index a86375d..4b9e92a 100644 --- a/frontend/src/lib/organizer.ts +++ b/frontend/src/lib/organizer.ts @@ -13,6 +13,7 @@ import { eventArgs, queryLogsFrom } from "./logs"; import { fetchOrganizerElectionAddresses } from "./chainElections"; import { COUNTER_BASE, generateElectionKeys, type SerializedKeyPair } from "./paillier"; import { deriveElectionKeys, newKeyNonce } from "./tallyKey"; +import { signsDeterministically } from "./organizerKey"; import { effectivePersonhood, isCoherentPolicy, @@ -172,10 +173,11 @@ export async function createElection( const { ethers } = await import("ethers"); // 1. Tally keypair. Preferred: derive deterministically from the organizer's - // passkey PRF, keyed by a public per-election nonce. Nothing is stored at - // rest and the key can be re-derived on any device the passkey syncs to. - // Fallback (no PRF passkey): a random key that must be stored/exported, - // since it could never be reproduced. + // wallet signature (`organizerKey.ts`), keyed by a public per-election + // nonce. Nothing is stored at rest and the key can be re-derived on any + // device the wallet reaches. Fallback (a wallet whose signatures are not + // deterministic): a random key that must be stored/exported, since a + // derived one could never be reproduced. // Refused here and not only in the wizard, because this is the last code the // app runs before a transaction exists and the wizard is one caller of it. // It is NOT a defence against a transaction built by hand, which never comes @@ -185,7 +187,9 @@ export async function createElection( } const keyNonce = newKeyNonce(); - const derived = await deriveElectionKeys(keyNonce, signer); + const derived = (await signsDeterministically(signer)) + ? await deriveElectionKeys(keyNonce, signer) + : null; const keyDerivable = derived !== null; const paillierKeys = derived ?? (await generateElectionKeys()); @@ -362,18 +366,24 @@ export async function markVoided(signer: Signer, address: string): Promise { - const tx = await getElection(address, signer).publishResults(ipfsCid, counts.map(BigInt)); + const tx = await getElection(address, signer).publishResults( + ipfsCid, + tally.counts.map(BigInt), + BigInt(tally.invalidBallots), + tally.proof, + ); return waitForLifecycleTx(tx); } diff --git a/frontend/src/lib/organizerKey.test.ts b/frontend/src/lib/organizerKey.test.ts new file mode 100644 index 0000000..c1c6414 --- /dev/null +++ b/frontend/src/lib/organizerKey.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { Signer } from 'ethers'; + +import { clearOrganizerKeyCache, organizerMasterSecret, signsDeterministically } from './organizerKey'; + +/** A wallet stand-in whose signatures are fixed, or fresh every call. */ +function wallet(address: string, deterministic: boolean): Signer & { prompts: number } { + let counter = 0; + const signer = { + prompts: 0, + getAddress: async () => address, + provider: { getNetwork: async () => ({ chainId: 80002n }) }, + signTypedData: async () => { + signer.prompts += 1; + counter += 1; + return '0x' + (deterministic ? 'ab' : counter.toString(16).padStart(2, '0')).repeat(65); + }, + }; + return signer as unknown as Signer & { prompts: number }; +} + +beforeEach(() => { + localStorage.clear(); + clearOrganizerKeyCache(); +}); + +describe('signsDeterministically', () => { + it('trusts a wallet that signs the same payload to the same bytes', async () => { + const w = wallet('0x0000000000000000000000000000000000000001', true); + expect(await signsDeterministically(w)).toBe(true); + expect(w.prompts).toBe(2); + }); + + it('refuses one whose signatures change, which could never re-derive a key', async () => { + const w = wallet('0x0000000000000000000000000000000000000002', false); + expect(await signsDeterministically(w)).toBe(false); + }); + + it('asks once per wallet, not once per election', async () => { + const w = wallet('0x0000000000000000000000000000000000000003', true); + await signsDeterministically(w); + await signsDeterministically(w); + expect(w.prompts).toBe(2); + }); + + it('derives the same master secret from a deterministic wallet every time', async () => { + const w = wallet('0x0000000000000000000000000000000000000004', true); + const a = await organizerMasterSecret(w); + clearOrganizerKeyCache(); + const b = await organizerMasterSecret(w); + expect(a).toEqual(b); + }); +}); diff --git a/frontend/src/lib/organizerKey.ts b/frontend/src/lib/organizerKey.ts index 310ca14..66e7742 100644 --- a/frontend/src/lib/organizerKey.ts +++ b/frontend/src/lib/organizerKey.ts @@ -77,16 +77,21 @@ async function tallySignature(signer: Signer): Promise { const cached = sessionSignatures.get(address); if (cached) return cached; + const signature = await requestSignature(signer, address); + sessionSignatures.set(address, signature); + return signature; +} + +/** Asks the wallet, every time. */ +async function requestSignature(signer: Signer, address: string): Promise { const network = await signer.provider?.getNetwork(); const domain = { ...DOMAIN, chainId: Number(network?.chainId ?? 0) }; try { - const signature = await signer.signTypedData(domain, TYPES as never, { + return await signer.signTypedData(domain, TYPES as never, { purpose: PURPOSE, organizer: address, }); - sessionSignatures.set(address, signature); - return signature; } catch (e) { const err = e as { code?: number | string }; if (err?.code === 4001 || err?.code === "ACTION_REJECTED") { @@ -96,6 +101,47 @@ async function tallySignature(signer: Signer): Promise { } } +/** Where the answer to `signsDeterministically` is remembered, per address. */ +const DETERMINISM_KEY_PREFIX = "votain_deterministic_signer:"; + +/** + * Whether this wallet signs the same payload to the same bytes, checked once. + * + * THE ASSUMPTION ABOVE IS NOT UNIVERSAL. RFC 6979 is what MetaMask and most + * software wallets do, but an MPC wallet, a smart-contract wallet answering + * through ERC-1271, or a hardware signer with randomised nonces returns a + * different signature every time. Deriving a tally key from one of those + * works exactly once: the next derivation, on this device or another, yields a + * different key and the election can never be decrypted. + * + * So the first time an address is used, it signs twice and the two are + * compared. A wallet that fails gets a random key for each election instead, + * kept on this device and offered for export, which is the path an organizer + * without a derivable key already had. The answer is remembered, so the second + * prompt happens once per wallet, not once per election. + */ +export async function signsDeterministically(signer: Signer): Promise { + const address = (await signer.getAddress()).toLowerCase(); + const key = DETERMINISM_KEY_PREFIX + address; + try { + const known = localStorage.getItem(key); + if (known === "yes" || known === "no") return known === "yes"; + } catch { + /* storage unavailable: ask the wallet */ + } + + const first = await tallySignature(signer); + const second = await requestSignature(signer, address); + const deterministic = first === second; + try { + localStorage.setItem(key, deterministic ? "yes" : "no"); + } catch { + /* not remembered: asked again next time, which is safe */ + } + if (!deterministic) sessionSignatures.delete(address); + return deterministic; +} + /** HKDF over arbitrary key material, to 32 bytes. */ async function hkdf(material: Uint8Array): Promise { const ikm = await crypto.subtle.importKey("raw", material as BufferSource, "HKDF", false, [ diff --git a/frontend/src/lib/paillier.test.ts b/frontend/src/lib/paillier.test.ts index 2a50dd9..a813319 100644 --- a/frontend/src/lib/paillier.test.ts +++ b/frontend/src/lib/paillier.test.ts @@ -1,12 +1,10 @@ -import { describe, it, expect, beforeAll } from "vitest"; +import { describe, it, expect } from "vitest"; import { COUNTER_BASE, LEGACY_COUNTER_BASE, counterBaseFor, - decryptTally, encryptBallot, } from "./paillier"; -import { generateRandomKeys } from "paillier-bigint"; import { isHexString, solidityPackedKeccak256 } from "ethers"; /** @@ -62,43 +60,3 @@ describe("counterBaseFor, an election is decoded in its own base", () => { expect(counterBaseFor("not json at all")).toBe(LEGACY_COUNTER_BASE); }); }); - -/** - * Every ballot adds exactly one to exactly one counter, so the counters have to - * add up to the ballots that went in. That equality is what catches an overflow - * between counters, which the previous check could not see: it only looked for - * a leftover past the LAST counter, and a carry from option 0 into option 1 - * leaves none. - */ -describe("decryptTally, the counters have to account for every ballot", () => { - // 512 bits: big enough to hold several counters, fast enough for a test. - let keys: { publicKey: any; privateKey: any }; - beforeAll(async () => { - keys = (await generateRandomKeys(512)) as unknown as typeof keys; - }); - - function tallyOf(options: number[], base: bigint, slots: number, expected?: number) { - const sum = options - .map(i => keys.publicKey.encrypt(base ** BigInt(i))) - .reduce((a: bigint, c: bigint) => keys.publicKey.addition(a, c)); - return decryptTally(keys, sum, slots, base, expected); - } - - it("unpacks a well-formed tally", () => { - expect(tallyOf([0, 1, 1, 2], 1000n, 3, 4).map(Number)).toEqual([1, 2, 1]); - }); - - it("refuses a tally whose counters do not add up to the ballots", () => { - expect(() => tallyOf([0, 1, 1, 2], 1000n, 3, 5)).toThrow(/does not match the ballots counted/); - }); - - it("catches a carry between counters, which the old leftover check could not", () => { - // Base 10: eleven votes for option 0 overflow into option 1's counter. The - // leftover after the last counter is still zero, so only the sum sees it. - const eleven = Array.from({ length: 11 }, () => 0); - const counts = tallyOf(eleven, 10n, 3).map(Number); - expect(counts).toEqual([1, 1, 0]); // silently wrong - expect(counts.reduce((a, c) => a + c, 0)).not.toBe(11); - expect(() => tallyOf(eleven, 10n, 3, 11)).toThrow(/does not match the ballots counted/); - }); -}); diff --git a/frontend/src/lib/paillier.ts b/frontend/src/lib/paillier.ts index dbc3bce..079ea35 100644 --- a/frontend/src/lib/paillier.ts +++ b/frontend/src/lib/paillier.ts @@ -118,54 +118,10 @@ export function encryptBallot( optionIndex: number, base: bigint = COUNTER_BASE, ): string { + if (!Number.isInteger(optionIndex) || optionIndex < 0) { + throw new Error(`not an option index: ${optionIndex}`); + } const pk = parsePublicKey(publicKeyJson); const plaintext = base ** BigInt(optionIndex); return toBytesHex(pk.encrypt(plaintext)); } - -/** Homomorphically adds a list of 0x-hex ciphertexts. */ -export function addCiphertexts(publicKey: PublicKey, ciphertexts: string[]): bigint { - if (ciphertexts.length === 0) throw new Error("no ciphertexts to add"); - return ciphertexts.map(fromHex).reduce((acc, c) => publicKey.addition(acc, c)); -} - -/** - * Decrypts an aggregated ciphertext and unpacks the per-option counters. - * - * `expectedBallots` is what makes the result trustworthy rather than merely - * plausible. Every ballot adds exactly one to exactly one counter, so the - * counters must sum to the number of ballots that went in. That single equality - * catches everything this function can get wrong: a counter that overflowed into - * its neighbour, a key that does not belong to this election, a ciphertext that - * was corrupted on the way. - * - * The old check, "nothing is left over after the last counter", only caught an - * overflow out of the TOP counter. An option that passed the base carried into - * the next one, the leftover still came out zero, and the tally was quietly - * wrong with nothing to show for it. - */ -export function decryptTally( - keyPair: { publicKey: PublicKey; privateKey: PrivateKey }, - aggregated: bigint, - numOptionsWithBlank: number, - base: bigint = COUNTER_BASE, - expectedBallots?: number, -): bigint[] { - let remaining = keyPair.privateKey.decrypt(aggregated); - const counts: bigint[] = []; - for (let i = 0; i < numOptionsWithBlank; i++) { - counts.push(remaining % base); - remaining /= base; - } - if (remaining !== 0n) throw new Error("tally overflow: counters exceeded the counter base"); - - if (expectedBallots !== undefined) { - const total = counts.reduce((a, c) => a + c, 0n); - if (total !== BigInt(expectedBallots)) { - throw new Error( - `tally does not match the ballots counted: unpacked ${total}, expected ${expectedBallots}`, - ); - } - } - return counts; -} diff --git a/frontend/src/lib/recoveryPhrase.ts b/frontend/src/lib/recoveryPhrase.ts index 6c02721..f64d394 100644 --- a/frontend/src/lib/recoveryPhrase.ts +++ b/frontend/src/lib/recoveryPhrase.ts @@ -37,8 +37,8 @@ import { Identity } from "@semaphore-protocol/identity"; * A deliberately small, plain list rather than BIP-39. These words are read off * a screen and typed back by someone who may never have seen a seed phrase, so * they are short, unambiguous when spoken, and have no near-homophones. Twelve - * words from 128 give 84 bits, which is far past what an attacker could search - * when every guess has to be checked against a Semaphore commitment. + * words from 128 give 84 bits, and the derivation below is deliberately slow, + * which is what makes 84 enough: see `STRETCH_ITERATIONS`. * * NOT BIP-39 on purpose: that list exists to be compatible with wallets, and * borrowing it would invite someone to type this phrase into one. @@ -65,7 +65,22 @@ const WORDS = [ /** Words per phrase. Twelve of 128 is 84 bits. */ const WORD_COUNT = 12; -const HKDF_INFO = new TextEncoder().encode("votain:voter-identity:v1"); +/** Salt of the derivation. Changing it changes every identity, so it is versioned. */ +const DERIVATION_SALT = new TextEncoder().encode("votain:voter-identity:v2"); + +/** + * How slow deriving an identity from a phrase is, on purpose. + * + * WHY. Every voter's commitment is public and permanent in `PlatformRegistry`, + * so an attacker can guess phrases offline, at leisure, and check each guess + * against ALL of them at once. With a fast derivation, 84 bits spread over a + * million voters is 2^64 guesses to find somebody: large, but a sum a + * well-funded attacker could one day spend, against a record that never + * expires. 600,000 rounds of PBKDF2-SHA256 (the current OWASP figure) multiply + * every guess by about 2^19, which puts that out of reach, and costs an honest + * voter well under a second, once, when they type their phrase. + */ +const STRETCH_ITERATIONS = 600_000; /** A fresh phrase, from the browser's cryptographic generator. */ export function generateRecoveryPhrase(): string { @@ -102,10 +117,11 @@ export function unknownWords(phrase: string): string[] { /** * The Semaphore identity a phrase reconstructs. * - * Through HKDF rather than handing the phrase to `Identity` directly: the - * derivation is then domain-separated, so the same phrase used for anything - * else later cannot produce the same secret, and the identity does not depend - * on how a library happens to hash its input today. + * Through a salted, stretched KDF rather than handing the phrase to `Identity` + * directly: the derivation is domain-separated, so the same phrase used for + * anything else later cannot produce the same secret; it does not depend on how + * a library happens to hash its input today; and every guess costs an attacker + * `STRETCH_ITERATIONS` rounds. */ export async function identityFromPhrase(phrase: string): Promise { const normalized = normalizePhrase(phrase); @@ -116,12 +132,12 @@ export async function identityFromPhrase(phrase: string): Promise { const ikm = await crypto.subtle.importKey( "raw", new TextEncoder().encode(normalized) as BufferSource, - "HKDF", + "PBKDF2", false, ["deriveBits"], ); const bits = await crypto.subtle.deriveBits( - { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(32), info: HKDF_INFO }, + { name: "PBKDF2", hash: "SHA-256", salt: DERIVATION_SALT, iterations: STRETCH_ITERATIONS }, ikm, 256, ); diff --git a/frontend/src/lib/relay.ts b/frontend/src/lib/relay.ts index b27cc6e..ce669c9 100644 --- a/frontend/src/lib/relay.ts +++ b/frontend/src/lib/relay.ts @@ -177,6 +177,10 @@ const RELAY_ERROR_ABI = [ "error UnknownOrExpiredRoot()", "error InvalidProof()", "error WrongPhase()", + "error RevoteTooSoon(uint256 availableAt)", + "error InvalidBallot()", + "error MissingDocumentTag()", + "error UnexpectedDocumentTag()", ]; /** @@ -197,6 +201,10 @@ const SELECTOR_NAMES: Record = { "0x7a651f34": "UnexpectedAttestation", "0x716dcc39": "AttestationExpired", "0x342bd384": "BadAttestation", + "0x6ba214a7": "RevoteTooSoon", + "0x76115943": "InvalidBallot", + "0x23e148c2": "MissingDocumentTag", + "0xb48b3460": "UnexpectedDocumentTag", }; /** What the chain actually refused with, by whichever route the error kept it. */ @@ -239,6 +247,7 @@ const ERROR_MESSAGE_KEY: Record = { NotPlatformVerified: "errors.not_platform_verified", AttestationRequired: "errors.attestation_required", AttestationExpired: "errors.attestation_expired", + RevoteTooSoon: "errors.revote_too_soon", }; /** @@ -326,6 +335,8 @@ export interface EnrollAttestationInput { */ interface EnrolmentVoucher { humanTag: string; + /** "0" unless the election requires a document, then the document's tag. */ + documentTag: string; deadline: number; signature: string; eligibilitySignature: string; @@ -375,6 +386,7 @@ export async function relayEnrollPrivate( election, identityCommitment, BigInt(voucher.humanTag), + BigInt(voucher.documentTag), BigInt(voucher.deadline), voucher.signature, voucher.eligibilitySignature, @@ -417,7 +429,7 @@ export async function relayEnroll( { election, identityCommitment: identityCommitment.toString(), - ...(attestation ?? {}), + ...attestation, }, "include", ); diff --git a/frontend/src/lib/relayErrors.test.ts b/frontend/src/lib/relayErrors.test.ts index 26c2fd7..7530ecd 100644 --- a/frontend/src/lib/relayErrors.test.ts +++ b/frontend/src/lib/relayErrors.test.ts @@ -105,10 +105,17 @@ describe("relayErrorMessage", () => { "UnexpectedAttestation", "AttestationExpired", "BadAttestation", + "InvalidBallot", + "MissingDocumentTag", + "UnexpectedDocumentTag", ]; for (const name of names) { expect(revertNameOf({ data: id(`${name}()`).slice(0, 10) })).toBe(name); } + // The one with an argument: its selector hashes the full signature. + expect(revertNameOf({ data: id("RevoteTooSoon(uint256)").slice(0, 10) + "00".repeat(32) })).toBe( + "RevoteTooSoon", + ); }); it("passes an unrecognised failure through verbatim", () => { diff --git a/frontend/src/lib/semaphore.ts b/frontend/src/lib/semaphore.ts index 0f5d131..2e4652e 100644 --- a/frontend/src/lib/semaphore.ts +++ b/frontend/src/lib/semaphore.ts @@ -213,6 +213,11 @@ let cachedIdentity: Identity | null = null; /** Caches the identity and remembers its public commitment for read-only checks. */ function remember(id: Identity): Identity { + // A DIFFERENT identity (a recovery, another phrase) makes every per-election + // commitment stored for the previous one wrong: they would answer "enrolled" + // for elections this identity never joined. + const previous = localStorage.getItem(IDENTITY_COMMITMENT_KEY); + if (previous !== null && previous !== id.commitment.toString()) forgetElectionIdentities(); cachedIdentity = id; localStorage.setItem(IDENTITY_COMMITMENT_KEY, id.commitment.toString()); return id; diff --git a/frontend/src/lib/tally.ts b/frontend/src/lib/tally.ts index 380b724..27e190e 100644 --- a/frontend/src/lib/tally.ts +++ b/frontend/src/lib/tally.ts @@ -4,8 +4,14 @@ * Mirrors the pipeline in `scripts-tally/tally-votes.ts`, minus the IPFS * pinning: read every VoteCast event, keep only the highest nonce per nullifier * (coercion resistance, a coerced ballot is always superseded by a later one), - * sum the surviving Paillier ciphertexts homomorphically, and decrypt the single - * aggregate. Individual ballots are never decrypted. + * and prove the result with `tallyProof.ts`. + * + * WHAT THE KEY SEES. To prove the tally, each surviving ballot is decrypted on + * this device to tell a valid one from garbage or a stuffed one. That is no new + * power: the ciphertexts are public and whoever holds this key could always + * decrypt them. What leaves the device is only what the proof needs: the + * totals, the randomness that opens their sum, and an opening of each ballot + * excluded as invalid. Nothing about any valid ballot is published. * * The private key is read from local storage on the organizer's device and never * leaves it. `scripts-tally` stays in the repo as the auditor-facing path: it @@ -16,15 +22,9 @@ import type { Signer } from "ethers"; import { getElection } from "./contracts"; import { eventArgs, queryLogsFrom } from "./logs"; import { loadElectionPrivateKey, storeElectionPrivateKey } from "./organizer"; -import { - addCiphertexts, - counterBaseFor, - decryptTally, - restoreKeyPair, - type SerializedKeyPair, -} from "./paillier"; +import { counterBaseFor, restoreKeyPair, type SerializedKeyPair } from "./paillier"; import { deriveElectionKeys } from "./tallyKey"; - +import { encodeTallyProof, finalBallots, proveTally } from "./tallyProof"; export interface TallyResult { /** Vote counts per option; the LAST entry is the blank vote. */ @@ -36,6 +36,10 @@ export interface TallyResult { /** True when `voters` is below the election's privacy quorum. */ quorumMet: boolean; privacyQuorum: number; + /** Final ballots excluded because they encrypt no single valid choice. */ + invalidBallots: number; + /** The encoded decryption proof `publishResults` carries. */ + proof: string; } /** @@ -106,20 +110,21 @@ export async function importTallyKey(address: string, fileText: string): Promise // only wants the shape of a tally need not connect a wallet. export async function computeTally(address: string, signer?: Signer): Promise { const election = getElection(address); - const [numOptionsBn, pkJson, metadataJson] = await Promise.all([ + const [numOptionsBn, pkJson, metadataJson, quorumBn] = await Promise.all([ election.numOptions(), election.paillierPublicKey(), election.metadataJson(), + election.privacyQuorum() as Promise, ]); const totalSlots = Number(numOptionsBn) + 1; // + blank vote + // The contract's own floor, which is the one `publishResults` enforces. The + // copy in the metadata is for display and could disagree with it. + const privacyQuorum = Number(quorumBn); - let privacyQuorum = 0; let keyNonce: string | undefined; try { - const meta = JSON.parse(metadataJson) as { privacyQuorum?: number; keyNonce?: string }; - privacyQuorum = meta.privacyQuorum ?? 0; - keyNonce = meta.keyNonce; - } catch { /* no metadata: treat as no quorum, no derivable key */ } + keyNonce = (JSON.parse(metadataJson) as { keyNonce?: string }).keyNonce; + } catch { /* no metadata: no derivable key */ } // Imported/stored key first, else re-derive (prompts for a wallet signature). const keys = await resolveTallyKey(address, keyNonce, signer); @@ -132,40 +137,29 @@ export async function computeTally(address: string, signer?: Signer): Promise(); - for (const e of events) { - const args = eventArgs<{ nullifier: bigint; voteCiphertext: string; nonce: bigint }>(e); - const key = args.nullifier.toString(); - const existing = latest.get(key); - if (!existing || args.nonce > existing.nonce) { - latest.set(key, { ciphertext: args.voteCiphertext, nonce: args.nonce }); - } - } - - const finalVotes = [...latest.values()]; - const quorumMet = finalVotes.length >= privacyQuorum; - - // With no votes there is nothing to add: report an all-zero tally rather than - // letting addCiphertexts throw on an empty list. - // One ballot per voter reaches the sum, so the unpacked counters have to add - // up to exactly this many. `decryptTally` refuses anything else. - const counts = finalVotes.length === 0 - ? Array.from({ length: totalSlots }, () => 0) - : decryptTally( - { publicKey, privateKey }, - addCiphertexts(publicKey, finalVotes.map(v => v.ciphertext)), - totalSlots, - counterBaseFor(metadataJson), - finalVotes.length, - ).map(Number); + const ballots = finalBallots( + events.map(e => { + const args = eventArgs<{ nullifier: bigint; voteCiphertext: string; nonce: bigint }>(e); + return { nullifier: args.nullifier, nonce: args.nonce, ciphertext: args.voteCiphertext }; + }), + ); + + const proven = proveTally({ + publicKey: { n: publicKey.n, g: publicKey.g }, + lambda: privateKey.lambda, + decrypt: c => privateKey.decrypt(c), + ballots, + slots: totalSlots, + base: counterBaseFor(metadataJson), + }); return { - counts, - voters: finalVotes.length, + counts: proven.counts.map(Number), + voters: ballots.length, ballotsCast: events.length, - quorumMet, + quorumMet: ballots.length >= privacyQuorum, privacyQuorum, + invalidBallots: proven.invalidBallots, + proof: encodeTallyProof(proven.proof), }; } diff --git a/frontend/src/lib/tallyAudit.ts b/frontend/src/lib/tallyAudit.ts new file mode 100644 index 0000000..dbc8c1a --- /dev/null +++ b/frontend/src/lib/tallyAudit.ts @@ -0,0 +1,62 @@ +/** + * Checks a published result against the chain, with no key and no trust. + * + * Reads what anyone can read: every `VoteCast`, the election's public key and + * counter base, the published counters and the `TallyProofPublished` event, + * and runs `verifyTally` over them. The answer is the same for every reader, + * which is what makes the result verifiable rather than merely published. + */ +import { getElection } from "./contracts"; +import { eventArgs, queryLogsFrom } from "./logs"; +import { counterBaseFor } from "./paillier"; +import { decodeTallyProof, finalBallots, verifyTally } from "./tallyProof"; + +export type TallyAudit = + | { status: "verified"; validBallots: number; invalidBallots: number; voters: number } + | { status: "failed"; reason: string; voters: number } + /** Published by a contract that predates the proof, so there is nothing to check. */ + | { status: "unproven"; voters: number }; + +export async function auditPublishedTally(address: string): Promise { + const election = getElection(address); + const [pkJson, metadataJson, tally, distinctVoters] = await Promise.all([ + election.paillierPublicKey() as Promise, + election.metadataJson() as Promise, + election.tally() as Promise, + election.distinctVoters() as Promise, + ]); + const voters = Number(distinctVoters); + + const proofEvents = await queryLogsFrom(election, election.filters.TallyProofPublished()); + if (proofEvents.length === 0) return { status: "unproven", voters }; + const published = eventArgs<{ invalidBallots: bigint; proof: string }>(proofEvents[0]); + + let proof; + try { + proof = decodeTallyProof(published.proof); + } catch { + return { status: "failed", reason: "the published proof cannot be read", voters }; + } + + const { n, g } = JSON.parse(pkJson) as { n: string; g: string }; + const voteEvents = await queryLogsFrom(election, election.filters.VoteCast()); + const ballots = finalBallots( + voteEvents.map(e => { + const args = eventArgs<{ nullifier: bigint; voteCiphertext: string; nonce: bigint }>(e); + return { nullifier: args.nullifier, nonce: args.nonce, ciphertext: args.voteCiphertext }; + }), + ); + + const verdict = verifyTally({ + publicKey: { n: BigInt(n), g: BigInt(g) }, + ballots, + counts: [...tally], + invalidBallots: published.invalidBallots, + proof, + base: counterBaseFor(metadataJson), + }); + + return verdict.ok + ? { status: "verified", validBallots: verdict.validBallots, invalidBallots: verdict.invalidBallots, voters } + : { status: "failed", reason: verdict.reason, voters }; +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 9a9c114..d5e7d6c 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -20,3 +20,27 @@ export function shortenReference(reference: string): string { ? `${reference.slice(0, 12)}…${reference.slice(-8)}` : reference; } + +/** + * `items.map(fn)` with at most `limit` calls in flight, results in input order. + * + * For reads fanned out over every election: one at a time made a history + * screen wait for the sum of every round trip, and all at once would hit a free + * RPC endpoint with hundreds of requests in the same instant. + */ +export async function mapWithConcurrency( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + const results: R[] = Array.from({ length: items.length }); + let next = 0; + const worker = async () => { + while (next < items.length) { + const index = next++; + results[index] = await fn(items[index], index); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return results; +} diff --git a/frontend/src/lib/voteHistory.test.ts b/frontend/src/lib/voteHistory.test.ts index 8c795f0..de31e85 100644 --- a/frontend/src/lib/voteHistory.test.ts +++ b/frontend/src/lib/voteHistory.test.ts @@ -33,6 +33,11 @@ vi.mock('./contracts', () => ({ getElection: (address: string) => ({ address, scope: async () => 7n, + // An election from before private enrolment: the call REVERTS, which is + // how the chain says "the old doors". + platformAttester: async () => { + throw Object.assign(new Error('execution reverted'), { code: 'CALL_EXCEPTION' }); + }, filters: { VoteCast: () => ({}) }, }), getReadProvider: () => ({}), diff --git a/frontend/src/lib/voting.ts b/frontend/src/lib/voting.ts index a284b15..4df310e 100644 --- a/frontend/src/lib/voting.ts +++ b/frontend/src/lib/voting.ts @@ -22,6 +22,10 @@ import type { Identity } from "@semaphore-protocol/identity"; import { identityForElection } from "./electionIdentity"; import { claimAttestation } from "./eligibility"; import { counterBaseFor, encryptBallot } from "./paillier"; +import { mapWithConcurrency } from "./utils"; + +/** Elections read at once when a screen fans out over all of them. */ +const READ_CONCURRENCY = 6; import { computeNullifier, ensureRegistered, @@ -68,9 +72,13 @@ async function enrolsPrivately(electionAddress: string): Promise { try { const attester: string = await getElection(electionAddress).platformAttester(); answer = attester !== "0x0000000000000000000000000000000000000000"; - } catch { - // An election deployed before the function existed reverts rather than - // answering zero. Same meaning: the old doors. + } catch (error: unknown) { + // An election deployed before the function existed REVERTS rather than + // answering zero. Same meaning: the old doors. Anything else (the RPC + // down, a timeout) is not an answer at all: caching "public" then would + // have this tab enrol and vote with the wrong identity until it reloads, + // so it is thrown and asked again next time. + if ((error as { code?: string } | null)?.code !== "CALL_EXCEPTION") throw error; answer = false; } privateEnrolment.set(key, answer); @@ -280,8 +288,14 @@ export async function findVoteReceipt( const nullifier = parseNullifier(trimmed); if (nullifier === null) return null; - for (const el of elections) { - const receipts = await fetchVoteReceipts(el.contractAddress, nullifier); + // In parallel, then the first match in the order given, so the answer is + // the same as the one-at-a-time loop gave, without waiting on every miss. + const found = await mapWithConcurrency(elections, READ_CONCURRENCY, el => + fetchVoteReceipts(el.contractAddress, nullifier), + ); + for (let i = 0; i < elections.length; i++) { + const el = elections[i]; + const receipts = found[i]; if (receipts.length === 0) continue; const last = receipts[receipts.length - 1]; return { @@ -370,11 +384,16 @@ export async function fetchLocalVoteHistory( elections: { contractAddress: string; title: string; phase: string }[], ): Promise { const entries: VoteHistoryEntry[] = []; - for (const el of elections) { + const known = elections.flatMap(el => { const nullifier = getStoredVoteNullifier(el.contractAddress); - if (nullifier === null) continue; - - const receipts = await fetchVoteReceipts(el.contractAddress, nullifier); + return nullifier === null ? [] : [{ el, nullifier }]; + }); + const found = await mapWithConcurrency(known, READ_CONCURRENCY, ({ el, nullifier }) => + fetchVoteReceipts(el.contractAddress, nullifier), + ); + for (let i = 0; i < known.length; i++) { + const { el, nullifier } = known[i]; + const receipts = found[i]; if (receipts.length === 0) continue; const last = receipts[receipts.length - 1]; @@ -403,7 +422,7 @@ export async function fetchVoteHistory( if (!master) return []; const entries: VoteHistoryEntry[] = []; - for (const el of elections) { + const looked = await mapWithConcurrency(elections, READ_CONCURRENCY, async el => { const election = getElection(el.contractAddress); const scope: bigint = el.scope ?? BigInt(await election.scope()); // Per election, because that is what the ballot was cast with wherever the @@ -411,7 +430,9 @@ export async function fetchVoteHistory( // found nothing at all there, which reads as "you never voted". const identity = await votingIdentity(el.contractAddress, master); const nullifier = computeNullifier(identity, scope); - const receipts = await fetchVoteReceipts(el.contractAddress, nullifier); + return { el, nullifier, receipts: await fetchVoteReceipts(el.contractAddress, nullifier) }; + }); + for (const { el, nullifier, receipts } of looked) { if (receipts.length === 0) continue; // Learned once, so this browser stops needing the passkey to answer the diff --git a/frontend/src/lib/worldId.test.ts b/frontend/src/lib/worldId.test.ts index 2bc2ede..9685594 100644 --- a/frontend/src/lib/worldId.test.ts +++ b/frontend/src/lib/worldId.test.ts @@ -204,11 +204,11 @@ describe('opening one', () => { expect(JSON.parse(llamadas[0].init?.body as string)).not.toHaveProperty('returnTo'); }); - it('carries an action override, since each one yields its own nullifier', async () => { + it('never names an action, which only the server may fix', async () => { respuestas = [{ connectorURI: 'x' }, { status: 'none' }]; - await requestWorldIdProof({ action: 'recover-identity' }); + await requestWorldIdProof(); - expect(JSON.parse(llamadas[0].init?.body as string)).toEqual({ action: 'recover-identity' }); + expect(JSON.parse(llamadas[0].init?.body as string)).toEqual({}); }); it('abandons one on request, so a reload does not put its QR straight back', async () => { diff --git a/frontend/src/lib/worldId.ts b/frontend/src/lib/worldId.ts index 54335b9..f41cba7 100644 --- a/frontend/src/lib/worldId.ts +++ b/frontend/src/lib/worldId.ts @@ -48,8 +48,6 @@ import { backendBase } from "./backend"; export interface WorldIdRequestOptions { /** Called once the QR / deep-link URI is ready, so the UI can render it. */ onConnectorUri?: (uri: string) => void; - /** Overrides the configured action (each action yields its own nullifier). */ - action?: string; /** Lets a screen that is unmounting stop waiting without cancelling anything. */ signal?: AbortSignal; } @@ -126,9 +124,9 @@ export async function requestWorldIdProof( method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", - body: JSON.stringify({ - ...(opts.action ? { action: opts.action } : {}), - }), + // No action: the server fixes it, since each action would yield its own + // nullifier and with it a second identity for the same person. + body: JSON.stringify({}), }); if (!res.ok) throw new Error("Failed to open World ID request"); diff --git a/frontend/src/pages/organizer/ElectionManagement.tsx b/frontend/src/pages/organizer/ElectionManagement.tsx index ca6bcd5..db709e2 100644 --- a/frontend/src/pages/organizer/ElectionManagement.tsx +++ b/frontend/src/pages/organizer/ElectionManagement.tsx @@ -211,6 +211,14 @@ export default function ElectionManagement() { // The key exists to make the tally possible. A decided election has either had // its tally published or will never have one, so from here it is only a // liability to be disposed of. + /** + * An election that gave up being called off cannot be voided once its result + * is publishable: the organizer can read the result first, so voiding it + * would be the veto they promised not to have. `markVoided` reverts with + * `ResultPublishable`, and the button would only lead there. + */ + const voidRefused = + election.cancellable === false && (election.distinctVoters ?? 0) >= election.privacyQuorum; const keyStillNeeded = !['closed', 'voided', 'cancelled'].includes(election.phase); // Drop the decrypted counts on close so reopening always recomputes from the @@ -309,7 +317,7 @@ export default function ElectionManagement() { // Publishing is a transaction like every other write here, and it was the // one that never brought the wallet forward. await wallet.withWalletApp( - () => publishResults(signer, election.contractAddress, tallyPreview.counts), + () => publishResults(signer, election.contractAddress, tallyPreview), () => toast({ title: t('errors.confirm_in_wallet_app'), variant: 'info' }), ); toast({ title: t('election_mgmt.results_published'), variant: 'success' }); @@ -565,6 +573,12 @@ export default function ElectionManagement() { <> + {/* A key that could not be derived from the wallet (see + `signsDeterministically`) exists on this device only: losing + the browser data would make the election impossible to count. */} + {tallyKeyPresent && !election.keyNonce && ( +

{t('election_mgmt.key_device_only')}

+ )} {tallyKeyPresent ? ( - {!tallyKeyPresent ? ( + {!tallyKeyPresent && voidRefused ? ( + // The contract refuses: see `voidRefused`. +

+ {t('election_mgmt.void_refused')} +

+ ) : !tallyKeyPresent ? ( // No key here: the election can still be voided if that's the intent. - + {reference ? ( +
+ {referenceDisplay} + +
+ ) : ( +

{t('confirmation.demo_reference')}

+ )} {/* Election name */} @@ -114,11 +129,13 @@ export default function VoteConfirmation() { this is the one moment the voter holds that reference, and the page it opens is the tool anyone else would use to check the same thing. */} - + {reference && ( + + )} - {/* Tallying phase: decrypt in the browser with the organizer's Paillier - key (which never leaves the device), review the counts, then sign the - publishing transaction. If the privacy quorum was not met the tally - must not be published: the election is voided instead. */} + {/* Tallying phase: decrypt the election's aggregate in the browser with + the organizer's keys (which never leave the device), prove it, review + the counts, then sign the publishing transaction. If the privacy + quorum was not met the tally must not be published: the election is + voided instead, with a proof that says only how many voted. */}
@@ -676,11 +681,6 @@ export default function ElectionManagement() { {n}
))} - {tallyPreview.invalidBallots > 0 && ( -

- {t('election_mgmt.tally_excluded', { excluded: tallyPreview.invalidBallots })} -

- )} {!tallyPreview.quorumMet && (

{t('election_mgmt.tally_quorum_short', { @@ -726,7 +726,11 @@ export default function ElectionManagement() { ) : ( )} diff --git a/frontend/src/pages/public/VerifyReceipt.tsx b/frontend/src/pages/public/VerifyReceipt.tsx index 6965891..0e00f9a 100644 --- a/frontend/src/pages/public/VerifyReceipt.tsx +++ b/frontend/src/pages/public/VerifyReceipt.tsx @@ -24,10 +24,10 @@ import { usePageMeta } from '../../seo/usePageMeta'; * is a strange thing for a project whose claim is end-to-end verifiability. * * It stays deliberately unauthenticated and reachable without a session. The - * whole value of publishing a nullifier is that a third party can check a + * whole value of a public ballot tag is that a third party can check a * receipt somebody shows them; behind a login it would only ever tell voters * what their own history already tells them. Signed-in voters do get their own - * receipts listed below as a shortcut, because nobody memorises a nullifier, + * receipts listed below as a shortcut, because nobody memorises a tag, * but that is a convenience layered on top and never a requirement. */ @@ -67,7 +67,7 @@ export default function VerifyReceipt() { const trimmed = query.trim(); // Nothing to search until the election list is in: the lookup reads a // transaction against the elections it knows and scans their events for a - // nullifier, so running it against an empty list answers "not found" to + // tag, so running it against an empty list answers "not found" to // every receipt, including the valid ones. if (!trimmed || elections.length === 0) return; setState('searching'); @@ -79,7 +79,7 @@ export default function VerifyReceipt() { ); // A link from the history arrives with the receipt already in it, so the - // voter does not have to carry a nullifier from one screen to the other by + // voter does not have to carry a tag from one screen to the other by // hand. It waits for the election list, which is what the lookup searches, // and runs once: the search rewrites the query string, which would otherwise // bring it straight back round. @@ -103,7 +103,7 @@ export default function VerifyReceipt() { if (!voterLoggedIn || !live || elections.length === 0) return; let cancelled = false; void (async () => { - // The device's own record needs no passkey, because a nullifier is a + // The device's own record needs no passkey, because a ballot tag is a // public value this browser wrote down when it voted. Unlocking widens // the answer to ballots cast anywhere; it does not enable it. const targets = elections.map(e => ({ @@ -235,20 +235,11 @@ export default function VerifyReceipt() { {row(t('verify_receipt.reference'), match.txHash, true)} - {row(t('verify_receipt.nullifier'), match.nullifier, true)} + {row(t('verify_receipt.nullifier'), match.tag, true)}

{t('verify_receipt.date')} {match.timestamp.toLocaleString()}
- {/* Only worth a line when there was more than one. A re-vote - replaces the earlier ballot, so seeing two here and one tally - entry is the system working, not a discrepancy. */} - {match.voteCount > 1 && ( -
- {t('verify_receipt.ballots')} - {match.voteCount} -
- )}
{t('verify_receipt.status')} [0]['variant']}> diff --git a/frontend/src/pages/voter/ChangeVote.tsx b/frontend/src/pages/voter/ChangeVote.tsx index 7f771a3..c2993a5 100644 --- a/frontend/src/pages/voter/ChangeVote.tsx +++ b/frontend/src/pages/voter/ChangeVote.tsx @@ -50,7 +50,8 @@ export default function ChangeVote() { const handleConfirm = () => { setConfirmOpen(false); - // Re-vote is the same castVote path: same nullifier, next nonce is read on-chain. + // Re-vote is the same castVote path: it finds the voter's last ballot from + // their own tags and cancels it, and nothing on chain says it did. const optionIndex = election.candidates.findIndex(c => c.id === selected); navigate(`/voter/election/${election.id}/zk-proof`, { state: { candidateId: selected, isChangeVote: true, optionIndex, live, address: election.contractAddress }, diff --git a/frontend/src/pages/voter/VoterHistory.tsx b/frontend/src/pages/voter/VoterHistory.tsx index 2733df7..ccef078 100644 --- a/frontend/src/pages/voter/VoterHistory.tsx +++ b/frontend/src/pages/voter/VoterHistory.tsx @@ -23,7 +23,7 @@ interface HistoryRow { phase: string; date: Date; referenceNumber: string; - nullifier: string; + tag: string; } export default function VoterHistory() { @@ -50,7 +50,7 @@ export default function VoterHistory() { if (!live) return; // seed history is already the initial state // Both paths answer; they differ in reach. Without the identity this lists // the ballots this browser recorded when it cast them, which needs no - // passkey because a nullifier is public. With it, every ballot anywhere. + // passkey because a ballot's tag is public. With it, every ballot anywhere. let cancelled = false; void (async () => { try { @@ -69,7 +69,7 @@ export default function VoterHistory() { phase: e.phase, date: e.lastVoteAt, referenceNumber: e.referenceNumber, - nullifier: e.nullifier, + tag: e.tag, }))); } finally { if (!cancelled) setLoading(false); @@ -141,12 +141,12 @@ export default function VoterHistory() { const handleExport = () => { const csv = [ - ['Election', 'Date', 'Reference', 'Nullifier'].join(','), + ['Election', 'Date', 'Reference', 'Ballot tag'].join(','), ...rows.map(v => [ `"${v.electionTitle}"`, v.date.toLocaleDateString(), v.referenceNumber, - v.nullifier, + v.tag, ].join(',')), ].join('\n'); const blob = new Blob([csv], { type: 'text/csv' }); diff --git a/frontend/src/pages/voter/ZkProofGeneration.tsx b/frontend/src/pages/voter/ZkProofGeneration.tsx index 532d96b..7c5094e 100644 --- a/frontend/src/pages/voter/ZkProofGeneration.tsx +++ b/frontend/src/pages/voter/ZkProofGeneration.tsx @@ -76,7 +76,7 @@ export default function ZkProofGeneration() { electionId: id, referenceNumber: result.referenceNumber, txHash: result.txHash, - nullifier: result.nullifier.toString(), + tag: result.tag.toString(), }, replace: true, }); diff --git a/frontend/src/types/snarkjs.d.ts b/frontend/src/types/snarkjs.d.ts new file mode 100644 index 0000000..9a60b95 --- /dev/null +++ b/frontend/src/types/snarkjs.d.ts @@ -0,0 +1,23 @@ +/** + * The slice of snarkjs this app uses. The package ships no types, and the + * community ones lag its API; this declares exactly what is called, so a + * change there fails the typecheck here instead of at runtime. + */ +declare module "snarkjs" { + export interface Groth16Proof { + pi_a: string[]; + pi_b: string[][]; + pi_c: string[]; + protocol: string; + curve: string; + } + + export const groth16: { + fullProve( + input: Record, + wasm: string | Uint8Array, + zkey: string | Uint8Array, + ): Promise<{ proof: Groth16Proof; publicSignals: string[] }>; + verify(vkey: unknown, publicSignals: string[], proof: Groth16Proof): Promise; + }; +} diff --git a/scripts-tally/package-lock.json b/scripts-tally/package-lock.json index 9e0d7fa..9e910c4 100644 --- a/scripts-tally/package-lock.json +++ b/scripts-tally/package-lock.json @@ -11,12 +11,17 @@ "dependencies": { "dotenv": "^18.0.2", "ethers": "^6.17.0", - "paillier-bigint": "^3.4.3" + "poseidon-lite": "^0.3.0", + "snarkjs": "^0.7.5" }, "devDependencies": { "@types/node": "^26.6.2", "tsx": "^4.23.15", "typescript": "^7.0.2" + }, + "engines": { + "node": ">=24", + "npm": ">=11" } }, "node_modules/@adraffy/ens-normalize": { @@ -467,6 +472,22 @@ "node": ">=18" } }, + "node_modules/@iden3/bigarray": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@iden3/bigarray/-/bigarray-0.0.2.tgz", + "integrity": "sha512-Xzdyxqm1bOFF6pdIsiHLLl3HkSLjbhqJHVyqaTxXt3RqXBEnmsUmEW47H7VOi/ak7TdkRpNkxjyK5Zbkm+y52g==", + "license": "GPL-3.0" + }, + "node_modules/@iden3/binfileutils": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@iden3/binfileutils/-/binfileutils-0.0.12.tgz", + "integrity": "sha512-naAmzuDufRIcoNfQ1d99d7hGHufLA3wZSibtr4dMe6ZeiOPV1KwOZWTJ1YVz4HbaWlpDuzVU72dS4ATQS4PXBQ==", + "license": "GPL-3.0", + "dependencies": { + "fastfile": "0.0.20", + "ffjavascript": "^0.3.0" + } + }, "node_modules/@noble/curves": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", @@ -847,13 +868,65 @@ "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", "license": "MIT" }, - "node_modules/bigint-crypto-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/bigint-crypto-utils/-/bigint-crypto-utils-3.3.0.tgz", - "integrity": "sha512-jOTSb+drvEDxEq6OuUybOAv/xxoh3cuYRUIPyu8sSHQNKM303UQ2R1DAo45o1AkcIXw6fzbaFI1+xGGdaXs2lg==", + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/bfj": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/bfj/-/bfj-7.1.0.tgz", + "integrity": "sha512-I6MMLkn+anzNdCUp9hMRyui1HaNEUCco50lxbvNS4+EyXg8lN3nJ48PjPWtbH8UVS9CuMoaKE9U2V3l29DaRQw==", "license": "MIT", + "dependencies": { + "bluebird": "^3.7.2", + "check-types": "^11.2.3", + "hoopy": "^0.1.4", + "jsonpath": "^1.1.1", + "tryer": "^1.0.1" + }, "engines": { - "node": ">=14.0.0" + "node": ">= 8.0.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/check-types": { + "version": "11.2.3", + "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.3.tgz", + "integrity": "sha512-+67P1GkJRaxQD6PKK0Et9DhwQB+vGg3PM5+aavopCpZT1lj9jeqfvpgTLAWErNj8qApkkmXlu/Ug74kmhagkXg==", + "license": "MIT" + }, + "node_modules/circom_runtime": { + "version": "0.1.28", + "resolved": "https://registry.npmjs.org/circom_runtime/-/circom_runtime-0.1.28.tgz", + "integrity": "sha512-ACagpQ7zBRLKDl5xRZ4KpmYIcZDUjOiNRuxvXLqhnnlLSVY1Dbvh73TI853nqoR0oEbihtWmMSjgc5f+pXf/jQ==", + "license": "Apache-2.0", + "dependencies": { + "ffjavascript": "0.3.1" + }, + "bin": { + "calcwit": "calcwit.js" } }, "node_modules/dotenv": { @@ -871,6 +944,21 @@ "url": "https://dotenvx.com" } }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -913,6 +1001,70 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esprima": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.2.5.tgz", + "integrity": "sha512-S9VbPDU0adFErpDai3qDkjq8+G05ONtKzcyNrPKg/ZKa+tf879nX2KexNU95b31UoTJjRLInNBHHHjFPoCd7lQ==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ethers": { "version": "6.17.0", "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", @@ -956,6 +1108,32 @@ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "license": "MIT" }, + "node_modules/fastfile": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/fastfile/-/fastfile-0.0.20.tgz", + "integrity": "sha512-r5ZDbgImvVWCP0lA/cGNgQcZqR+aYdFx3u+CtJqUE510pBUVGMn4ulL/iRTI4tACTYsNJ736uzFxEBXesPAktA==", + "license": "GPL-3.0" + }, + "node_modules/ffjavascript": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/ffjavascript/-/ffjavascript-0.3.1.tgz", + "integrity": "sha512-4PbK1WYodQtuF47D4pRI5KUg3Q392vuP5WjE1THSnceHdXwU3ijaoS0OqxTzLknCtz4Z2TtABzkBdBdMn3B/Aw==", + "license": "GPL-3.0", + "dependencies": { + "wasmbuilder": "0.0.16", + "wasmcurves": "0.2.2", + "web-worker": "1.2.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -971,18 +1149,153 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/paillier-bigint": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/paillier-bigint/-/paillier-bigint-3.4.3.tgz", - "integrity": "sha512-82otV7H/gBiQeYjsmc/Zo029GoJDnAFyoMWQMsUgfya50W8dYRVgvUR/lHO0yrS7MfMwgE8fCeEIBF0IucLYFw==", + "node_modules/hoopy": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", + "integrity": "sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==", "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "license": "Apache-2.0", "dependencies": { - "bigint-crypto-utils": "^3.3.0" + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" }, "engines": { - "node": ">=12" + "node": ">=10" + } + }, + "node_modules/jsonpath": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.3.0.tgz", + "integrity": "sha512-0kjkYHJBkAy50Z5QzArZ7udmvxrJzkpKYW27fiF//BrMY7TQibYLl+FYIXN2BiYmwMIVzSfD8aDRj6IzgBX2/w==", + "license": "MIT", + "dependencies": { + "esprima": "1.2.5", + "static-eval": "2.1.1", + "underscore": "1.13.6" + } + }, + "node_modules/logplease": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/logplease/-/logplease-1.2.15.tgz", + "integrity": "sha512-jLlHnlsPSJjpwUfcNyUxXCl33AYg2cHhIf9QhGL2T4iPT0XPB+xP1LRKFPgIg1M/sg9kAJvy94w9CzBNrfnstA==", + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/poseidon-lite": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/poseidon-lite/-/poseidon-lite-0.3.0.tgz", + "integrity": "sha512-ilJj4MIve4uBEG7SrtPqUUNkvpJ/pLVbndxa0WvebcQqeIhe+h72JR4g0EvwchUzm9sOQDlOjiDNmRAgxNZl4A==", + "license": "MIT" + }, + "node_modules/r1csfile": { + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/r1csfile/-/r1csfile-0.0.48.tgz", + "integrity": "sha512-kHRkKUJNaor31l05f2+RFzvcH5XSa7OfEfd/l4hzjte6NL6fjRkSMfZ4BjySW9wmfdwPOtq3mXurzPvPGEf5Tw==", + "license": "GPL-3.0", + "dependencies": { + "@iden3/bigarray": "0.0.2", + "@iden3/binfileutils": "0.0.12", + "fastfile": "0.0.20", + "ffjavascript": "0.3.0" } }, + "node_modules/r1csfile/node_modules/ffjavascript": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/ffjavascript/-/ffjavascript-0.3.0.tgz", + "integrity": "sha512-l7sR5kmU3gRwDy8g0Z2tYBXy5ttmafRPFOqY7S6af5cq51JqJWt5eQ/lSR/rs2wQNbDYaYlQr5O+OSUf/oMLoQ==", + "license": "GPL-3.0", + "dependencies": { + "wasmbuilder": "0.0.16", + "wasmcurves": "0.2.2", + "web-worker": "1.2.0" + } + }, + "node_modules/snarkjs": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/snarkjs/-/snarkjs-0.7.6.tgz", + "integrity": "sha512-4uH1xA5JzVU5jaaWS2fXej3+RC6L5Erhr6INTJtUA27du4Elbh4VXCeeRjB4QiwL6N6y7SNKePw5prTxyEf4Zg==", + "license": "GPL-3.0", + "dependencies": { + "@iden3/binfileutils": "0.0.12", + "@noble/hashes": "^1.7.1", + "bfj": "^7.0.2", + "circom_runtime": "0.1.28", + "ejs": "^3.1.6", + "fastfile": "0.0.20", + "ffjavascript": "0.3.1", + "logplease": "^1.2.15", + "r1csfile": "0.0.48" + }, + "bin": { + "snarkjs": "build/cli.cjs" + } + }, + "node_modules/snarkjs/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/static-eval": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz", + "integrity": "sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA==", + "license": "MIT", + "dependencies": { + "escodegen": "^2.1.0" + } + }, + "node_modules/tryer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", + "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", @@ -1043,6 +1356,12 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "node_modules/underscore": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", + "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", @@ -1050,6 +1369,27 @@ "dev": true, "license": "MIT" }, + "node_modules/wasmbuilder": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/wasmbuilder/-/wasmbuilder-0.0.16.tgz", + "integrity": "sha512-Qx3lEFqaVvp1cEYW7Bfi+ebRJrOiwz2Ieu7ZG2l7YyeSJIok/reEQCQCuicj/Y32ITIJuGIM9xZQppGx5LrQdA==", + "license": "GPL-3.0" + }, + "node_modules/wasmcurves": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/wasmcurves/-/wasmcurves-0.2.2.tgz", + "integrity": "sha512-JRY908NkmKjFl4ytnTu5ED6AwPD+8VJ9oc94kdq7h5bIwbj0L4TDJ69mG+2aLs2SoCmGfqIesMWTEJjtYsoQXQ==", + "license": "GPL-3.0", + "dependencies": { + "wasmbuilder": "0.0.16" + } + }, + "node_modules/web-worker": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.2.0.tgz", + "integrity": "sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA==", + "license": "Apache-2.0" + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", diff --git a/scripts-tally/package.json b/scripts-tally/package.json index 2ea4d39..6b5bd52 100644 --- a/scripts-tally/package.json +++ b/scripts-tally/package.json @@ -1,7 +1,7 @@ { "name": "votain-tally", "version": "1.0.0", - "description": "Off-chain homomorphic tally + IPFS publication for Votain elections", + "description": "Off-chain tally, proof and audit for Votain elections", "type": "module", "license": "AGPL-3.0-only", "engines": { @@ -15,7 +15,8 @@ "dependencies": { "dotenv": "^18.0.2", "ethers": "^6.17.0", - "paillier-bigint": "^3.4.3" + "poseidon-lite": "^0.3.0", + "snarkjs": "^0.7.5" }, "devDependencies": { "@types/node": "^26.6.2", diff --git a/scripts-tally/tally-votes.ts b/scripts-tally/tally-votes.ts index c15c1b6..04bfb70 100644 --- a/scripts-tally/tally-votes.ts +++ b/scripts-tally/tally-votes.ts @@ -1,19 +1,21 @@ /** * Votain off-chain tally, and the auditor's check of a published one. * - * TALLY (the organizer, holding the key): - * 1. Read every VoteCast event from an election. - * 2. Coercion resistance: keep only the HIGHEST nonce per nullifier. - * 3. Prove the tally with `tallyProof.ts`, the same code the browser runs: - * valid ballots are summed and the sum opened to the counters; invalid - * ones are excluded and each opened in public. - * 4. Apply the privacy quorum (the contract's own), determine the outcome. - * 5. Write an auditable JSON, optionally pin it to IPFS (Pinata) and publish - * the results with their proof via ElectionV4.publishResults. + * TALLY (the organizer, holding the keys): + * 1. Read the election's aggregate: the sum the contract kept of every + * ballot, votes and cancellations, which encrypts each voter's LAST vote. + * 2. Decrypt it with the tally keys to the counts, and prove the decryption + * with the tally circuit, the same one the browser runs. + * 3. Apply the privacy quorum (the contract's own): above it, publish the + * counts; below it, prove only that and void the election. + * 4. Write an auditable JSON, optionally pin it to IPFS (Pinata) and send the + * result with its proof to the election, which verifies it on chain. * * VERIFY (anyone, with no key): - * Reads the published counters and `TallyProofPublished`, and checks them - * against every VoteCast on chain. Exits non-zero if they do not match. + * Re-adds every BallotCast on chain and checks the sum is the aggregate the + * result was proved against, then re-verifies the published proof against + * the tally circuit's verification key, independently of the chain's own + * verifier. Exits non-zero if either fails. * * Usage: * npm run tally -- [--publish] [--pin] @@ -21,71 +23,61 @@ * * Env (.env): * RPC_URL Amoy RPC - * PAILLIER_KEY_FILE (tally) path to the JSON keypair exported by the organizer + * TALLY_KEY_FILE (tally) key file exported by the organizer from the election screen + * CIRCUITS_DIR where tally_s.{wasm,zkey,vkey.json} live (default: the frontend's copy) * ORGANIZER_PRIVATE_KEY (only with --publish) EOA that owns the election * PINATA_JWT (only with --pin) Pinata API JWT */ import "dotenv/config"; import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { Contract, JsonRpcProvider, Wallet } from "ethers"; -import { PublicKey, PrivateKey } from "paillier-bigint"; -import { - decodeTallyProof, - encodeTallyProof, - finalBallots, - proveTally, - verifyTally, - type FinalBallot, -} from "../frontend/src/lib/tallyProof.js"; - -/** - * Fallback only. The base is fixed into every ballot at encryption time, so an - * election is decoded in the base IT recorded (metadataJson.counterBase), and - * this is what the ones created before that field existed used. - */ -const LEGACY_COUNTER_BASE = 1_000_000n; - -/** Reads the base one election was encoded with. */ -function counterBaseOf(metadataJson: string): bigint { - try { - const meta = JSON.parse(metadataJson) as { counterBase?: string }; - return meta.counterBase ? BigInt(meta.counterBase) : LEGACY_COUNTER_BASE; - } catch { - return LEGACY_COUNTER_BASE; - } -} +import { poseidon3 } from "poseidon-lite"; +import * as snarkjs from "snarkjs"; +import { + aggregate as addBallots, + BASE, + decryptTally, + equals, + keysHash, + multiply, + solidityProof, + tallyCircuitInputs, + unflattenPoints, + type Ballot, + type Point, + type SolidityProof, +} from "../frontend/src/lib/ballotCrypto.js"; + +const CIRCUITS_DIR = + process.env.CIRCUITS_DIR ?? join(dirname(fileURLToPath(import.meta.url)), "..", "frontend", "public", "circuits"); + +const PROOF = "(uint256[2] a, uint256[2][2] b, uint256[2] c)"; const ELECTION_ABI = [ "function numOptions() view returns (uint256)", "function votingType() view returns (uint8)", "function thresholdValue() view returns (uint256)", - "function paillierPublicKey() view returns (string)", - "function metadataJson() view returns (string)", "function name() view returns (string)", "function privacyQuorum() view returns (uint256)", "function resultsPublished() view returns (bool)", "function tally() view returns (uint256[])", - "function publishResults(string ipfsCid, uint256[] tallyResults, uint256 invalidBallots, bytes tallyProof)", - "event VoteCast(uint256 indexed nullifier, bytes voteCiphertext, uint256 nonce, uint256 timestamp)", - "event TallyProofPublished(uint256 invalidBallots, bytes proof)", + "function voters() view returns (uint256)", + "function voteCount() view returns (uint256)", + "function circuitSlots() view returns (uint256)", + "function tallyKeys() view returns (uint256[])", + "function aggregate() view returns (uint256[2] a, uint256[] b)", + `function publishResults(string ipfsCid, uint256[] tallyResults, ${PROOF} proof)`, + `function voidBelowQuorum(uint256 votersBelow, ${PROOF} proof)`, + "event BallotCast(uint256 indexed tag, uint256 index, uint256 leaf, uint256[2] voteA, uint256[] voteB, uint256[2] cancelA, uint256[] cancelB, uint256 timestamp)", + "event ResultsPublished(string ipfsCid, uint256[] tally, uint8 outcome, uint256 winnerIndex)", ]; -interface SerializedKeyPair { - publicKey: { n: string; g: string }; - privateKey: { lambda: string; mu: string }; -} - -const fromHex = (s: string): bigint => BigInt(s); - -function restoreKeyPair(s: SerializedKeyPair): { publicKey: PublicKey; privateKey: PrivateKey } { - const publicKey = new PublicKey(fromHex(s.publicKey.n), fromHex(s.publicKey.g)); - const privateKey = new PrivateKey(fromHex(s.privateKey.lambda), fromHex(s.privateKey.mu), publicKey); - return { publicKey, privateKey }; -} - const VOTING_TYPE = ["SIMPLE_PLURALITY", "ABSOLUTE_MAJORITY", "SUPERMAJORITY_TWO_THIRDS", "WITNESS_THRESHOLD"]; -function determineOutcome( +/** Mirrors `ElectionV4._computeOutcome`, rule for rule. The contract's answer is the binding one. */ +export function determineOutcome( votingType: number, counts: bigint[], numOptions: number, @@ -98,9 +90,21 @@ function determineOutcome( return { outcome: counts[0] >= threshold ? "APPROVED" : "REJECTED" }; } if (votingType === 2) { - // SUPERMAJORITY_TWO_THIRDS - if (totalCast === 0n) return { outcome: "REJECTED" }; - return { outcome: counts[0] * 3n >= totalCast * 2n ? "APPROVED" : "REJECTED" }; + // SUPERMAJORITY_TWO_THIRDS: two options are a motion, more are candidates + // of whom the leader must still clear two thirds. + if (totalCast === 0n) return { outcome: numOptions === 2 ? "REJECTED" : "THRESHOLD_NOT_MET" }; + if (numOptions === 2) return { outcome: counts[0] * 3n >= totalCast * 2n ? "APPROVED" : "REJECTED" }; + let lead = 0n; + let leadIdx = 0; + for (let i = 0; i < numOptions; i++) { + if (counts[i] > lead) { + lead = counts[i]; + leadIdx = i; + } + } + return lead * 3n >= totalCast * 2n + ? { outcome: "APPROVED", winnerIndex: leadIdx } + : { outcome: "THRESHOLD_NOT_MET" }; } // Plurality-style: leading candidate (blank excluded) @@ -176,21 +180,45 @@ async function queryAll( } } -/** The surviving ballot of every voter, from every VoteCast on chain. */ -async function readFinalBallots( - election: Contract, - provider: JsonRpcProvider, -): Promise<{ ballots: FinalBallot[]; cast: number }> { - // An auditor must see every ballot or the tally is wrong, so a truncated log - // range is never acceptable here. - const events = await queryAll(election, provider, election.filters.VoteCast()); - const ballots = finalBallots( - events.map(e => { - const args = (e as unknown as { args: { nullifier: bigint; voteCiphertext: string; nonce: bigint } }).args; - return { nullifier: args.nullifier, nonce: args.nonce, ciphertext: args.voteCiphertext }; - }), - ); - return { ballots, cast: events.length }; +/** What the election states about its tally: keys, sizes and the aggregate. */ +async function readElection(election: Contract) { + const [numOptions, slots, keys, aggregate, quorum, ballots] = await Promise.all([ + election.numOptions() as Promise, + election.circuitSlots() as Promise, + election.tallyKeys() as Promise, + election.aggregate() as Promise<[bigint[], bigint[]]>, + election.privacyQuorum() as Promise, + election.voteCount() as Promise, + ]); + return { + numOptions: Number(numOptions), + circuitSlots: Number(slots), + keys: unflattenPoints(keys), + total: { a: [aggregate[0][0], aggregate[0][1]] as Point, b: unflattenPoints(aggregate[1]) }, + quorum, + ballots: Number(ballots), + }; +} + +/** The tally circuit's public signals, in its order, as the contract builds them. */ +function tallySignals( + state: Awaited>, + counts: readonly bigint[], + voters: bigint, + publish: boolean, +): string[] { + const n = state.circuitSlots; + const pad = (i: number): Point => state.total.b[i] ?? [0n, 1n]; + return [ + ...Array.from({ length: n }, (_, i) => counts[i] ?? 0n), + voters, + keysHash(poseidon3, state.keys, n), + BigInt(state.keys.length), + ...state.total.a, + ...Array.from({ length: n }, (_, i) => pad(i)).flat(), + state.quorum, + publish ? 1n : 0n, + ].map(String); } /** `--verify`: checks a published result against the chain, with no key. */ @@ -199,42 +227,71 @@ async function verifyPublished(address: string, election: Contract, provider: Js console.error("No results have been published for this election yet."); process.exit(2); } - const [pkJson, metadataJson, published] = await Promise.all([ - election.paillierPublicKey() as Promise, - election.metadataJson() as Promise, + const state = await readElection(election); + const [published, voters] = await Promise.all([ election.tally() as Promise, + election.voters() as Promise, ]); - const proofs = await queryAll(election, provider, election.filters.TallyProofPublished()); - if (proofs.length === 0) { - console.error("Published without a proof (a contract from before tally proofs): nothing to verify."); + + // 1. The aggregate is the sum of the ballots everyone can see. + const events = await queryAll(election, provider, election.filters.BallotCast()); + const ballots: Ballot[] = events.map(e => { + const args = (e as unknown as { args: Record }).args; + const a = (key: string) => args[key] as bigint[]; + return { + tag: args.tag as bigint, + voteA: [a("voteA")[0], a("voteA")[1]], + voteB: unflattenPoints(a("voteB")), + cancelA: [a("cancelA")[0], a("cancelA")[1]], + cancelB: unflattenPoints(a("cancelB")), + }; + }); + const recomputed = addBallots(ballots, state.keys.length); + const sums = equals(recomputed.a, state.total.a) && recomputed.b.every((p, i) => equals(p, state.total.b[i])); + if (!sums) { + console.error(`${address}: DOES NOT VERIFY: the ${ballots.length} ballots on chain do not add up to the aggregate`); + process.exit(4); + } + + // 2. The published proof, re-verified here against the circuit's own key. + const [event] = await queryAll(election, provider, election.filters.ResultsPublished()); + const tx = await provider.getTransaction(event.transactionHash); + const call = tx && election.interface.parseTransaction(tx); + if (!call || call.name !== "publishResults") { + console.error(`${address}: the result was not published by a direct publishResults call; checked the sums only.`); process.exit(3); } - const args = (proofs[0] as unknown as { args: { invalidBallots: bigint; proof: string } }).args; - const { ballots } = await readFinalBallots(election, provider); - const { n, g } = JSON.parse(pkJson) as { n: string; g: string }; - - const verdict = verifyTally({ - publicKey: { n: BigInt(n), g: BigInt(g) }, - ballots, - counts: [...published], - invalidBallots: args.invalidBallots, - proof: decodeTallyProof(args.proof), - base: counterBaseOf(metadataJson), - }); - if (!verdict.ok) { - console.error(`${address}: DOES NOT VERIFY: ${verdict.reason}`); + const proof = call.args.proof as SolidityProof; + const vkey = JSON.parse(readFileSync(join(CIRCUITS_DIR, `tally_s${state.circuitSlots}.vkey.json`), "utf-8")); + const snark = { + pi_a: [proof.a[0], proof.a[1], 1n].map(String), + pi_b: [[proof.b[0][1], proof.b[0][0]], [proof.b[1][1], proof.b[1][0]], [1n, 0n]].map(p => p.map(String)), + pi_c: [proof.c[0], proof.c[1], 1n].map(String), + protocol: "groth16", + curve: "bn128", + }; + if (!(await snarkjs.groth16.verify(vkey, tallySignals(state, [...published], voters, true), snark))) { + console.error(`${address}: DOES NOT VERIFY: the published proof fails against the tally circuit's key`); process.exit(4); } console.log( - `${address}: verified. ${verdict.validBallots} valid ballots add up to the published counts ` + - `[${published.join(", ")}]; ${verdict.invalidBallots} excluded, each opened in the proof.`, + `${address}: verified. The ${ballots.length} ballots on chain add up to the aggregate, and the ` + + `published counts [${published.join(", ")}] are proved to be its decryption: ${voters} voters.`, ); } +/** Reads a key file exported from the election screen (`tallyKey.ts` writes it). */ +function readKeyFile(path: string): { secrets: bigint[]; keys: Point[] } { + const file = JSON.parse(readFileSync(path, "utf-8")) as { version?: number; secrets?: string[] }; + if (file.version !== 2 || !Array.isArray(file.secrets)) throw new Error(`${path} is not a Votain tally key file`); + const secrets = file.secrets.map(s => BigInt(s)); + return { secrets, keys: secrets.map(x => multiply(BASE, x)) }; +} + async function main() { const address = process.argv[2]; if (!address) { - console.error("Usage: npm run tally -- [--publish] [--pin]"); + console.error("Usage: npm run tally -- [--publish] [--pin] | --verify"); process.exit(1); } const doPublish = process.argv.includes("--publish"); @@ -242,74 +299,71 @@ async function main() { const doVerify = process.argv.includes("--verify"); // Tenderly serves eth_getLogs over the full block range. drpc and publicnode - // cap it at 10000 blocks; queryAllVoteCast() below falls back to windowed - // queries so the audit still completes on those endpoints. - const provider = new JsonRpcProvider( - process.env.RPC_URL ?? "https://polygon-amoy.gateway.tenderly.co", - ); + // cap it at 10000 blocks; queryAll() falls back to windowed queries so the + // audit still completes on those endpoints. + const provider = new JsonRpcProvider(process.env.RPC_URL ?? "https://polygon-amoy.gateway.tenderly.co"); const election = new Contract(address, ELECTION_ABI, provider); if (doVerify) return verifyPublished(address, election, provider); - const [numOptionsBn, votingTypeBn, thresholdBn, pkJson, name] = await Promise.all([ - election.numOptions(), - election.votingType(), - election.thresholdValue(), - election.paillierPublicKey(), - election.name(), + const [votingTypeBn, thresholdBn, name] = await Promise.all([ + election.votingType() as Promise, + election.thresholdValue() as Promise, + election.name() as Promise, ]); - const numOptions = Number(numOptionsBn); const votingType = Number(votingTypeBn); - const totalSlots = numOptions + 1; // + blank vote - - // 1-2. All votes, and each voter's last one. - const { ballots, cast } = await readFinalBallots(election, provider); - console.log(`Found ${cast} VoteCast events, ${ballots.length} voters after coercion-resistance dedup`); - - const metadataJson: string = await election.metadataJson(); + const state = await readElection(election); - // The contract's own privacy quorum, which is the one publishResults - // enforces; the copy in the metadata is for display and could disagree. - const privacyQuorum = Number(await election.privacyQuorum()); - if (ballots.length < privacyQuorum) { - console.error(`Privacy quorum not met (${ballots.length} < ${privacyQuorum}). Election should be VOIDED.`); - process.exit(2); + // 1-2. Decrypt the aggregate with keys that must be this election's own. + const keyFile = readKeyFile(process.env.TALLY_KEY_FILE ?? "tally-key.json"); + if (keyFile.keys.length !== state.keys.length || !keyFile.keys.every((k, i) => equals(k, state.keys[i]))) { + throw new Error("the key file does not belong to this election's on-chain keys"); } + const counts = decryptTally(keyFile.secrets, state.total, state.ballots); + const voters = counts.reduce((a, b) => a + b, 0n); + const quorumMet = voters >= state.quorum; + console.log(`${state.ballots} ballots cast by ${voters} voters`); + + // 3. Prove, in whichever mode the quorum allows. + const inputs = tallyCircuitInputs({ + poseidon3, + circuitSlots: state.circuitSlots, + keys: state.keys, + secrets: keyFile.secrets, + counts, + aggregate: state.total, + quorum: state.quorum, + publish: quorumMet, + }); + const base = join(CIRCUITS_DIR, `tally_s${state.circuitSlots}`); + const { proof: snark } = await snarkjs.groth16.fullProve(inputs, `${base}.wasm`, `${base}.zkey`); + const proof = solidityProof(snark); - const { publicKey, privateKey } = restoreKeyPair( - JSON.parse(readFileSync(process.env.PAILLIER_KEY_FILE ?? "paillier-key.json", "utf-8")) as SerializedKeyPair, - ); - // The key file must match the on-chain public key, or every opening fails. - if ("0x" + publicKey.n.toString(16) !== JSON.parse(pkJson).n) { - throw new Error("the key file does not belong to this election's on-chain public key"); - } + const signer = () => { + if (!process.env.ORGANIZER_PRIVATE_KEY) throw new Error("ORGANIZER_PRIVATE_KEY not set"); + return election.connect(new Wallet(process.env.ORGANIZER_PRIVATE_KEY, provider)) as Contract; + }; - // 3. Prove. - const proven = proveTally({ - publicKey: { n: publicKey.n, g: publicKey.g }, - lambda: privateKey.lambda, - decrypt: c => privateKey.decrypt(c), - ballots, - slots: totalSlots, - base: counterBaseOf(metadataJson), - }); - const counts = proven.counts; - const proofBytes = encodeTallyProof(proven.proof); - if (proven.invalidBallots > 0) { - console.log(`${proven.invalidBallots} ballot(s) excluded as invalid, each opened in the proof`); + if (!quorumMet) { + console.error(`Privacy quorum not met (${voters} < ${state.quorum}): the counts stay secret.`); + if (doPublish) { + const receipt = await (await signer().voidBelowQuorum(voters, proof)).wait(); + console.log(`Voided below the quorum, with the proof: ${receipt.hash}`); + } else { + console.error("Run with --publish to void it with the proof."); + } + process.exit(2); } - // 5. Outcome - const { outcome, winnerIndex } = determineOutcome(votingType, counts, numOptions, thresholdBn); + const { outcome, winnerIndex } = determineOutcome(votingType, counts, state.numOptions, thresholdBn); - // 6. Auditable JSON + // 4. Auditable JSON const audit = { - election: { address, name, votingType: VOTING_TYPE[votingType], numOptions }, + election: { address, name, votingType: VOTING_TYPE[votingType], numOptions: state.numOptions }, computedAt: new Date().toISOString(), - totalVotesCast: cast, - uniqueVoters: ballots.length, - invalidBallots: proven.invalidBallots, + ballotsCast: state.ballots, + voters: Number(voters), tally: counts.map(c => Number(c)), - tallyProof: proofBytes, + proof: { a: proof.a.map(String), b: proof.b.map(p => p.map(String)), c: proof.c.map(String) }, outcome, winnerIndex, }; @@ -319,29 +373,17 @@ async function main() { console.log(`\nTally:`, audit.tally, `→ ${outcome}${winnerIndex !== undefined ? ` (option ${winnerIndex})` : ""}`); console.log(`Audit trail written to ${outFile}`); - // Optional: pin to IPFS let cid = ""; if (doPin) { cid = await pinToIpfs(audit); console.log(`Pinned to IPFS: ${cid}`); } - // Optional: publish on-chain if (doPublish) { - if (!process.env.ORGANIZER_PRIVATE_KEY) throw new Error("ORGANIZER_PRIVATE_KEY not set"); - const signer = new Wallet(process.env.ORGANIZER_PRIVATE_KEY, provider); - const writer = election.connect(signer) as unknown as { - publishResults: ( - cid: string, - tally: bigint[], - invalidBallots: bigint, - proof: string, - ) => Promise<{ wait: () => Promise<{ hash: string }> }>; - }; - const tx = await writer.publishResults(cid, counts, BigInt(proven.invalidBallots), proofBytes); - const receipt = await tx.wait(); + const receipt = await (await signer().publishResults(cid, counts, proof)).wait(); console.log(`Results published on-chain: ${receipt.hash}`); } + process.exit(0); } -await main(); +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) await main(); diff --git a/scripts-tally/tsconfig.json b/scripts-tally/tsconfig.json index ca70ed3..1629db1 100644 --- a/scripts-tally/tsconfig.json +++ b/scripts-tally/tsconfig.json @@ -9,5 +9,6 @@ "noEmit": true, "types": ["node"] }, - "include": ["*.ts"] + // The snarkjs declaration is the frontend's: one description of the one API. + "include": ["*.ts", "../frontend/src/types/snarkjs.d.ts"] } From 7f1ca9231d74f11ca138db41b75f4faebd42045f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:38:46 +0000 Subject: [PATCH 12/32] docs, ci: circuits job, hidden re-votes and honest limits CI gains a circuits job (witness tests, cached build seeded from the Hermez phase 1) whose outputs the contracts job needs, with REQUIRE_CIRCUITS so a missing build fails rather than skips. README, architecture, glossary and the module guides describe the new scheme and its limits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi --- .github/workflows/checks.yml | 75 ++++++++++- CONTRIBUTING.md | 7 +- README.md | 55 ++++++-- docs/dev/architecture.md | 242 +++++++++++++++++++---------------- docs/dev/glossary.md | 8 +- docs/dev/state.md | 14 +- 6 files changed, 264 insertions(+), 137 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index b9ef23f..0513734 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -115,8 +115,68 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max + # ── the ballot and tally circuits ────────────────────────────────────────── + # + # The witness tests say the circuits accept what they should and refuse what + # they should; the build produces the verifiers the contracts compile against + # and the proving files the contracts' E2E suite proves with. + # + # CACHED ON THE SOURCES, because the trusted setup is slow: a phase 1 of 2^17 + # takes a quarter of an hour when generated. On a cache miss it is fetched + # from the Hermez ceremony instead, which is also what a real deployment + # should start from, and only generated if that download fails. Either way + # the phase 2 here is the public DEVELOPMENT one, which is fine for CI and + # never deployable (deploy.ts refuses it off the local chain). + circuits: + name: circuits + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + cache-dependency-path: circuits/package-lock.json + - run: npm ci + working-directory: circuits + - run: npm test + working-directory: circuits + - uses: actions/cache@v4 + id: build + with: + path: | + circuits/build + contracts/contracts/verifiers + frontend/public/circuits + key: circuits-${{ hashFiles('circuits/src/**', 'circuits/scripts/build.mjs', 'circuits/package-lock.json') }} + - name: Phase 1 from the Hermez ceremony + if: steps.build.outputs.cache-hit != 'true' + working-directory: circuits + run: | + mkdir -p build + curl -fsSL --retry 3 -o build/hermez17.ptau \ + https://storage.googleapis.com/zkevm/ptau/powersOfTau28_hez_final_17.ptau \ + && echo "PTAU=$PWD/build/hermez17.ptau" >> "$GITHUB_ENV" \ + || echo "download failed: the build generates its own phase 1" + - if: steps.build.outputs.cache-hit != 'true' + run: npm run build + working-directory: circuits + timeout-minutes: 40 + - uses: actions/upload-artifact@v4 + with: + name: circuits + # The verifiers and what proving needs; not the phase 1 files. + path: | + circuits/build/*.zkey + circuits/build/*_js/*.wasm + circuits/build/*.vkey.json + circuits/build/manifest.json + contracts/contracts/verifiers + retention-days: 3 + contracts: name: contracts + needs: circuits runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -125,14 +185,21 @@ jobs: node-version: 24 cache: npm cache-dependency-path: contracts/package-lock.json + - uses: actions/download-artifact@v4 + with: + name: circuits + path: . - run: npm ci working-directory: contracts - run: npm run build working-directory: contracts # The slowest of the three by a wide margin: it proves Groth16 end to - # end rather than mocking it. + # end rather than mocking it. REQUIRE_CIRCUITS turns a missing build + # into a failure rather than a quietly skipped suite. - run: npm test working-directory: contracts + env: + REQUIRE_CIRCUITS: "1" # THE SCRIPTS ARE NOT CHECKED BY EITHER OF THE TWO ABOVE, which is easy # to miss: `hardhat compile` builds Solidity and `hardhat test` only @@ -171,8 +238,8 @@ jobs: # Those stay manual, in `deploy-heroku.yml` and `deploy-phala.yml`, because # deciding what the world talks to should be somebody's decision. # THE TALLY CLI WAS IN NO JOB AT ALL, which is a strange thing to leave out: - # it is the auditor's path, the one that reads the encrypted ballots, adds - # them up and publishes the result with an IPFS receipt. Nothing here builds + # it is the auditor's path, the one that decrypts and proves the tally, + # publishes it with an IPFS receipt and checks a published one. Nothing here builds # or runs it, so a dependency bump could break the official count and every # tick would stay green. # @@ -190,7 +257,7 @@ jobs: cache-dependency-path: scripts-tally/package-lock.json - run: npm ci working-directory: scripts-tally - # Type-checks `tallyProof.ts` too, which this CLI shares with the + # Type-checks `ballotCrypto.ts` too, which this CLI shares with the # frontend: a change there that breaks the auditor's path fails here. - run: npm run typecheck working-directory: scripts-tally diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e9ceb2..06277f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,16 +8,17 @@ Votain is an **end-to-end verifiable, anonymous, coercion-resistant voting dApp* - `docs/dev/state.md`. Current milestone, pinned versions, technical debt. - `docs/dev/conventions.md`. Binding development conventions. - `docs/dev/architecture.md`. On-chain / off-chain / frontend / tally diagram. -- `docs/dev/glossary.md`. Semaphore, nullifier, SD-JWT, ERC-4337, Paillier, TEE, etc. +- `docs/dev/glossary.md`. Semaphore, nullifier, SD-JWT, ElGamal, Groth16, TEE, etc. ## Monorepo structure ``` votain/ -├── contracts/ # Solidity 0.8.37 + Hardhat 3 + Semaphore V4 +├── contracts/ # Solidity 0.8.37 + Hardhat 3 ├── backend/ # Node.js Express SD-JWT issuer + World ID v4 ├── frontend/ # React 19 + Vite + Tailwind 4 + ethers v6 -├── scripts-tally/ # off-chain tally with paillier-bigint (to create) +├── circuits/ # ballot + tally circuits, trusted setup, generated verifiers +├── scripts-tally/ # off-chain tally, proof and --verify (auditor CLI) ├── docs/ │ ├── PLAN.md # iterative plan (source of truth for progress) │ ├── dev/ # developer documentation diff --git a/README.md b/README.md index 873596c..63259af 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ Votain is a Bachelor's thesis project (TFG) demonstrating how modern cryptograph | Property | How it is achieved | |----------|--------------------| -| **End-to-end verifiable** | Every vote is a Paillier ciphertext stored on-chain, and every result is published with a proof of correct decryption: the randomness that opens the sum of the valid ballots to exactly the published counts, plus a public opening of each ballot excluded as invalid. Anyone can check it against the chain with no key, in the results screen or with `npm run tally -- --verify`, and a stuffed or malformed ballot is excluded in the open rather than counted. The auditor CLI additionally pins a result JSON to IPFS; the in-app tally does not pin yet | +| **End-to-end verifiable** | Every ballot is an ElGamal ciphertext on chain with a zero-knowledge proof that it is exactly one valid option, so a stuffed or malformed ballot never gets in. The election adds every ballot into an aggregate itself, and publishes a result only with a proof that the counts are that aggregate's decryption. Anyone can re-add the ballots and re-check the proof with no key, in the results screen or with `npm run tally -- --verify`. The auditor CLI additionally pins a result JSON to IPFS; the in-app tally does not pin yet | | **Anonymous** | Semaphore V4 zero-knowledge proofs hide voter identity inside the eligible-voters group | -| **Coercion resistant** | Per-nullifier nonce lets a coerced voter override a prior ballot later, and only the highest-nonce vote counts. The limit, stated plainly: re-votes are public per nullifier, so a coercer who learns a voter's nullifier can see THAT they re-voted, though not how. Hiding that needs a MACI-style key change and is future work. Sponsored re-votes are spaced by a one-hour cooldown, so no voter can drain the organizer's gas tank | +| **Coercion resistant** | A coerced voter can vote again, and nobody watching can tell they did. Every ballot silently cancels the voter's previous one, proved in zero knowledge without saying which, and a first vote and a re-vote look the same on chain. One ballot per voter per hour bounds how fast anyone can spend the organizer's gas, through tags that link nothing. The limit, stated plainly: the organizer's key could open a single ballot and see that it replaced one, though never whose it is (see [Known limits](#known-limits)) | | **Sybil resistant** | World ID v4 proof of personhood under one action fixed by the server, so one person holds one platform identity. Elections that require a document also refuse the same passport or ID card twice, whatever World ID account it arrives with | | **Eligible without identifying** | Age and nationality come from the chip in a passport or national identity card, read over NFC by the [Self](https://self.xyz) app and proved in zero knowledge. The document never leaves the phone, and age is asked as a predicate: the answer is "over 18", never a date of birth | | **Gasless for voters** | Ballots are relayed through `ElectionPaymaster`, reimbursed from the organizer's own gas tank; voters never hold tokens | @@ -72,13 +72,14 @@ User (recovery phrase + World ID) └── relayed tx ─────── Polygon Amoy ElectionFactory · ElectionV4 ElectionPaymaster · PlatformRegistry - Semaphore V4 Verifier + Ballot + tally Groth16 verifiers + ElectionV4 keeps the ElGamal aggregate │ ▼ - Tally (Paillier homomorphic sum): in-app in the - browser, or the off-chain CLI (auditor path) + Tally (decrypt the aggregate, prove it): in-app + in the browser, or the off-chain CLI → Result JSON pinned on IPFS (Pinata, CLI) - → publishResults(cid, tally, proof) on-chain + → publishResults(cid, counts, proof), verified on chain ``` Full diagram in [`docs/dev/architecture.md`](docs/dev/architecture.md). @@ -87,10 +88,11 @@ Full diagram in [`docs/dev/architecture.md`](docs/dev/architecture.md). ``` votain/ -├── contracts/ # Solidity 0.8.37 + Hardhat 3 + Semaphore V4 +├── circuits/ # ballot + tally circuits (circom), trusted setup, verifier generation +├── contracts/ # Solidity 0.8.37 + Hardhat 3 ├── backend/ # Node.js Express SD-JWT issuer (target: Phala TEE) ├── frontend/ # React 19 + Vite (deployed: IPFS / 4EVERLAND) -├── scripts-tally/ # off-chain Paillier tally + IPFS publication (auditor CLI) +├── scripts-tally/ # off-chain tally, proof, IPFS publication and --verify (auditor CLI) ├── docs/ │ ├── PLAN.md # iterative milestone plan (source of truth) │ ├── dev/ # developer documentation @@ -104,20 +106,30 @@ votain/ ## Tech stack -**Contracts**: Solidity 0.8.37, Hardhat 3, ethers v6, OpenZeppelin 5, [`@semaphore-protocol/contracts`](https://semaphore.pse.dev/) 4.x. +**Circuits**: circom 2 (WASM build), circomlib, Groth16 via [`snarkjs`](https://github.com/iden3/snarkjs); Semaphore V4 identities and LeanIMT trees. + +**Contracts**: Solidity 0.8.37, Hardhat 3, ethers v6, OpenZeppelin 5, LeanIMT and Poseidon from [zk-kit](https://github.com/privacy-scaling-explorations/zk-kit). **Backend**: Node.js 24, Express 5, [`@sd-jwt/core`](https://github.com/openwallet-foundation-labs/sd-jwt-js) (EdDSA / Ed25519), [`@worldcoin/idkit-core`](https://docs.world.org/) v4, [`@selfxyz/core`](https://self.xyz) for document-backed eligibility, tsx. **Two identity sources, two different jobs.** World ID answers *are you a distinct human*, once per election scope. Self answers *do you meet this election's rules* (a minimum age, a nationality inside or outside a named set) from a real document, without disclosing the values behind the answers. Only `backend/src/eligibility/self.ts` knows Self exists: the rest of `eligibility/` speaks in policies and attestations, so an EUDI Wallet connector can be added beside it without touching them. -**Frontend**: React 19, Vite (Rolldown), Tailwind CSS 4, [`@semaphore-protocol/{identity,group,proof}`](https://semaphore.pse.dev/), [`paillier-bigint`](https://github.com/juanelas/paillier-bigint), [`@worldcoin/idkit`](https://docs.world.org/), i18next (13 languages), framer-motion. +**Frontend**: React 19, Vite (Rolldown), Tailwind CSS 4, [`@semaphore-protocol/{identity,group}`](https://semaphore.pse.dev/), `snarkjs` (proving in the browser), [`@worldcoin/idkit`](https://docs.world.org/), i18next (13 languages), framer-motion. Pinned versions live in [`docs/dev/state.md`](docs/dev/state.md). ## Quick start ```bash -# Contracts (185 tests, Hardhat 3) +# Circuits: witness tests, then the trusted setup and the generated verifiers. +# The first build spends ~15 minutes on a local phase 1 (cached afterwards); +# PTAU= skips it. +cd circuits +npm install +npm test +npm run build + +# Contracts (Hardhat 3; the E2E suite proves for real, so build circuits first) cd contracts npm install npx hardhat test @@ -162,6 +174,25 @@ auditor CLI. See [`docs/PLAN.md`](docs/PLAN.md) for the plan and **891 tests pass**: 185 on the contracts, 144 on the backend, 562 on the frontend. +## Known limits + +Stated here so that nobody reads their absence as a guarantee; the full list is +in [`docs/dev/architecture.md`](docs/dev/architecture.md#known-limits-stated-plainly). + +- **The organizer's key could open a single ballot.** The app only ever decrypts + the aggregate, but the keys could open any one ballot, and its cancellation + shows whether it replaced an earlier vote. Never whose ballot it is, nor which + it replaced. Splitting the key among trustees (threshold decryption) would + remove the power; on ElGamal that is now a protocol to add, not a primitive to + change. +- **The trusted setup is the deployment's to run.** The default circuit build + uses a public development ceremony, which `deploy.ts` refuses off the local + chain. +- **One backend instance, no indexer.** Rate limits and sessions live in process + memory, and browsers read events straight from the RPC in windows. Fine at + thesis scale; a public platform needs a shared store and an indexer. +- **The issuer sees network metadata** of the requests it relays. + ## Documentation - [`CONTRIBUTING.md`](CONTRIBUTING.md). Developer guide, monorepo conventions. @@ -171,7 +202,7 @@ auditor CLI. See [`docs/PLAN.md`](docs/PLAN.md) for the plan and - [`docs/dev/conventions.md`](docs/dev/conventions.md). Coding conventions, color tokens, i18n keys. - [`docs/dev/deployment.md`](docs/dev/deployment.md). CI pipeline, the two backend hosts, and how to verify either from outside. - [`docs/dev/world-id-listing.md`](docs/dev/world-id-listing.md). App store text for the World ID Developer Portal, in all thirteen languages. -- [`docs/dev/glossary.md`](docs/dev/glossary.md). Semaphore, nullifier, SD-JWT, relaying, Paillier, TEE. +- [`docs/dev/glossary.md`](docs/dev/glossary.md). Semaphore, nullifier, SD-JWT, relaying, ElGamal, TEE. - [`docs/dev/state.md`](docs/dev/state.md). Current state, pinned versions, technical debt. - Per-module guides: [`contracts/DEVELOPMENT.md`](contracts/DEVELOPMENT.md), [`backend/DEVELOPMENT.md`](backend/DEVELOPMENT.md), [`frontend/DEVELOPMENT.md`](frontend/DEVELOPMENT.md). diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 599fa01..1862018 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -6,7 +6,7 @@ ┌─────────────────────────────────────────────────────────────────┐ │ USER │ │ Browser (IPFS gateway / 4EVERLAND CDN) │ -│ React 19 + Vite + Tailwind 4 + Semaphore v4 + Paillier │ +│ React 19 + Vite + Tailwind 4 + Semaphore v4 + ElGamal/Groth16 │ └─────────────────┬───────────────────────────┬───────────────────┘ │ World ID QR + relayed tx │ RPC (read only) ▼ ▼ @@ -22,21 +22,25 @@ │ POST /relay/vote │───▶│ relayed through │ │ (unauthenticated) │ │ ElectionPaymaster │ │ /attestation │ │ │ -│ → Intel TDX report │ │ Semaphore V4 Verifier │ -└─────────────────────────┘ └──────────────────────────────────┘ +│ → Intel TDX report │ │ Ballot + Tally Groth16 verifiers │ +└─────────────────────────┘ │ (one pair per circuit size) │ + │ ElectionV4 keeps the running │ + │ ElGamal aggregate of all ballots │ + └──────────────────────────────────┘ │ - │ queryFilter(VoteCast) + │ aggregate() ▼ ┌──────────────────────────────────┐ │ TALLY (in-app or off-chain CLI) │ │ in browser · scripts-tally/ │ │ │ - │ 1. Filter VoteCast by nonce │ - │ 2. Sum ciphertexts (Paillier) │ - │ 3. Decrypt w/ passkey-derived key│ + │ 1. Read the on-chain aggregate │ + │ 2. Decrypt w/ wallet-derived keys│ + │ 3. Prove it (tally circuit) │ │ 4. Generate auditable JSON │ │ 5. Pin to IPFS (CLI only) │ - │ 6. publishResults(+ proof) │ + │ 6. publishResults(+ proof), │ + │ verified by the contract │ └──────────────────────────────────┘ ``` @@ -78,29 +82,38 @@ 4. VOTING Frontend: user selects candidate - → Paillier encrypt(candidate_index, publicKey) - → ZK Proof: Semaphore V4 circuit - input: identity, group, scope (electionId), signal (encrypted_vote) - output: proof, nullifier, merkleRoot - → UserOp: ElectionV4.castVote(nullifier, nonce, proof, voteCiphertext) - -> ElectionPaymaster relays the call and reimburses the relayer from the - organizer gas tank, in the same transaction - → Tx on Amoy + → find the voter's place in their own chain of ballots: tags + Poseidon(TAG, secret, scope, k) for k = 0, 1, ... until one is missing; + only the voter's secret can compute them + → exponential ElGamal on Baby Jubjub, one key per option: + vote A = r·G, B_i = v_i·G + r·H_i + cancel A' = -A_prev + s·G, B'_i = -B_prev_i + s·H_i (of nothing, on a first ballot) + → ZK proof, ballot circuit (circuits/src/ballot.circom), in the browser: + on the roll, exactly one option, cancels the voter's OWN previous ballot + (by membership in the ballots tree, not by pointing at it), tags are theirs + → ElectionPaymaster.relayVote(election, ballot, proof): the relayer is + reimbursed from the organizer's gas tank in the same transaction + → ElectionV4 checks roots, tag, epoch tag and proof, files the ballot as a + leaf of the ballots tree and adds vote AND cancellation to its aggregate 5. COERCION RESISTANCE (vote change) - Same nullifier, nonce+1 - → Contract only counts the vote with the highest nonce per nullifier - → Coerced user can vote again "under pressure" without revealing the previous vote + The same pipeline: the next tag, a cancellation of the last ballot. + → The aggregate telescopes each voter's chain to their LAST vote + → A first ballot and a re-vote look the same on chain and share no public + value, so nobody watching can tell that a voter changed their mind + → One ballot per voter per epoch (an hour) bounds the gas one voter can + spend, through an epoch tag that links nothing across epochs 6. TALLY (two interchangeable paths, same pipeline) a) In-app (default): organizer opens the election → Compute tally → Publish. - Decryption runs in the browser; the Paillier key is DERIVED on demand from - the organizer's passkey PRF (public per-election keyNonce in metadata), + Decryption and proving run in the browser; the tally keys are DERIVED on + demand from a wallet signature (public per-election keyNonce in metadata), never stored at rest. Publishing is a wallet-signed publishResults tx. - b) Offline CLI (auditor path): scripts-tally/tally-votes.ts recomputes the - same result independently and pins the audit JSON to IPFS (Pinata). - Both: keep max nonce per nullifier → homomorphic sum → decrypt → if - votes < Privacy Quorum → Voided; else publishResults(cid, tally, invalid, proof). + b) Offline CLI (auditor path): scripts-tally/tally-votes.ts does the same + from a key file and pins the audit JSON to IPFS (Pinata). + Both: aggregate() → decrypt → prove (tally circuit) → if voters < Privacy + Quorum → voidBelowQuorum(voters, proof), counts never revealed; else + publishResults(cid, counts, proof). The contract verifies the proof. ``` ## Module breakdown @@ -109,8 +122,11 @@ | Contract | Purpose | |----------|---------| -| `ElectionV4.sol` | Single election: Semaphore V4, Paillier, coercion resistance | -| `ElectionFactory.sol` | ElectionV4 deployment, paymaster fund management | +| `ElectionV4.sol` | Single election: members tree, ballots tree, ElGamal aggregate, tags and epoch tags, tally proof checked on publish | +| `ElectionFactory.sol` | Election creation through `ElectionDeployer`, picks the verifier pair for the option count, paymaster fund management | +| `ElectionDeployer.sol` | Holds ElectionV4's creation code, which no longer fits inside the factory | +| `BabyJubJub.sol` | Point addition in extended coordinates, for the on-chain aggregate | +| `verifiers/*.sol` | GENERATED by `circuits/scripts/build.mjs`: one ballot and one tally Groth16 verifier per circuit size | | `ElectionPaymaster.sol` | Relay hub and gas tank: sponsors every enrolment and ballot, with gas reserved per election so it cannot be withdrawn from under the voters | | `PlatformRegistry.sol` | Identity commitment registry with owner access control, plus two sealed stores it cannot read: the identity vault and each voter's preferences | @@ -261,7 +277,7 @@ they are being offered. Closing enrolment or voting early reads as an ordinary convenience until you notice what the organizer can see while doing it. `memberCount` and -`distinctVoters` are public and rise in real time, so the roll can be cut off at +`voteCount` are public and rise in real time, so the roll can be cut off at the moment it suits and the vote ended at the moment the result does, and neither leaves any trace of why. Ballot secrecy does not help here: nobody needs to know who voted, only how many. @@ -391,7 +407,7 @@ Upcoming → Enrolling → PendingVote → Active → Tallying → Closed (Appro ## Voting types -Each election declares one of four winner-determination rules. The cryptographic primitive (Paillier homomorphic sum of Semaphore-proof-anchored ciphertexts) is identical across all four; only the post-decryption check differs. +Each election declares one of four winner-determination rules. The cryptographic pipeline (a proved ElGamal aggregate of every ballot, decrypted with a proof) is identical across all four; only the post-decryption check differs. | `VotingType` | Approval rule | Example | |--------------|---------------|---------| @@ -416,52 +432,47 @@ The frontend onboarding flow lets the user pick the source. The backend SD-JWT i ### What the tally guarantees, and what it does not -Three separate things protect a published result. They are worth keeping apart, -because each stops a different attack and none of them stops all three. - -**The counters have to account for every ballot.** A ballot for option i is the -Paillier plaintext B^i, so summing ciphertexts sums per-option counters in base -B, and every ballot adds exactly one to exactly one counter. `decryptTally` -therefore checks that the unpacked counters total the ballots that went in. The -check it replaces only looked for a leftover past the LAST counter, which an -overflow from option 0 into option 1 never produces: the tally came out quietly -wrong with nothing to show for it. B is a trillion, and `MAX_OPTIONS` in the -contract is 50 because 51 counters of 12 digits is as much as a 2048-bit modulus -can carry. The base is recorded per election in `metadataJson.counterBase`, -since it is fixed into each ballot when it is encrypted and cannot be changed -underneath an election already running. - -**A result may not rest on too few voters.** `publishResults` reverts below -`privacyQuorum`, leaving `markVoided` as the only way the election can end. -This bounds PUBLICATION, not knowledge: the organizer holds the decryption key -and the ciphertexts are public, so they can always compute the result privately -and no contract can prevent it. Only threshold decryption, splitting the key so -no single party can decrypt alone, would. Note also that a small tally leaks by -itself, whoever decrypts it: three voters and a 3-0 result identifies everyone. - -**Anyone can check the totals without a key.** The results screen compares the -published counters against `distinctVoters`, which the contract counts and -anyone can read, so invented or dropped ballots are visible to every reader -rather than only to someone who runs the CLI. +> Rewritten on 2026-09-24, when ballots moved from Paillier to exponential +> ElGamal with proofs. The earlier version described a result that could be +> posted and then contradicted; this one describes a result that cannot be +> posted wrong at all. + +**Every ballot is proved well formed before it is accepted.** The ballot +circuit shows each ballot encrypts exactly one of the election's options, and +that its cancellation undoes this voter's own previous ballot and nothing else. +A stuffed or malformed ballot is not excluded at tally time any more: it never +reaches the chain. + +**The contract keeps the aggregate itself.** Every vote and every cancellation +is added, as Baby Jubjub points, into a running sum in `ElectionV4`. Because +each re-vote cancels the one before, the sum encrypts each voter's LAST vote, +and nothing needs deduplicating. Anyone can re-add the `BallotCast` events and +land on the same point, which is what the results screen and `--verify` do. + +**A result is published only with a proof of its decryption.** The tally circuit +proves the counts are the decryption of that aggregate under the election's +keys, and `publishResults` verifies it. There is no step left where a wrong +count could be posted and merely contradicted afterwards. + +**A result may not rest on too few voters, and the counts of one that does stay +secret.** The number of voters is the sum of the counts, so it too is proved. +Below `privacyQuorum` the organizer calls `voidBelowQuorum` with the tally +circuit in its other mode, which proves only that fewer voted than the quorum. +This bounds PUBLICATION, not knowledge: the organizer holds the keys and can +always decrypt privately. Note also that a small tally leaks by itself, whoever +decrypts it: three voters and a 3-0 result identifies everyone. #### Not guaranteed -The contract does NOT require the published counters to sum to -`distinctVoters`, though every honest tally does. It never validates a -ciphertext, only the membership proof around one, so an enrolled voter can cast -arbitrary bytes as their ballot; with that rule in place, one voter could make -any election permanently unpublishable. It would also buy little, since an -organizer inclined to falsify moves votes BETWEEN options and leaves the total -alone. - -Closing that needs two pieces this project does not have. A **ballot validity -proof**, so a ciphertext is provably one of the allowed plaintexts, which is -what would make the sum rule safe to enforce. And a **proof of correct -decryption**, so the organizer must show the published numbers really are the -decryption of the aggregate anyone can recompute from chain. With both, a false -tally could not be posted at all. Without them it can be posted and then -contradicted, which is weaker, and saying so is more useful than implying -otherwise. +- **What the key holder could do.** The app decrypts the aggregate only, but + every ballot is encrypted under the same keys, so whoever holds them could + open a single ballot: its choice and, from its cancellation, whether it + replaced an earlier vote and for which option. Not whose it is, nor which + earlier ballot it cancels. Threshold decryption, below, is what removes it. +- **The trusted setup.** Groth16 needs one per circuit, and whoever knows its + randomness can forge proofs. The build's default is a public DEVELOPMENT + ceremony that `deploy.ts` refuses off the local chain; a deployment runs its + own phase 2 over a public phase 1 (`circuits/scripts/build.mjs`). #### Threshold decryption, and why it is not here @@ -473,21 +484,16 @@ partials combine into the result, so the complete key never exists anywhere. That is what would make "not even the organizer can see the result early" a property rather than a promise. -It is not here, and the reason is the primitive. With Paillier the hard part is -not decrypting, it is GENERATING: the trustees would have to jointly produce an -RSA modulus n = p*q without any of them learning p or q, which is a serious -multi-party protocol. The usual shortcut is a trusted dealer who generates the -key, splits it and deletes the original, but then that party held the whole key -for a moment, which is precisely the assumption threshold decryption exists to -remove. - -Comparable systems avoid this by not choosing Paillier. Helios and Belenios use -exponential ElGamal, where distributed key generation is almost free: each -trustee picks x_i and publishes g^x_i, and the public key is the product. No -factoring, no dealer. The cost is that decryption ends in a discrete log, which -is fine because the result is bounded by the number of voters and can simply be -searched. The honest summary is that the clean route to a threshold ran through -choosing ElGamal at the start. +It is not here yet, but the primitive is no longer what stops it. With Paillier +the hard part was GENERATING the key: the trustees would have had to jointly +produce an RSA modulus without any of them learning its factors. Votain now +uses exponential ElGamal, like Helios and Belenios, where distributed key +generation is almost free: each trustee picks x_i and publishes x_i·G, and the +key is the sum. Decryption already ends in a bounded discrete log. What is left +is the protocol around it: trustees posting partial decryptions of the +aggregate with a proof each (a Chaum-Pedersen equality, or one small circuit), +and the contract combining them. See the proposal in the pull request that +introduced this scheme. There is also a product cost, separate from the cryptography. Trustees are people who hold shares and have to turn up to decrypt. An organizer creating an @@ -516,13 +522,11 @@ it anyone decrypts INDIVIDUAL ballots rather than just the total. That is strictly worse than what we have. The homomorphic sum is a different matter: adding ciphertexts needs only the -public key, so the contract could maintain the aggregate itself. With a 2048-bit -key the modulus n^2 is 4096 bits and the EVM can only do that through the MODEXP -precompile, at real cost per ballot. It would also buy very little. That sum is -already deterministic and public: anyone can read the VoteCast events and -recompute the identical aggregate on their own machine. Putting it on chain -moves where a public computation happens without adding any assurance. The trust -gap is not in the addition. It is entirely in the decryption. +public keys, and since ballots moved to ElGamal on Baby Jubjub the contract does +maintain the aggregate itself, at a few mulmods per point. That is what lets the +tally proof be checked against a value the contract computed rather than one the +organizer supplied. The decryption is still the organizer's, and the trust gap +left is in who holds the keys, not in the arithmetic. Timelock encryption (encrypting the key to a future drand round, say) looks like it closes this: nobody could decrypt before the deadline, everybody could after. @@ -653,6 +657,10 @@ leaves it. The shape that follows: +> Written when ballots were Paillier ciphertexts. With ElGamal the shape is the +> same and cheaper: the enclave would hold the x_i, read the on-chain aggregate +> and produce the tally proof itself. + - At election creation a Lit Action generates the Paillier keypair INSIDE the enclave, returns only the public `n` and `g`, and stores the private key sealed under an access condition that reads this election's `phase()` on @@ -736,11 +744,12 @@ the mechanism, the authorisation that replaced the registry lookup, and the measurement: eight commitments across thirty-eight elections became sixty-nine, each in one. -The ballot was never linkable and still is not. The nullifier in `VoteCast` is -`poseidon2(scope, secret)`, and every election is created with its own random -scope (31 random bytes, `lib/organizer.ts`), so two ballots cast by the same -voter in two elections share nothing, and no ballot can be tied back to the -commitment that enrolled. +The ballot was never linkable and still is not, and since 2026-09-24 not even +to the same voter's other ballots. Each ballot is filed under a tag, +`Poseidon(TAG, secret, scope, k)`, different for every step k of the voter's +chain, and the scope is derived from the election's own address and chain, so +two ballots cast by the same voter, in one election or in two, share nothing, +and no ballot can be tied back to the commitment that enrolled. The honest summary, which is now shorter than it was: the chain shows that someone took part in a given election, never what they said, never that two @@ -1560,18 +1569,29 @@ member, and takes it off the public record. The pre-Amoy review closed what could be closed in code. These remain, and are written down so that nobody reads their absence as a guarantee. -- **Re-votes are visible per nullifier.** Only the last ballot counts, and a - coerced voter can always override later, but `VoteCast` carries the - nullifier and nonce, so someone who learns a voter's nullifier can see that - they re-voted (never how). Hiding it needs a MACI-style design where ballots - are encrypted to a coordinator and keys can be changed silently. -- **Sponsored re-votes wait an hour.** `ElectionPaymaster.REVOTE_COOLDOWN` - bounds how fast one enrolled voter can spend the organizer's tank. The honest - override is delayed, never refused, and a voter can still submit a re-vote - themselves, unsponsored, at any time. -- **The organizer can read individual ballots.** Whoever holds the Paillier key - can decrypt any ciphertext on chain; the tally proof reveals nothing new, but - it does not take that away. Threshold decryption would. +- **The key holder could open single ballots.** The app only ever decrypts the + aggregate, but the tally keys could open any one ballot, showing its choice + and, from its cancellation, whether it replaced an earlier vote. Never whose + ballot it is or which one it replaced: nothing on chain says either. Threshold + decryption, now cheap on ElGamal, would take the power away. +- **Re-votes are hidden from everyone else.** A first ballot and a re-vote look + the same and share no public value. A coercer who watches the chain, or holds + the voter's receipt, cannot tell that the voter voted again. One who holds + the voter's SECRET can, since they can compute the voter's tags; that is the + same as holding the voter's identity. +- **Two ballots an hour at most.** Epoch tags bound how fast one voter can spend + the organizer's tank: a proof may name this epoch or the last, so two in any + hour, and the app uses one. A coerced voter's override is delayed, never + refused. +- **The trusted setup is the deployment's to run.** The default build uses a + public development ceremony, refused off the local chain. A deployment runs + its own phase 2 (`CEREMONY_ENTROPY`, better a multi-party contribution) over + a public phase 1 (`PTAU`), and publishes the verifiers and proving files. +- **The proving files are large.** Around 50 MB per zkey at the default sizes, + fetched once per device and served from `VITE_CIRCUITS_URL`. Proving takes + seconds on a laptop and longer on an old phone. +- **An election holds at most 2^20 members and 2^20 ballots**, the depth the + circuits are compiled for (`TreeFull` past that). - **The issuer sees network metadata.** `/relay/vote` carries no session, but a voter reaching it from the same address as their authenticated requests can be correlated by an operator who logs addresses. The server does not log them; diff --git a/docs/dev/glossary.md b/docs/dev/glossary.md index c933e36..48bb879 100644 --- a/docs/dev/glossary.md +++ b/docs/dev/glossary.md @@ -16,7 +16,13 @@ **Merkle Tree**: data structure used by Semaphore for the set of enrolled voters. The root is public; each voter proves membership without revealing their leaf. -**Paillier Homomorphic Encryption**: partially homomorphic (additive) encryption scheme. Allows summing ciphertexts without decrypting: Enc(a) · Enc(b) = Enc(a+b). Votain encrypts each vote; the tally decrypts the sum. +**Exponential ElGamal**: additively homomorphic encryption: a message m is encrypted as (r·G, m·G + r·H), so adding ciphertexts adds messages, and decryption ends in a small discrete log. Votain encrypts each vote this way on the Baby Jubjub curve, one key per option, and the election adds every ballot into one aggregate that the tally decrypts. Chosen over Paillier, which Votain used until 2026-09-24, because a circuit can compute on it (so a ballot can prove it cancels the voter's previous one) and because distributed key generation for it is almost free. + +**Ballot tag**: the public identifier of one ballot, Poseidon(TAG, secret, scope, k) for the k-th ballot of a voter. Only the voter can compute their tags, so only they can tell which ballots are theirs; a re-vote carries a new tag that links to nothing. + +**Groth16 / trusted setup**: the zero-knowledge proof system of the ballot and tally circuits. Each circuit needs a one-off setup whose randomness must be destroyed; whoever keeps it can forge proofs. + +**Paillier Homomorphic Encryption**: the additive scheme Votain used before ElGamal. Its keys are hard to generate among several parties, which is part of why it was replaced. ## Blockchain and Smart Contracts diff --git a/docs/dev/state.md b/docs/dev/state.md index d576a1a..84edfca 100644 --- a/docs/dev/state.md +++ b/docs/dev/state.md @@ -5,7 +5,7 @@ both user flows are wired and green; the tally runs on both paths, but only the auditor CLI pins to IPFS and the in-app path publishes an empty CID. See H5, H6 and H9 in `PLAN.md`. **Since Phase B**: 8-phase lifecycle (`UPCOMING`/`PENDING_VOTE` added to the contract -`phase()`), **in-app tally** with a Paillier key **derived from the organizer's wallet signature** +`phase()`), **in-app tally** with tally keys **derived from the organizer's wallet signature** (nothing stored at rest; CLI kept as the auditor path), organizer display-name persistence, custom dark `DatePicker`, phase-aware voter/organizer/public screens, and shared phase helpers. **This pass**: signing in on a phone. Both roles sign in by LEAVING: the voter to World App, @@ -116,9 +116,10 @@ then Phase C (H10 IPFS, H11 Phala TEE), both now done: see the deploy note above - Deploy and verify on PolygonScan Amoy. Blocked on funding the deployer key, and the single largest gap in the project: nothing here has run against a live chain. -**Done since this list was written**, verified against the tree on 2026-09-18: the official -`SemaphoreVerifierV4` is what `deploy.ts` uses off the local chain (`MockVerifier` survives -only for local runs, unless `USE_REAL_VERIFIER=true`); `cancelElection`, `closeEnrollmentEarly`, +**Done since this list was written**, verified against the tree on 2026-09-18 and updated on +2026-09-24: `deploy.ts` deploys the ballot and tally verifiers generated by `circuits/` (mock +verifiers only on a local chain with no circuits built, and a development ceremony is refused +off the local chain unless `ALLOW_INSECURE_CEREMONY=1`); `cancelElection`, `closeEnrollmentEarly`, `closeVotingEarly`, `publishResults` and `markVoided` all exist on `ElectionV4`; and so do the `VotingType` enum and `thresholdValue`. "Lock down `ElectionPaymaster.sponsorVote`" is obsolete rather than done: that function no longer exists, the paymaster relays through `relayEnroll` @@ -190,7 +191,7 @@ personhood; Self is what discloses attributes. | `@types/node` | 26.5.1 | Same: the `latest` tag lags at 22.x | **Build**: ✅ clean, no sourcemaps. -**Tests**: 537/537 unit tests in 66 files passing (Vitest, jsdom), covering the voter identity lifecycle (minting, sealing, the PRF read-back proof, rotation, adding further passkeys), the per-election identities that keep enrolments unlinkable, noticing an expired session, WalletConnect return handling, the Paillier ballot encoding, the i18n plural tables, how the lists are paged and ordered, the schedule timeline both roles read, turnout counted in people rather than ballots, the three words a new voter has to put back before their phrase is accepted, the twelve boxes they type it into on the way back, the hat following the page without fighting the header for it, the saved list holding on to a row somebody has just unsaved, and the member list naming each election once instead of once per member. Playwright E2E scaffold in `e2e/` (excluded from Vitest). +**Tests**: 537/537 unit tests in 66 files passing (Vitest, jsdom), covering the voter identity lifecycle (minting, sealing, the PRF read-back proof, rotation, adding further passkeys), the per-election identities that keep enrolments unlinkable, noticing an expired session, WalletConnect return handling, the ElGamal ballots and their aggregate, the tally keys and their file, the i18n plural tables, how the lists are paged and ordered, the schedule timeline both roles read, turnout counted in people rather than ballots, the three words a new voter has to put back before their phrase is accepted, the twelve boxes they type it into on the way back, the hat following the page without fighting the header for it, the saved list holding on to a row somebody has just unsaved, and the member list naming each election once instead of once per member. Playwright E2E scaffold in `e2e/` (excluded from Vitest). **Enrolling stopped naming the enrolled** (2026-09-16): what goes into an election's merkle tree is a commitment derived from the voter's secret and that election's address, @@ -217,7 +218,7 @@ to close. **Technical debt pending**: -- Code-splitting lazy-load for Semaphore WASM and Paillier. +- Code-splitting lazy-load for Semaphore WASM and snarkjs (the proving files are fetched only when a ballot is cast). - IP-based language auto-detect (ipapi.co) not wired yet. - `lib/semaphore.ts` is 1090 lines, 33 exports and imported by 18 files. It holds at least five separable jobs: the identity lifecycle, this device's storage policy, registry reads, @@ -279,5 +280,6 @@ The project is released under **AGPL-3.0** (was MIT until H0 cleanup). All `pack | Relay + vault | 2026-08 | ERC-4337 dropped for an own relay contract (a per-voter smart account publicly linked enrollment to ballot, and hosted paymasters cannot fund gas per organizer). Encrypted identity vault so one Semaphore identity unlocks from several passkeys, plus `rotateMember` recovery after losing them all. Session cookies now signature-checked. Contracts 66/66 (including a real-Groth16 E2E suite), backend 21/21, frontend 17/17 | | Saved elections + session hardening | 2026-09-17 | **Saved elections**: `PlatformRegistry.setPreferences`, one sealed blob per human holding a list per ROLE, AES-GCM under a key derived from the voter's own secret, so the chain stores what nobody but them can read; measured at 149 bytes for two elections and 59,368 gas a change (`scripts/e2e-preferences.ts`). Bookmark on the cards and the election page, "saved" chip in the participation band, `/organizer/saved` in both navigation bars. **Session**: `isRevoked` stopped reading the whole revocation set on every authenticated request, the cookie became `__Host-` prefixed and Secure in development, CORS is opt in, and holder binding is written up as future work in `architecture.md`. **Defects**: linking a passkey now drops the local phrase, a session with no identity is routed instead of silently failing, the gas page no longer loops, the top bar measures itself instead of guessing a breakpoint, one clear-filters control in one corner, and the candidate count no longer disappears on signing in | | Voter identity flow | 2026-09-15 | Two passes. **WalletConnect**: every organizer action works from a phone (idempotent provider, `rpcMap`, relay recovery on return, cancellation handled everywhere). **Voter identity**: registration on chain no longer rides on having a passkey, so an authenticator that cannot evaluate PRF no longer leaves a voter off the registry; the phrase modal became a two-step screen at `/voter/identity` that will not move on until the words are copied, and nothing mints a phrase outside it; recovery reuses those two steps and stopped demanding a passkey (`clearVault`); `MyDevices` became `MyPasskeys` and can link more than one; the PRF read-back proof is no longer skipped across authenticators. Contracts untouched. Backend 107/107, frontend 253/253 | +| Hidden re-votes | 2026-09-24 | Ballots moved from Paillier to exponential ElGamal on Baby Jubjub, one key per option, proved by a new ballot circuit (`circuits/`). Every ballot carries a vote and a cancellation of the voter's previous ballot, proved against a ballots tree without saying which, and is filed under a per-step tag, so a first ballot and a re-vote look alike and share nothing public. `ElectionV4` keeps the aggregate itself and publishes a result only with the tally circuit's proof of its decryption; below the quorum `voidBelowQuorum` proves only that too few voted. Epoch tags replace the per-nullifier re-vote cooldown. `ElectionDeployer` split off for code size. ABI parity test over every hand-written fragment, which found a local-relay signature left behind by the `documentTag` change | | Pre-Amoy review | 2026-09-24 | **Contracts**: no ERC-2771 forwarder (the deployer fallback could speak as any organizer); ballots capped at 512 bytes and sponsored re-votes at one per hour per nullifier; the paymaster's factory set once, its relay parameters bounded, and even the factory limited to an organizer's own elections; `markVoided` honours `cancellable`; `enrollPrivate` carries a document tag so one passport enrols once; shared `TwoStepOwnable`. **Verifiable tally**: `publishResults` takes a Paillier decryption proof (`tallyProof.ts`, shared by the browser, the CLI and the E2E suite), excludes invalid ballots in the open and checks the counters against `distinctVoters`; `TallyCheck` and `tally --verify` check it with no key. **Backend**: World ID action fixed by the server, no unverified VC claims, revocation by the human's slot, per-voter quotas on platform-paid writes, one serialized signer per key, sanitized errors and security headers. **Frontend**: identity caches keyed by master, no invented receipt, stretched phrase derivation, determinism check for tally-key wallets, paginated election list. **Tooling**: oxlint, Node 24 / npm 11 pinned, Playwright smoke test in CI | | H5–H9 (Phase B) | 2026-07 | Real integration. Contracts rewritten (on-chain Semaphore group, VotingType, lifecycle); frontend chain client (`lib/{contracts,paillier,semaphore,voting,organizer}.ts`, chain-aware hooks); backend on-chain registrar + SD + Status List 2021 + rate limiting; `scripts-tally/` homomorphic tally and IPFS pin. Live Amoy deploy pending user key. Branch `phase-b/real-integration`. **Four claims in the original entry did not survive an audit on 2026-09-18 and have been struck from it**: "94% cov" (no coverage tool is configured), "`/present`" (`@sd-jwt/present` is not a backend dependency), "locked paymaster" (it refers to `sponsorVote`, a function that no longer exists), and `zerodev.ts` + `viem`, both removed when ERC-4337 was dropped for the own relay. H9 is therefore NOT complete: the tally is real on both paths, but only the auditor CLI pins to IPFS, and the in-app path publishes an empty CID | From 5c918d7bd6e4c2a30b6030e54123d77ec7e0b893 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:02:02 +0000 Subject: [PATCH 13/32] fix: seed windows sized for proving, CLI exits after --verify, localized epoch refusal Found by running the whole stack against a local node: the seed's finished elections gave 90 seconds of voting to ballots that now take seconds each to prove; the CLI answered --verify and then hung on snarkjs's worker threads; and a second ballot within the hour, refused in the browser before proving, reached the voter as an English error string. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi --- frontend/src/lib/relay.ts | 6 ++++++ frontend/src/lib/relayErrors.test.ts | 5 +++++ scripts-tally/tally-votes.ts | 9 +++++++-- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/relay.ts b/frontend/src/lib/relay.ts index 9ac181e..716eb7e 100644 --- a/frontend/src/lib/relay.ts +++ b/frontend/src/lib/relay.ts @@ -276,6 +276,12 @@ const ERROR_MESSAGE_KEY: Record = { export function relayErrorMessage(error: unknown): string { if (error instanceof GasTankEmptyError) return i18n.t("errors.gas_tank_empty"); if (isTankEmpty(error)) return i18n.t("errors.gas_tank_empty"); + // Caught in the browser before proving (`ballot.ts`), so it never reaches the + // chain as a revert. Matched by name: importing the class here would close an + // import cycle through `semaphore.ts`. + if ((error as { name?: string } | null)?.name === "EpochAlreadyUsedError") { + return i18n.t("errors.epoch_already_cast"); + } const key = ERROR_MESSAGE_KEY[revertNameOf(error) ?? ""]; if (key) return i18n.t(key); diff --git a/frontend/src/lib/relayErrors.test.ts b/frontend/src/lib/relayErrors.test.ts index 43520a7..cf53f11 100644 --- a/frontend/src/lib/relayErrors.test.ts +++ b/frontend/src/lib/relayErrors.test.ts @@ -120,6 +120,11 @@ describe("relayErrorMessage", () => { } }); + it("explains a second ballot within the hour, caught before any proving", async () => { + const { EpochAlreadyUsedError } = await import("./ballot"); + expect(relayErrorMessage(new EpochAlreadyUsedError(new Date()))).toBe(i18n.t("errors.epoch_already_cast")); + }); + it("passes an unrecognised failure through verbatim", () => { // Losing the detail would be worse than showing it: an unknown failure is // the one case where the raw text is the only clue anybody has. diff --git a/scripts-tally/tally-votes.ts b/scripts-tally/tally-votes.ts index 04bfb70..5418d68 100644 --- a/scripts-tally/tally-votes.ts +++ b/scripts-tally/tally-votes.ts @@ -276,7 +276,7 @@ async function verifyPublished(address: string, election: Contract, provider: Js } console.log( `${address}: verified. The ${ballots.length} ballots on chain add up to the aggregate, and the ` + - `published counts [${published.join(", ")}] are proved to be its decryption: ${voters} voters.`, + `published counts [${published.join(", ")}] are proved to be its decryption: ${voters} voter${voters === 1n ? "" : "s"}.`, ); } @@ -303,7 +303,12 @@ async function main() { // audit still completes on those endpoints. const provider = new JsonRpcProvider(process.env.RPC_URL ?? "https://polygon-amoy.gateway.tenderly.co"); const election = new Contract(address, ELECTION_ABI, provider); - if (doVerify) return verifyPublished(address, election, provider); + if (doVerify) { + await verifyPublished(address, election, provider); + // snarkjs keeps its curve's worker threads alive, so without this the + // process answers and then never returns to the shell. + process.exit(0); + } const [votingTypeBn, thresholdBn, name] = await Promise.all([ election.votingType() as Promise, From 5661e247f226f5d2d344487d8f52ac11d32372c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:10:00 +0000 Subject: [PATCH 14/32] fix(seed): ballots may be absent from a spec Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi --- contracts/scripts/seed-demo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/scripts/seed-demo.ts b/contracts/scripts/seed-demo.ts index 941ebe1..3b83ec8 100644 --- a/contracts/scripts/seed-demo.ts +++ b/contracts/scripts/seed-demo.ts @@ -508,7 +508,7 @@ async function main(): Promise { // seconds to make, so a window sized for instant ballots closed before the // last of them landed. A minute per ballot is generous on any machine. if (spec.finish) { - const ballots = spec.ballots.length + (spec.revote ? 1 : 0); + const ballots = (spec.ballots?.length ?? 0) + (spec.revote ? 1 : 0); spec = { ...spec, enrollFrom: -30, enrollTo: 60, voteFrom: 60, voteTo: 120 + 60 * ballots }; } From ca486d02215fec96bed2911db809342043e46359 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:15:41 +0000 Subject: [PATCH 15/32] test(circuits): compile before the witness tests, never skip them The suite skipped itself when no build existed, and CI runs it before the build: it would have passed on every push while testing nothing. npm test now compiles the smallest size first (build.mjs --compile, seconds, no setup), and a missing build is an error rather than a skip. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi --- circuits/package.json | 2 +- circuits/scripts/build.mjs | 7 +++++++ circuits/test/circuits.test.ts | 9 ++++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/circuits/package.json b/circuits/package.json index 0774f67..4e02d5b 100644 --- a/circuits/package.json +++ b/circuits/package.json @@ -12,7 +12,7 @@ "scripts": { "build": "node scripts/build.mjs", "build:all": "node scripts/build.mjs --all", - "test": "node --import tsx --test test/*.test.ts" + "test": "node scripts/build.mjs --compile && node --import tsx --test test/*.test.ts" }, "devDependencies": { "@semaphore-protocol/identity": "^4.14.3", diff --git a/circuits/scripts/build.mjs b/circuits/scripts/build.mjs index a090311..dec34b4 100644 --- a/circuits/scripts/build.mjs +++ b/circuits/scripts/build.mjs @@ -190,6 +190,13 @@ function wrapVerifier(circuit, source, slots, publicCount) { return renamed.replace(/\}\s*$/, wrapper); } +// `--compile`: only the witness calculators of the smallest size, which is all +// the witness tests need. Seconds rather than a trusted setup. +if (process.argv.includes("--compile")) { + for (const kind of Object.keys(CIRCUITS)) compile(kind, DEFAULT_SIZES[0]); + process.exit(0); +} + const built = []; for (const slots of sizes) { built.push(await setup("ballot", slots)); diff --git a/circuits/test/circuits.test.ts b/circuits/test/circuits.test.ts index 387514d..544d240 100644 --- a/circuits/test/circuits.test.ts +++ b/circuits/test/circuits.test.ts @@ -44,7 +44,14 @@ async function witness(wasm: string, inputs: object): Promise { const accepts = (wasm: string, inputs: object) => assert.doesNotReject(witness(wasm, inputs)); const refuses = (wasm: string, inputs: object) => assert.rejects(witness(wasm, inputs)); -describe("ballot and tally circuits", { skip: !existsSync(BALLOT_WASM) || !existsSync(TALLY_WASM) }, () => { +// Never a silent skip: a suite that tests nothing and passes is worse than a +// red one. `npm test` compiles first (`build.mjs --compile`), so this only +// fires when the test file is run on its own. +for (const wasm of [BALLOT_WASM, TALLY_WASM]) { + if (!existsSync(wasm)) throw new Error(`${wasm} is missing: run \`npm test\`, which compiles the circuits first`); +} + +describe("ballot and tally circuits", () => { const scope = 777n; const epoch = 5n; let keys: Point[]; From 5c23b958e58e8125fcad3ba51b537338a5317bf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:57:37 +0000 Subject: [PATCH 16/32] ci(circuits): generate phase 1 once and cache it on its own The Hermez phase 1 now answers 403, so the runner generated its own 2^17 phase 1: 38 minutes, and the 40-minute build step timed out right after it, saving nothing. build.mjs --ptau generates only that file; CI caches it under its own key, saved as soon as it exists, so the cost is paid once and later runs go straight to the minutes-long phase 2. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi --- .github/workflows/checks.yml | 36 ++++++++++++++++++++++-------------- circuits/scripts/build.mjs | 20 +++++++++++++++----- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 0513734..b168596 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -121,12 +121,13 @@ jobs: # they should; the build produces the verifiers the contracts compile against # and the proving files the contracts' E2E suite proves with. # - # CACHED ON THE SOURCES, because the trusted setup is slow: a phase 1 of 2^17 - # takes a quarter of an hour when generated. On a cache miss it is fetched - # from the Hermez ceremony instead, which is also what a real deployment - # should start from, and only generated if that download fails. Either way - # the phase 2 here is the public DEVELOPMENT one, which is fine for CI and - # never deployable (deploy.ts refuses it off the local chain). + # CACHED IN TWO LAYERS, because the trusted setup is slow. The phase 1 of + # 2^17 takes nearly forty minutes to generate on a runner and depends on + # nothing in the repository, so it has a cache of its own, SAVED THE MOMENT + # IT EXISTS: a later step failing must not throw that work away. The rest + # (phase 2, verifiers, proving files) is cached on the circuit sources. + # The phase 2 here is the public DEVELOPMENT one, fine for CI and never + # deployable (deploy.ts refuses it off the local chain). circuits: name: circuits runs-on: ubuntu-latest @@ -149,19 +150,26 @@ jobs: contracts/contracts/verifiers frontend/public/circuits key: circuits-${{ hashFiles('circuits/src/**', 'circuits/scripts/build.mjs', 'circuits/package-lock.json') }} - - name: Phase 1 from the Hermez ceremony + - uses: actions/cache/restore@v4 + id: ptau if: steps.build.outputs.cache-hit != 'true' + with: + path: circuits/build/pot17_dev.ptau + key: ptau-dev-17-v1 + - name: Phase 1 (generated once, then cached) + if: steps.build.outputs.cache-hit != 'true' && steps.ptau.outputs.cache-hit != 'true' + run: node scripts/build.mjs --ptau 17 working-directory: circuits - run: | - mkdir -p build - curl -fsSL --retry 3 -o build/hermez17.ptau \ - https://storage.googleapis.com/zkevm/ptau/powersOfTau28_hez_final_17.ptau \ - && echo "PTAU=$PWD/build/hermez17.ptau" >> "$GITHUB_ENV" \ - || echo "download failed: the build generates its own phase 1" + timeout-minutes: 75 + - uses: actions/cache/save@v4 + if: steps.build.outputs.cache-hit != 'true' && steps.ptau.outputs.cache-hit != 'true' + with: + path: circuits/build/pot17_dev.ptau + key: ptau-dev-17-v1 - if: steps.build.outputs.cache-hit != 'true' run: npm run build working-directory: circuits - timeout-minutes: 40 + timeout-minutes: 30 - uses: actions/upload-artifact@v4 with: name: circuits diff --git a/circuits/scripts/build.mjs b/circuits/scripts/build.mjs index dec34b4..3703382 100644 --- a/circuits/scripts/build.mjs +++ b/circuits/scripts/build.mjs @@ -18,11 +18,13 @@ * contribution on top of a public powers-of-tau), publishes the resulting * verifiers with the deployment and discards the entropy. * - * PHASE 1. Generated locally unless PTAU names a public powers-of-tau file - * (for example Hermez's powersOfTau28_hez_final_17.ptau, large enough for the - * default sizes). A deployment should use one: phase 1 from a ceremony with - * many contributors, phase 2 from its own. Locally the file is generated once - * and cached in build/, which takes a quarter of an hour at 2^17. + * PHASE 1. Generated locally unless PTAU names a public powers-of-tau file of + * at least 2^17 (the Hermez or PSE perpetual ceremonies publish them; download + * one and point PTAU at it). A deployment should use one: phase 1 from a + * ceremony with many contributors, phase 2 from its own. Locally the file is + * generated once and cached in build/: a quarter of an hour at 2^17 on a laptop, + * nearer forty minutes on a CI runner, which is why CI caches it on its own + * (`--ptau 17`). * * CACHING. A size whose zkey is newer than its r1cs is not set up again, only * re-exported, so rerunning the script is cheap. `--force` redoes everything. @@ -190,6 +192,14 @@ function wrapVerifier(circuit, source, slots, publicCount) { return renamed.replace(/\}\s*$/, wrapper); } +// `--ptau `: only the local phase 1 of that size, so CI can generate it +// in a step of its own and cache it apart from everything that depends on it. +const ptauFlag = process.argv.indexOf("--ptau"); +if (ptauFlag !== -1) { + console.log(await powersOfTau(Number(process.argv[ptauFlag + 1]))); + process.exit(0); +} + // `--compile`: only the witness calculators of the smallest size, which is all // the witness tests need. Seconds rather than a trusted setup. if (process.argv.includes("--compile")) { From 9e5c022ba561c47ecb658e5ba1fc7a957a15f27e Mon Sep 17 00:00:00 2001 From: CodeInIA Date: Sun, 27 Sep 2026 21:15:23 +0200 Subject: [PATCH 17/32] fix(contracts): refuse a deployment before its first transaction The development-ceremony check ran after Poseidon, the registry, the domains and the paymaster were already deployed, and the missing-attester check after the verifiers too. On Amoy a refused deployment had paid for up to five contracts and left them orphaned. Both checks, and the missing-circuits one, now run before anything is sent. Checked against a local node posing as chain 80002: with the development ceremony it refuses at nonce 0; with ALLOW_INSECURE_CEREMONY=1 the whole non-local path deploys, Poseidon through the CREATE2 proxy included. --- contracts/scripts/deploy.ts | 95 +++++++++++++++++++++++-------------- 1 file changed, 59 insertions(+), 36 deletions(-) diff --git a/contracts/scripts/deploy.ts b/contracts/scripts/deploy.ts index e396560..d6d99aa 100644 --- a/contracts/scripts/deploy.ts +++ b/contracts/scripts/deploy.ts @@ -55,9 +55,18 @@ async function ensurePoseidon(deployer: any, name: PoseidonName): Promise { +type CircuitManifest = { ceremony: string; sizes: { kind: string; slots: number }[] }; + +/** + * Reads the circuits' manifest and refuses a build this network must not use. + * + * CALLED BEFORE THE FIRST TRANSACTION. It used to run halfway through the + * deployment, so a refused ceremony on Amoy had already paid for the registry, + * the domains and the paymaster, and left them orphaned on chain. + */ +function readCircuitManifest(isLocal: boolean): CircuitManifest | null { const manifestPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "circuits", "build", "manifest.json"); - let manifest: { ceremony: string; sizes: { kind: string; slots: number }[] } | null = null; + let manifest: CircuitManifest | null = null; try { manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); } catch { @@ -66,14 +75,7 @@ async function deployVerifiers(isLocal: boolean): Promise<{ ballot: string[]; ta if (!manifest) { if (!isLocal) throw new Error(`No circuits built (${manifestPath}): run \`npm run build\` in circuits/ first.`); - console.warn("No circuits built: deploying MOCK verifiers, which accept any proof. Local only."); - const ballot: string[] = []; - const tally: string[] = []; - for (const size of [5, 9, 17, 33, 51]) { - ballot.push(await (await (await ethers.getContractFactory("MockBallotVerifier")).deploy(size)).getAddress()); - tally.push(await (await (await ethers.getContractFactory("MockTallyVerifier")).deploy(size)).getAddress()); - } - return { ballot, tally, kind: "mock" }; + return null; } if (manifest.ceremony !== "custom" && !isLocal && process.env.ALLOW_INSECURE_CEREMONY !== "1") { @@ -83,6 +85,22 @@ async function deployVerifiers(isLocal: boolean): Promise<{ ballot: string[]; ta "set ALLOW_INSECURE_CEREMONY=1 for a throwaway testnet deployment.", ); } + return manifest; +} + +async function deployVerifiers( + manifest: CircuitManifest | null, +): Promise<{ ballot: string[]; tally: string[]; kind: string }> { + if (!manifest) { + console.warn("No circuits built: deploying MOCK verifiers, which accept any proof. Local only."); + const ballot: string[] = []; + const tally: string[] = []; + for (const size of [5, 9, 17, 33, 51]) { + ballot.push(await (await (await ethers.getContractFactory("MockBallotVerifier")).deploy(size)).getAddress()); + tally.push(await (await (await ethers.getContractFactory("MockTallyVerifier")).deploy(size)).getAddress()); + } + return { ballot, tally, kind: "mock" }; + } const sizes = [...new Set(manifest.sizes.map(s => s.slots))].sort((a, b) => a - b); const ballot: string[] = []; @@ -134,6 +152,35 @@ async function main() { console.log(`Deploying to ${networkName} (chainId ${chainId}) with account: ${deployer.address}`); + // Before anything is sent: a deployment this network refuses must cost + // nothing, rather than stop halfway with contracts already paid for. + const circuits = readCircuitManifest(isLocal); + const platformAttester = resolvePlatformAttester(); + if (platformAttester === ZERO_ADDRESS) { + const complaint = + "No platform attester: elections deployed here will enrol the OLD way, with the " + + "voter's platform commitment in every tree, which publishes which elections each " + + "person joined. Set PLATFORM_ATTESTER_ADDRESS, or put " + + "ELIGIBILITY_ATTESTER_PRIVATE_KEY in backend/.env so this script can derive it."; + + /** + * FATAL OFF THE LOCAL CHAIN, and only a warning on it. + * + * A real deployment that forgets this key still works: every screen looks + * right, voters enrol, ballots count. The only thing that changes is that + * the chain publishes who took part in what, which is precisely the thing + * nobody notices until somebody reads the chain. A warning scrolls past in + * a deploy log; this does not. + * + * The escape hatch is deliberate and has to be typed out: a chain deployed + * with no platform behind it can only use the public paths. + */ + if (!isLocal && process.env.ALLOW_PUBLIC_ENROLMENT !== "1") { + throw new Error(complaint + " Set ALLOW_PUBLIC_ENROLMENT=1 if that is genuinely intended."); + } + console.warn(complaint); + } + const libraries = { PoseidonT3: await ensurePoseidon(deployer, "PoseidonT3"), PoseidonT4: await ensurePoseidon(deployer, "PoseidonT4"), @@ -156,7 +203,7 @@ async function main() { await paymaster.waitForDeployment(); console.log("ElectionPaymaster:", await paymaster.getAddress()); - const verifiers = await deployVerifiers(isLocal); + const verifiers = await deployVerifiers(circuits); // Holds ElectionV4's creation code, which no longer fits inside the factory. const Deployer = await ethers.getContractFactory("ElectionDeployer", { libraries }); @@ -164,31 +211,7 @@ async function main() { await electionDeployer.waitForDeployment(); console.log("ElectionDeployer:", await electionDeployer.getAddress()); - const platformAttester = resolvePlatformAttester(); - if (platformAttester === ZERO_ADDRESS) { - const complaint = - "No platform attester: elections deployed here will enrol the OLD way, with the " + - "voter's platform commitment in every tree, which publishes which elections each " + - "person joined. Set PLATFORM_ATTESTER_ADDRESS, or put " + - "ELIGIBILITY_ATTESTER_PRIVATE_KEY in backend/.env so this script can derive it."; - - /** - * FATAL OFF THE LOCAL CHAIN, and only a warning on it. - * - * A real deployment that forgets this key still works: every screen looks - * right, voters enrol, ballots count. The only thing that changes is that - * the chain publishes who took part in what, which is precisely the thing - * nobody notices until somebody reads the chain. A warning scrolls past in - * a deploy log; this does not. - * - * The escape hatch is deliberate and has to be typed out: a chain deployed - * with no platform behind it can only use the public paths. - */ - if (!isLocal && process.env.ALLOW_PUBLIC_ENROLMENT !== "1") { - throw new Error(complaint + " Set ALLOW_PUBLIC_ENROLMENT=1 if that is genuinely intended."); - } - console.warn(complaint); - } else { + if (platformAttester !== ZERO_ADDRESS) { console.log("Platform attester (private enrolment):", platformAttester); } From 8870c216e74d68b285fe682b2aabb4e9b6f19642 Mon Sep 17 00:00:00 2001 From: CodeInIA Date: Sun, 27 Sep 2026 21:15:23 +0200 Subject: [PATCH 18/32] fix(circuits): compile on Windows with the native circom circom2, the WebAssembly build, cannot compile these circuits on Windows: its WASI filesystem does not see two includes of circomlib's poseidon as one file ("Duplicated callable symbol") and then cannot write its output. build.mjs also spawned its .bin shim, which is a .cmd there and fails with ENOENT before any of that. It now runs a native circom when there is one (CIRCOM, or circom on the PATH) and circom2 through node otherwise, and on Windows without a native compiler it says what to install instead of failing obscurely. The native v2.2.3 release, the version circom2 embeds, builds wasm byte-identical to CI's; the witness tests pass 8/8 on Windows with it. --- README.md | 2 ++ circuits/scripts/build.mjs | 41 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 63259af..d5776ab 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,8 @@ npm run build > **Windows / corporate network**: prepend `NODE_OPTIONS="--use-system-ca"` to any `npm install` or `npm-check-updates` command to avoid SSL chain errors. +> **Windows / circuits**: circom2, the WebAssembly compiler `npm install` brings, cannot compile these circuits on Windows. Download the native `circom-windows-amd64.exe` (v2.2.3, the same version) from the [circom releases](https://github.com/iden3/circom/releases) and put it on the PATH as `circom`, or set `CIRCOM` to its path. It builds byte-identical artefacts. WSL works too. + ## Project status Contracts, backend issuer and both user flows are written, wired together and green. diff --git a/circuits/scripts/build.mjs b/circuits/scripts/build.mjs index 3703382..1f19cc2 100644 --- a/circuits/scripts/build.mjs +++ b/circuits/scripts/build.mjs @@ -87,6 +87,42 @@ const CIRCUITS = { }, }; +/** + * The compiler to run: a native `circom` when there is one, else circom2. + * + * circom2 is the same compiler built to WebAssembly, which is what lets `npm + * install` alone build the circuits on Linux and macOS. ON WINDOWS IT CANNOT: + * its WASI filesystem layer does not see two includes of one file as the same + * file (circomlib's poseidon, reached from here and from the Merkle library, + * fails as "Duplicated callable symbol") and then cannot write its output. + * The native release of the same version compiles byte-identical artefacts. + * + * `CIRCOM` names a binary explicitly; otherwise `circom` on the PATH is used + * if it answers. Both are spawned directly, and circom2 through node rather + * than its `.bin` shim, which on Windows is a `.cmd` that execFileSync cannot + * run without a shell. + */ +function compiler() { + const candidates = [process.env.CIRCOM, "circom"].filter(Boolean); + for (const bin of candidates) { + try { + execFileSync(bin, ["--version"], { stdio: "ignore" }); + return { command: bin, prefix: [] }; + } catch { + if (bin === process.env.CIRCOM) throw new Error(`CIRCOM=${bin} does not run.`); + } + } + if (process.platform === "win32") { + throw new Error( + "Compiling the circuits on Windows needs the native circom compiler: circom2, the WebAssembly " + + "build, cannot resolve includes or write its output there. Download circom-windows-amd64.exe " + + "(v2.2.3) from https://github.com/iden3/circom/releases and put it on the PATH as circom, or " + + "point CIRCOM at it. WSL works too.", + ); + } + return { command: process.execPath, prefix: [join(ROOT, "node_modules", "circom2", "cli.js")] }; +} + function compile(kind, slots) { const circuit = CIRCUITS[kind]; const name = `${kind}_s${slots}`; @@ -95,9 +131,10 @@ function compile(kind, slots) { const r1cs = join(BUILD, `${name}.r1cs`); if (!existsSync(r1cs) || FORCE) { console.log(`compiling ${name}`); + const { command, prefix } = compiler(); execFileSync( - join(ROOT, "node_modules", ".bin", "circom2"), - [main, "--r1cs", "--wasm", "-l", join(ROOT, "node_modules"), "-l", join(ROOT, "node_modules", "circomlib", "circuits"), "-l", join(ROOT, "src"), "-o", BUILD], + command, + [...prefix, main, "--r1cs", "--wasm", "-l", join(ROOT, "node_modules"), "-l", join(ROOT, "node_modules", "circomlib", "circuits"), "-l", join(ROOT, "src"), "-o", BUILD], { stdio: "inherit" }, ); } From 9d6b644b54b3c0788f9f9e4ce4b9607a54cf0acb Mon Sep 17 00:00:00 2001 From: CodeInIA Date: Sun, 27 Sep 2026 21:15:23 +0200 Subject: [PATCH 19/32] fix(frontend): quote paid ballots at each election's circuit size "Ballots paid for" divided a reserve by one median across every relayed ballot, and most are five-slot ones. A six-option election proves with the nine-slot circuit, about 1.24M gas against 0.8M, so it was promised 124 ballots its reserve could pay about 79 of. The cost is now measured per circuit size, read from each relayed ballot's own calldata, and every screen about one election uses the size it proves with. A size nobody has voted in yet is scaled up from the largest measured one, never down. --- .../components/organizer/ElectionGasCard.tsx | 2 +- .../src/components/ui/ElectionSummary.tsx | 2 +- frontend/src/components/ui/FundingNotice.tsx | 2 +- frontend/src/hooks/useVoteCost.ts | 15 +++-- frontend/src/lib/gasNeeds.ts | 6 +- frontend/src/lib/voteCost.test.ts | 62 +++++++++++++++++-- frontend/src/lib/voteCost.ts | 52 ++++++++++++++-- .../src/pages/organizer/GasManagement.tsx | 7 ++- 8 files changed, 129 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/organizer/ElectionGasCard.tsx b/frontend/src/components/organizer/ElectionGasCard.tsx index fab0833..598a448 100644 --- a/frontend/src/components/organizer/ElectionGasCard.tsx +++ b/frontend/src/components/organizer/ElectionGasCard.tsx @@ -36,7 +36,7 @@ export function ElectionGasCard({ election }: ElectionGasCardProps) { const [amount, setAmount] = useState('0.5'); const [busy, setBusy] = useState(false); - const voteCost = useVoteCost(); + const voteCost = useVoteCost(election.candidates.length); const open = stillOpen(election); const remaining = remainingVoters(election); const covered = Math.floor(funding.reserved / voteCost.matic); diff --git a/frontend/src/components/ui/ElectionSummary.tsx b/frontend/src/components/ui/ElectionSummary.tsx index 0313484..e85b6e8 100644 --- a/frontend/src/components/ui/ElectionSummary.tsx +++ b/frontend/src/components/ui/ElectionSummary.tsx @@ -242,7 +242,7 @@ export function ElectionSchedule({ const [lastHint, setLastHint] = useState(null); if (openHint && openHint !== lastHint) setLastHint(openHint); const funding = useElectionFunding(election.contractAddress); - const voteCost = useVoteCost(); + const voteCost = useVoteCost(election.candidates.length); const reservedBallots = Math.floor(funding.reserved / voteCost.matic); const voters = votersOf(election); diff --git a/frontend/src/components/ui/FundingNotice.tsx b/frontend/src/components/ui/FundingNotice.tsx index 3676ab9..dbd3e7b 100644 --- a/frontend/src/components/ui/FundingNotice.tsx +++ b/frontend/src/components/ui/FundingNotice.tsx @@ -33,7 +33,7 @@ export function FundingNotice({ election, reserved, organizerFree, className }: const { t } = useTranslation(); // Read here rather than passed down: the answer is shared and cached, so two // components asking is one request, and a caller cannot forget to thread it. - const voteCost = useVoteCost(); + const voteCost = useVoteCost(election.candidates.length); const verdict = fundingVerdict(election, reserved, organizerFree, voteCost.matic); if (verdict.unfunded) { diff --git a/frontend/src/hooks/useVoteCost.ts b/frontend/src/hooks/useVoteCost.ts index 5af4c91..a879e42 100644 --- a/frontend/src/hooks/useVoteCost.ts +++ b/frontend/src/hooks/useVoteCost.ts @@ -11,9 +11,9 @@ * The cache is dropped on a full reload, which is the only moment the gas price * could have moved enough to matter for a figure quoted as "about". */ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { isChainConfigured } from '../lib/deployments'; -import { fetchVoteCost, ASSUMED, type VoteCost } from '../lib/voteCost'; +import { fetchVoteCost, voteCostFor, ASSUMED, type VoteCost } from '../lib/voteCost'; let shared: VoteCost | null = null; let inFlight: Promise | null = null; @@ -24,7 +24,11 @@ export function resetVoteCostCache(): void { inFlight = null; } -export function useVoteCost(): VoteCost { +/** + * `options` is an election's choice count, blank included: given, `matic` is + * what a ballot costs in THAT election's circuit rather than across all of them. + */ +export function useVoteCost(options?: number): VoteCost { const live = isChainConfigured(); const [cost, setCost] = useState(shared ?? ASSUMED); @@ -43,5 +47,8 @@ export function useVoteCost(): VoteCost { return () => { cancelled = true; }; }, [live]); - return cost; + return useMemo( + () => (options ? { ...cost, matic: voteCostFor(cost, options) } : cost), + [cost, options], + ); } diff --git a/frontend/src/lib/gasNeeds.ts b/frontend/src/lib/gasNeeds.ts index b5e0cb1..bbb4cbb 100644 --- a/frontend/src/lib/gasNeeds.ts +++ b/frontend/src/lib/gasNeeds.ts @@ -124,9 +124,11 @@ export function electionNeed( export function openNeeds( elections: Election[], reservedFor: (election: Election) => number, - voteCost: number, + /** One figure for all, or each election's own, since a ballot's cost grows with its circuit. */ + voteCost: number | ((election: Election) => number), ): ElectionNeed[] { - return elections.filter(stillOpen).map(e => electionNeed(e, reservedFor(e), voteCost)); + const costOf = typeof voteCost === 'number' ? () => voteCost : voteCost; + return elections.filter(stillOpen).map(e => electionNeed(e, reservedFor(e), costOf(e))); } export function totalShortfall(needs: ElectionNeed[]): number { diff --git a/frontend/src/lib/voteCost.test.ts b/frontend/src/lib/voteCost.test.ts index f13b6c6..4b644d6 100644 --- a/frontend/src/lib/voteCost.test.ts +++ b/frontend/src/lib/voteCost.test.ts @@ -25,7 +25,14 @@ const ENROL = '0xbbbbbbbb'; vi.mock('./contracts', () => ({ getPaymaster: () => ({ filters: { VoteSponsored: () => 'sponsored' }, - interface: { getFunction: () => ({ selector: VOTE }) }, + interface: { + getFunction: () => ({ selector: VOTE }), + // The slot count rides in the stub's calldata, where the real one reads + // it from the ballot's `voteB`: two coordinates per slot. + parseTransaction: ({ data }: { data: string }) => ({ + args: [null, { voteB: Array.from({ length: 2 * parseInt(data.slice(10), 16) }) }], + }), + }, maxGasPrice: async () => estado.maxGasPrice, maxRelayGas: async () => estado.maxRelayGas, }), @@ -45,10 +52,10 @@ vi.mock('./logs', async () => ({ queryTopicLogs: async () => [], })); -/** One relay: `units` of gas at `price`, of the given kind. */ -function relay(hash: string, units: bigint, price: bigint, selector = VOTE) { +/** One relay: `units` of gas at `price`, of the given kind and circuit size. */ +function relay(hash: string, units: bigint, price: bigint, selector = VOTE, slots = 5) { estado.eventos.push({ args: { cost: units * price }, transactionHash: hash }); - estado.txs[hash] = { data: selector + '0'.repeat(64), gasPrice: price }; + estado.txs[hash] = { data: selector + slots.toString(16).padStart(64, '0'), gasPrice: price }; } describe('fetchVoteCost', () => { @@ -137,3 +144,50 @@ describe('fetchVoteCost', () => { expect(cost.measured).toBe(true); }); }); + +describe('the cost per circuit size', () => { + beforeEach(() => { + estado.eventos = []; + estado.txs = {}; + estado.gasPrice = 1_000_000_000n; + estado.maxGasPrice = 200_000_000_000n; + estado.maxRelayGas = 2_000_000n; + vi.resetModules(); + }); + + it('measures each size apart, because a bigger circuit costs more', async () => { + // Mostly small ballots and one big one: the median across them is the + // small figure, which quoted a six-option election half as many ballots + // again as its reserve could pay for. + relay('0x1', 800_000n, 1_000_000_000n, VOTE, 5); + relay('0x2', 800_000n, 1_000_000_000n, VOTE, 5); + relay('0x3', 1_240_000n, 1_000_000_000n, VOTE, 9); + + const { fetchVoteCost, voteCostFor } = await import('./voteCost'); + const cost = await fetchVoteCost(); + + expect(cost.matic).toBeCloseTo(0.0008); + expect(voteCostFor(cost, 4)).toBeCloseTo(0.0008); + // Six options, blank included, need the nine-slot circuit. + expect(voteCostFor(cost, 6)).toBeCloseTo(0.00124); + }); + + it('scales a size nobody has voted in up from the largest measured one', async () => { + relay('0x1', 1_000_000n, 1_000_000_000n, VOTE, 9); + + const { fetchVoteCost, voteCostFor } = await import('./voteCost'); + const cost = await fetchVoteCost(); + + // Seventeen slots, never measured: never quoted as cheaper than nine. + expect(voteCostFor(cost, 17)).toBeCloseTo(0.001 * (17 / 9)); + // A smaller election than any measured is quoted at the smallest measured + // size that fits, which errs towards enough. + expect(voteCostFor(cost, 3)).toBeCloseTo(0.001); + }); + + it('keeps the platform-wide figure when no election is named', async () => { + const { voteCostFor, ASSUMED } = await import('./voteCost'); + expect(voteCostFor(ASSUMED, 6)).toBe(ASSUMED.matic); + expect(voteCostFor({ ...ASSUMED, bySlots: { 5: 1 } }, undefined)).toBe(ASSUMED.matic); + }); +}); diff --git a/frontend/src/lib/voteCost.ts b/frontend/src/lib/voteCost.ts index f77a214..ed3cb13 100644 --- a/frontend/src/lib/voteCost.ts +++ b/frontend/src/lib/voteCost.ts @@ -37,12 +37,21 @@ export const VOTE_COST_FALLBACK = 0.03; const RELAY_VOTE = "0x"; export interface VoteCost { - /** Native token per ballot, at today's gas price. */ + /** Native token per ballot, at today's gas price, across every circuit size. */ matic: number; /** False when nothing on chain could be measured and the fallback is in use. */ measured: boolean; /** How many past ballots the figure rests on. */ samples: number; + /** + * The same figure per circuit size (slots), where that size was measured. + * + * A ballot's cost grows with its circuit: a nine-slot ballot verifies half as + * many public signals again as a five-slot one and adds twice the points to + * the aggregate. One median across sizes quoted a six-option election about + * fifty per cent more ballots than its reserve could pay for. + */ + bySlots?: Record; } export const ASSUMED: VoteCost = { @@ -51,6 +60,23 @@ export const ASSUMED: VoteCost = { samples: 0, }; +/** + * What one ballot costs in an election with `options` choices, blank included. + * + * The factory gives an election the smallest circuit it fits in, so the + * smallest measured size that fits is the one its ballots use. A size nobody + * has voted in yet is scaled from the nearest measured one, and only ever + * upwards: a figure shown as "ballots paid for" errs towards enough. + */ +export function voteCostFor(cost: VoteCost, options: number | undefined): number { + const sizes = Object.keys(cost.bySlots ?? {}).map(Number).sort((a, b) => a - b); + if (!options || sizes.length === 0) return cost.matic; + const fits = sizes.find(size => size >= options); + if (fits !== undefined) return cost.bySlots![fits]; + const largest = sizes[sizes.length - 1]; + return cost.bySlots![largest] * (options / largest); +} + /** Most recent relays to look at. Enough to be steady, few enough to be cheap. */ const SAMPLE_SIZE = 12; @@ -80,6 +106,7 @@ export async function fetchVoteCost(): Promise { const voteSelector = paymaster.interface.getFunction("relayVote")?.selector ?? RELAY_VOTE; const units: bigint[] = []; + const unitsBySlots = new Map(); await Promise.all( recent.map(async log => { try { @@ -88,7 +115,12 @@ export async function fetchVoteCost(): Promise { const price = tx.gasPrice ?? 0n; if (price === 0n) return; const cost = eventArgs<{ cost?: bigint }>(log).cost ?? 0n; - if (cost > 0n) units.push(cost / price); + if (cost <= 0n) return; + units.push(cost / price); + // The ballot carries two coordinates per slot, so its own calldata + // says which circuit it was proved with. + const slots = ballotSlots(paymaster.interface.parseTransaction({ data: tx.data })?.args[1]); + if (slots) unitsBySlots.set(slots, [...(unitsBySlots.get(slots) ?? []), cost / price]); } catch { // One unreadable transaction is a smaller sample, not a failure. } @@ -105,15 +137,25 @@ export async function fetchVoteCost(): Promise { // The same two ceilings the contract applies when it pays, so the estimate // cannot promise more than a relay would ever be reimbursed. - const gasUnits = median(units); - const capped = gasUnits > maxRelayGas ? maxRelayGas : gasUnits; const now = feeData.gasPrice ?? feeData.maxFeePerGas ?? 0n; const price = now > maxGasPrice ? maxGasPrice : now; if (price === 0n) return ASSUMED; + const toMatic = (samples: bigint[]): number => { + const gasUnits = median(samples); + const capped = gasUnits > maxRelayGas ? maxRelayGas : gasUnits; + return Number(capped * price) / 1e18; + }; return { - matic: Number(capped * price) / 1e18, + matic: toMatic(units), measured: true, samples: units.length, + bySlots: Object.fromEntries([...unitsBySlots].map(([slots, samples]) => [slots, toMatic(samples)])), }; } + +/** Circuit slots of a decoded `relayVote` ballot, or 0 when it cannot tell. */ +function ballotSlots(ballot: unknown): number { + const voteB = (ballot as { voteB?: ArrayLike } | undefined)?.voteB; + return voteB && voteB.length > 0 ? voteB.length / 2 : 0; +} diff --git a/frontend/src/pages/organizer/GasManagement.tsx b/frontend/src/pages/organizer/GasManagement.tsx index eed8f6d..8c07157 100644 --- a/frontend/src/pages/organizer/GasManagement.tsx +++ b/frontend/src/pages/organizer/GasManagement.tsx @@ -29,6 +29,7 @@ import { useElectionPages } from '../../hooks/useElectionPages'; import { getPaymaster } from '../../lib/contracts'; import { openNeeds, totalShortfall } from '../../lib/gasNeeds'; import { useVoteCost } from '../../hooks/useVoteCost'; +import { voteCostFor } from '../../lib/voteCost'; import { DatePicker } from '../../components/ui/DatePicker'; import { usePageLimit } from '../../hooks/usePageLimit'; import { shortenReference } from '../../lib/utils'; @@ -288,7 +289,11 @@ export default function GasManagement() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [live, mineKey, reloadToken]); - const needs = openNeeds(mine.all, e => reserves[e.contractAddress] ?? 0, voteCost.matic); + const needs = openNeeds( + mine.all, + e => reserves[e.contractAddress] ?? 0, + e => voteCostFor(voteCost, e.candidates.length), + ); const owed = totalShortfall(needs); const waitingVoters = needs.reduce((sum, need) => sum + need.remainingVoters, 0); const leftAfterWithdrawal = balance - (Number(withdrawAmount) || 0); From 665f8c1d187a0ca49ccbb4e8de8c7be30a9e3f9d Mon Sep 17 00:00:00 2001 From: CodeInIA Date: Sun, 27 Sep 2026 21:15:32 +0200 Subject: [PATCH 20/32] fix(frontend): read failures, the hourly limit and gaps found testing end to end Found driving the whole flow in Chromium against a local chain: - A dropped RPC read showed "page not found" for an election that exists, and one the RPC never answered left the page on a spinner for good. Reads now time out, a failed one is retried once, and a connection failure gets its own screen with a retry on the election, results, management and change-vote pages. ChangeVote rendered nothing at all. - A second ballot inside the hour said "Something went wrong" without saying when the next one is allowed. It now says the latest ballot already counts and gives the time. - The election page labelled the ballot tag "Reference", the name every other screen gives the transaction hash: two different numbers under one name. - The timeline and sort labels had lost their accents in five languages, and German said "payout" (Auszahlung) where it meant the count (Auszaehlung). - "1 voters" on the verified tally: the count is pluralised now. - Modals had no dialog role and the date picker's hour and minute fields, like the modal's close button, were labelled in English only. - The smoke test visited /voter/election/:id, which no longer exists and passed on the 404 page. --- frontend/e2e/smoke.spec.ts | 5 +- frontend/src/components/ui/DatePicker.tsx | 4 +- .../src/components/ui/ElectionUnreadable.tsx | 31 +++++++ frontend/src/components/ui/Modal.tsx | 18 +++- frontend/src/components/ui/TallyCheck.tsx | 5 +- frontend/src/hooks/useElection.test.tsx | 82 +++++++++++++++++++ frontend/src/hooks/useElections.ts | 62 +++++++++++++- frontend/src/i18n/locales/ar.json | 13 ++- frontend/src/i18n/locales/de.json | 17 ++-- frontend/src/i18n/locales/en.json | 9 +- frontend/src/i18n/locales/es.json | 28 ++++--- frontend/src/i18n/locales/fr.json | 28 ++++--- frontend/src/i18n/locales/hi.json | 9 +- frontend/src/i18n/locales/it.json | 18 ++-- frontend/src/i18n/locales/ja.json | 8 +- frontend/src/i18n/locales/ko.json | 8 +- frontend/src/i18n/locales/nl.json | 9 +- frontend/src/i18n/locales/pt.json | 22 +++-- frontend/src/i18n/locales/ru.json | 11 ++- frontend/src/i18n/locales/zh.json | 8 +- frontend/src/i18n/plurals.test.ts | 1 + frontend/src/lib/relay.ts | 7 ++ frontend/src/lib/relayErrors.test.ts | 12 ++- frontend/src/pages/ElectionPage.tsx | 16 +++- .../pages/organizer/ElectionManagement.tsx | 13 ++- frontend/src/pages/public/ElectionResults.tsx | 7 +- frontend/src/pages/voter/ChangeVote.tsx | 7 +- .../src/pages/voter/ZkProofGeneration.tsx | 13 ++- 28 files changed, 402 insertions(+), 69 deletions(-) create mode 100644 frontend/src/components/ui/ElectionUnreadable.tsx create mode 100644 frontend/src/hooks/useElection.test.tsx diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index 30f3983..c8d9142 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -1,12 +1,13 @@ import { test, expect } from '@playwright/test'; -// Smoke test: verifies all 24 screens render without crashing and have basic content. +// Smoke test: verifies every screen renders without crashing and has basic content. // Run after: npx playwright install chromium const PUBLIC_ROUTES = [ { path: '/', label: 'Landing' }, { path: '/discover', label: 'Discover' }, { path: '/election/e1', label: 'Election Preview (active)' }, + { path: '/election/e2', label: 'Election Preview (enrolling)' }, { path: '/election/e4/results', label: 'Election Results (closed)' }, { path: '/how-it-works', label: 'How It Works' }, { path: '/verify-receipt', label: 'Verify Receipt' }, @@ -16,8 +17,6 @@ const VOTER_ROUTES = [ { path: '/voter/onboarding', label: 'Onboarding' }, { path: '/voter/re-verify', label: 'Re-verification' }, { path: '/voter/elections', label: 'Voter Elections' }, - { path: '/voter/election/e1', label: 'Election Detail (active)' }, - { path: '/voter/election/e2', label: 'Election Detail (enrolling)' }, { path: '/voter/election/e1/zk-proof', label: 'ZK Proof Generation' }, { path: '/voter/election/e4/confirmation', label: 'Vote Confirmation' }, { path: '/voter/election/e1/change-vote', label: 'Change Vote' }, diff --git a/frontend/src/components/ui/DatePicker.tsx b/frontend/src/components/ui/DatePicker.tsx index b13ab73..d45a88f 100644 --- a/frontend/src/components/ui/DatePicker.tsx +++ b/frontend/src/components/ui/DatePicker.tsx @@ -336,7 +336,7 @@ export function DatePicker({ type="number" min={minHour} max={23} - aria-label="Hour" + aria-label={t('common.hour')} disabled={!selected} value={selected ? pad(selected.getHours()) : ''} onChange={e => setTimePart('h', e.target.value)} @@ -347,7 +347,7 @@ export function DatePicker({ type="number" min={minMinute} max={59} - aria-label="Minute" + aria-label={t('common.minute')} disabled={!selected} value={selected ? pad(selected.getMinutes()) : ''} onChange={e => setTimePart('m', e.target.value)} diff --git a/frontend/src/components/ui/ElectionUnreadable.tsx b/frontend/src/components/ui/ElectionUnreadable.tsx new file mode 100644 index 0000000..3bbc7b2 --- /dev/null +++ b/frontend/src/components/ui/ElectionUnreadable.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from 'react-i18next'; +import { PageLayout } from '../layout/PageLayout'; +import { Button } from './Button'; + +interface ElectionUnreadableProps { + role: 'voter' | 'organizer' | 'public'; + onRetry: () => void; + onBack: () => void; +} + +/** + * The chain did not answer for this election. + * + * Its own screen rather than the "not found" one every election page fell back + * to: a dropped connection is not a missing election, and saying it was sent a + * voter away from one that exists, or left them on a blank page. + */ +export function ElectionUnreadable({ role, onRetry, onBack }: ElectionUnreadableProps) { + const { t } = useTranslation(); + return ( + +
+

{t('errors.election_unreadable')}

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index bf964f2..8dca1e4 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { createPortal } from 'react-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { cn } from '../../lib/utils'; import { Button } from './Button'; @@ -26,6 +27,13 @@ export function Modal({ showClose = true, size = 'md', }: ModalProps) { + const { t } = useTranslation(); + // Named for assistive technology: without a role a screen reader met these as + // loose text over a page it could still tab through, and could not tell which + // question it was being asked. + const titleId = React.useId(); + const descriptionId = React.useId(); + React.useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') onClose(); }; @@ -51,6 +59,10 @@ export function Modal({ exit={{ opacity: 0, scale: 0.95, y: 8 }} transition={{ duration: 0.2, ease: 'easeOut' }} onClick={(e: React.MouseEvent) => e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby={title ? titleId : undefined} + aria-describedby={description ? descriptionId : undefined} className={cn( 'relative z-10 w-full bg-surface-high/80 backdrop-blur-3xl rounded-3xl border border-white/8 shadow-[0_24px_64px_rgba(0,0,0,0.7)] flex flex-col', size === 'sm' && 'max-w-sm', @@ -64,7 +76,7 @@ export function Modal({ variant="ghost" onClick={onClose} className="absolute top-4 right-4 w-8 h-8 p-0 flex items-center justify-center rounded-full hover:bg-white/10 text-on-surface-meta hover:text-on-surface" - aria-label="Close" + aria-label={t('common.close')} > @@ -72,10 +84,10 @@ export function Modal({ {(title || description) && (
{title && ( -

{title}

+

{title}

)} {description && ( -

{description}

+

{description}

)}
)} diff --git a/frontend/src/components/ui/TallyCheck.tsx b/frontend/src/components/ui/TallyCheck.tsx index 1339208..8f22a9c 100644 --- a/frontend/src/components/ui/TallyCheck.tsx +++ b/frontend/src/components/ui/TallyCheck.tsx @@ -68,7 +68,10 @@ export function TallyCheck({ election, className }: { election: Election; classN

{audit.status === 'verified' - ? t('results.check_verified_body', { ballots: audit.ballots, voters: audit.voters }) + ? t('results.check_verified_body', { + ballots: audit.ballots, + voters: t('results.voters_count', { count: audit.voters }), + }) : t(`results.check_${audit.status}_body`)}

{audit.status === 'failed' && ( diff --git a/frontend/src/hooks/useElection.test.tsx b/frontend/src/hooks/useElection.test.tsx new file mode 100644 index 0000000..9402df5 --- /dev/null +++ b/frontend/src/hooks/useElection.test.tsx @@ -0,0 +1,82 @@ +/** + * Reading one election, and telling a dropped connection from a missing one. + * + * Both used to end in the same "page not found", and a read the RPC never + * answered left the page on a spinner for good. Seen on the local chain when + * the proxy reset a connection mid-read. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; + +const { fetchElection } = vi.hoisted(() => ({ fetchElection: vi.fn() })); + +vi.mock('../lib/deployments', () => ({ isChainConfigured: () => true })); +vi.mock('../data/seed', () => ({ ELECTIONS: [], getSeedElection: () => undefined })); +vi.mock('../lib/chainElections', () => ({ fetchElection })); + +import { useElection, ELECTION_READ_TIMEOUT_MS } from './useElections'; + +const ADDRESS = '0x6DF9099ef81A8d06bc753f4AF7A0D73c58723aed'; +const ELECTION = { contractAddress: ADDRESS, title: 'Transport Sector Board' }; + +/** What ethers throws for an address with no contract behind it. */ +const noContract = () => Object.assign(new Error('could not decode result data'), { code: 'BAD_DATA' }); +/** What it throws when the connection drops. */ +const dropped = () => Object.assign(new Error('failed to fetch'), { code: 'NETWORK_ERROR' }); + +beforeEach(() => { + fetchElection.mockReset(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('useElection', () => { + it('retries once after a dropped connection, and the reader never sees it', async () => { + fetchElection.mockRejectedValueOnce(dropped()).mockResolvedValueOnce(ELECTION); + + const { result } = renderHook(() => useElection(ADDRESS)); + + await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 3000 }); + expect(result.current.election).toEqual(ELECTION); + expect(result.current.unreachable).toBe(false); + expect(fetchElection).toHaveBeenCalledTimes(2); + }); + + it('says the chain did not answer, not that the election is missing', async () => { + fetchElection.mockRejectedValue(dropped()); + + const { result } = renderHook(() => useElection(ADDRESS)); + + await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 3000 }); + expect(result.current.election).toBeUndefined(); + expect(result.current.unreachable).toBe(true); + }); + + it('does not retry an address that answered it holds no election', async () => { + fetchElection.mockRejectedValue(noContract()); + + const { result } = renderHook(() => useElection(ADDRESS)); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.unreachable).toBe(false); + expect(fetchElection).toHaveBeenCalledTimes(1); + }); + + it('gives up on a read that is never answered instead of spinning for good', async () => { + vi.useFakeTimers({ shouldAdvanceTime: false }); + fetchElection.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useElection(ADDRESS)); + expect(result.current.loading).toBe(true); + + // The first read times out, the retry waits a second, and times out too. + await act(async () => { await vi.advanceTimersByTimeAsync(ELECTION_READ_TIMEOUT_MS); }); + await act(async () => { await vi.advanceTimersByTimeAsync(1_000); }); + await act(async () => { await vi.advanceTimersByTimeAsync(ELECTION_READ_TIMEOUT_MS); }); + + expect(result.current.loading).toBe(false); + expect(result.current.unreachable).toBe(true); + }); +}); diff --git a/frontend/src/hooks/useElections.ts b/frontend/src/hooks/useElections.ts index 5419b0b..bfed861 100644 --- a/frontend/src/hooks/useElections.ts +++ b/frontend/src/hooks/useElections.ts @@ -23,15 +23,56 @@ interface ElectionState { election: Election | undefined; loading: boolean; error: string | null; + /** + * The chain did not answer, as opposed to answering that there is no such + * election. The two used to render the same "page not found", so a dropped + * connection told a voter their election did not exist. + */ + unreachable: boolean; live: boolean; refresh: () => Promise; } +/** + * How long one read may take before it counts as unanswered. + * + * A request the RPC never answers leaves its promise pending for good, and the + * page on a spinner with it: seen on the local chain when the proxy dropped a + * connection mid-read, and a public endpoint does the same under load. + */ +export const ELECTION_READ_TIMEOUT_MS = 15_000; + +/** Pause before the one automatic retry: long enough for a blip to pass. */ +const RETRY_DELAY_MS = 1_000; + +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`No answer from the chain in ${ms / 1000}s`)), ms); + promise.then( + value => { clearTimeout(timer); resolve(value); }, + error => { clearTimeout(timer); reject(error); }, + ); + }); +} + +/** + * Whether a failed read means the address holds no election. + * + * An address with no contract answers every view with empty data, which ethers + * reports as BAD_DATA (or CALL_EXCEPTION for a contract that is not an + * election). Anything else is the connection failing, and is worth a retry. + */ +export function isNoSuchElection(error: unknown): boolean { + const code = (error as { code?: unknown } | null)?.code; + return code === "BAD_DATA" || code === "CALL_EXCEPTION"; +} + export function useElection(id: string | undefined): ElectionState { const live = isChainConfigured() && Boolean(id?.startsWith("0x")); const [chainElection, setChainElection] = useState(); const [loading, setLoading] = useState(live); const [error, setError] = useState(null); + const [unreachable, setUnreachable] = useState(false); const [reloadToken, setReloadToken] = useState(0); // Seed lookup is a pure derivation of `id` — no state, no effect. @@ -41,14 +82,29 @@ export function useElection(id: string | undefined): ElectionState { if (!live || !id) return; // seed mode resolves synchronously above let cancelled = false; + const read = () => withTimeout(fetchElection(id), ELECTION_READ_TIMEOUT_MS); + void (async () => { try { - const data = await fetchElection(id); + let data: Election; + try { + data = await read(); + } catch (first) { + // One retry for a connection that failed, never for an address that + // answered: a blip should not cost the reader a manual reload. + if (isNoSuchElection(first) || cancelled) throw first; + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS)); + if (cancelled) return; + data = await read(); + } if (cancelled) return; setChainElection(data); setError(null); + setUnreachable(false); } catch (e) { - if (!cancelled) setError(e instanceof Error ? e.message : String(e)); + if (cancelled) return; + setError(e instanceof Error ? e.message : String(e)); + setUnreachable(!isNoSuchElection(e)); } finally { if (!cancelled) setLoading(false); } @@ -63,5 +119,5 @@ export function useElection(id: string | undefined): ElectionState { setReloadToken(t => t + 1); }, [live, id]); - return { election, loading, error, live, refresh }; + return { election, loading, error, unreachable, live, refresh }; } diff --git a/frontend/src/i18n/locales/ar.json b/frontend/src/i18n/locales/ar.json index f0948b2..46a2105 100644 --- a/frontend/src/i18n/locales/ar.json +++ b/frontend/src/i18n/locales/ar.json @@ -65,6 +65,8 @@ "retry": "إعادة المحاولة", "close": "إغلاق", "confirm": "تأكيد", + "hour": "الساعة", + "minute": "الدقيقة", "integration_pending": "ستُربط هذه الميزة في مرحلة التكامل.", "all": "الكل", "yes": "نعم", @@ -230,7 +232,13 @@ "not_available": "النتائج غير متاحة بعد.", "check_pending": "جارٍ التحقق من النتيجة المنشورة مقابل أوراق الاقتراع على السلسلة…", "check_verified_title": "تم التحقق مقابل أوراق الاقتراع على السلسلة", - "check_verified_body": "لم تقبل الانتخابات هذه النتائج إلا مع إثبات معرفة صفرية بأنها فكّ تشفير مجموع كل البطاقات، وقد أُعيد هنا جمع البطاقات الموجودة على السلسلة وعددها {{ballots}} فكان الناتج المجموع نفسه: {{voters}} ناخبين. يمكن لأي شخص إعادة هذا التحقق دون مفتاح ودون ثقة.", + "check_verified_body": "لم تقبل الانتخابات هذه النتائج إلا مع إثبات معرفة صفرية بأنها فكّ تشفير مجموع كل البطاقات، وقد أُعيد هنا جمع البطاقات الموجودة على السلسلة وعددها {{ballots}} فكان الناتج المجموع نفسه: {{voters}}. يمكن لأي شخص إعادة هذا التحقق دون مفتاح ودون ثقة.", + "voters_count_zero": "لا ناخبين", + "voters_count_one": "ناخب واحد", + "voters_count_two": "ناخبان", + "voters_count_few": "{{count}} ناخبين", + "voters_count_many": "{{count}} ناخبًا", + "voters_count_other": "{{count}} ناخب", "check_failed_title": "لا يطابق أوراق الاقتراع على السلسلة", "check_failed_body": "تعذّر التحقق من الأعداد المنشورة مقابل أوراق الاقتراع المسجلة على السلسلة. لا تعتمد على هذه النتيجة." }, @@ -622,8 +630,11 @@ "wallet_request_rejected": "لقد ألغيت الطلب في محفظتك. لم يحدث أي شيء.", "passkey_cancelled": "أغلقت طلب مفتاح المرور. لم يُنشأ أي شيء: يمكنك المحاولة مرة أخرى.", "list_unreadable": "تعذّرت قراءة هذه القائمة من السلسلة، وهذا ليس كأنها فارغة. تحقق من الاتصال ثم أعد المحاولة.", + "election_unreadable": "تعذّرت قراءة هذه الانتخابات من السلسلة، وهذا لا يعني أنها غير موجودة. تحقق من الاتصال ثم حاول مرة أخرى.", "passkey_unproven": "أُنشئ مفتاح المرور، لكننا لم نتأكد بعد من قدرته على فكّ التشفير. اضغط مرة أخرى: سُيسأل المفتاح نفسه، دون إنشاء مفتاح جديد.", "epoch_already_cast": "لقد أدليت ببطاقة اقتراع خلال الساعة الأخيرة. آخر بطاقة لك هي التي تُحتسب، ويمكنك تغييرها مرة أخرى بعد انقضاء الساعة.", + "epoch_already_cast_until": "لقد أدليت ببطاقة اقتراع خلال هذه الساعة. آخر بطاقة لك هي التي تُحتسب، ويمكنك تغييرها مرة أخرى ابتداءً من {{time}}.", + "epoch_wait_title": "صوتك الأخير هو المحتسب", "ballot_already_cast": "هذه البطاقة مسجّلة بالفعل. لا حاجة لفعل أي شيء آخر: صوتك محتسب.", "ballot_expired": "أُعدّت هذه البطاقة منذ وقت طويل جدًا فلم تعد مقبولة. يُرجى التصويت مجددًا؛ إعداد بطاقة جديدة يستغرق بضع ثوانٍ." }, diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 54777c4..91f22c7 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -65,6 +65,8 @@ "retry": "Erneut versuchen", "close": "Schließen", "confirm": "Bestätigen", + "hour": "Stunde", + "minute": "Minute", "integration_pending": "Diese Funktion wird in der Integrationsphase verbunden.", "all": "Alle", "yes": "Ja", @@ -222,7 +224,9 @@ "not_available": "Ergebnisse noch nicht verfügbar.", "check_pending": "Das veröffentlichte Ergebnis wird mit den Stimmzetteln auf der Chain abgeglichen…", "check_verified_title": "Mit den Stimmzetteln auf der Chain verifiziert", - "check_verified_body": "Die Wahl hat diese Ergebnisse nur mit einem Zero-Knowledge-Beweis angenommen, dass sie die Summe aller Stimmzettel entschlüsseln, und die {{ballots}} Stimmzettel auf der Chain wurden hier erneut zu genau dieser Summe addiert: {{voters}} Wählende. Jeder kann diese Prüfung ohne Schlüssel und ohne Vertrauen wiederholen.", + "check_verified_body": "Die Wahl hat diese Ergebnisse nur mit einem Zero-Knowledge-Beweis angenommen, dass sie die Summe aller Stimmzettel entschlüsseln, und die {{ballots}} Stimmzettel auf der Chain wurden hier erneut zu genau dieser Summe addiert: {{voters}}. Jeder kann diese Prüfung ohne Schlüssel und ohne Vertrauen wiederholen.", + "voters_count_other": "{{count}} Wählende", + "voters_count_one": "{{count}} wählende Person", "check_failed_title": "Stimmt nicht mit den Stimmzetteln auf der Chain überein", "check_failed_body": "Die veröffentlichten Zahlen ließen sich nicht mit den auf der Chain erfassten Stimmzetteln verifizieren. Verlassen Sie sich nicht auf dieses Ergebnis." }, @@ -602,8 +606,11 @@ "wallet_request_rejected": "Du hast die Anfrage in deiner Wallet abgebrochen. Es wurde nichts ausgeführt.", "passkey_cancelled": "Du hast den Passkey-Dialog abgebrochen. Es wurde nichts erstellt: du kannst es erneut versuchen.", "list_unreadable": "Diese Liste konnte nicht von der Chain gelesen werden, was nicht dasselbe ist wie leer zu sein. Prüfen Sie die Verbindung und versuchen Sie es erneut.", + "election_unreadable": "Diese Wahl konnte nicht von der Chain gelesen werden, was nicht heißt, dass es sie nicht gibt. Prüfen Sie die Verbindung und versuchen Sie es erneut.", "passkey_unproven": "Der Passkey wurde erstellt, aber wir konnten noch nicht bestätigen, dass er entschlüsseln kann. Drück noch einmal: es wird derselbe gefragt, ohne einen neuen zu erstellen.", "epoch_already_cast": "Sie haben in der letzten Stunde bereits einen Stimmzettel abgegeben. Es zählt Ihr letzter Stimmzettel; nach Ablauf der Stunde können Sie ihn erneut ändern.", + "epoch_already_cast_until": "Sie haben in dieser Stunde bereits einen Stimmzettel abgegeben. Es zählt Ihr letzter Stimmzettel; ab {{time}} können Sie ihn erneut ändern.", + "epoch_wait_title": "Ihre letzte Stimme zählt bereits", "ballot_already_cast": "Dieser Stimmzettel wurde bereits erfasst. Sie müssen nichts weiter tun: Ihre Stimme ist abgegeben.", "ballot_expired": "Dieser Stimmzettel wurde vor zu langer Zeit vorbereitet, um noch angenommen zu werden. Bitte stimmen Sie erneut ab; ein neuer Stimmzettel dauert nur wenige Sekunden." }, @@ -877,16 +884,16 @@ "enrollment": "Anmeldung", "gap": "Zwischen Anmeldung und Abstimmung", "voting": "Abstimmung", - "results": "Auszahlung und Ergebnisse", + "results": "Auszählung und Ergebnisse", "from_date": "Ab {{date}}", "ended_early": "Vorzeitig beendet", "abandoned": "Diese Wahl wurde gestoppt, dieser Zeitplan wurde daher nicht bis zum Ende eingehalten." }, "sort": { "newest": "Neueste zuerst", - "oldest": "Alteste zuerst", - "closing": "Endet am fruhesten", + "oldest": "Älteste zuerst", + "closing": "Endet am frühesten", "enrolled": "Meiste Anmeldungen", - "partial_notice": "sortiert uber das bisher Gelesene" + "partial_notice": "sortiert über das bisher Gelesene" } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 30a8153..d37d542 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -106,6 +106,8 @@ "retry": "Try again", "close": "Close", "confirm": "Confirm", + "hour": "Hour", + "minute": "Minute", "integration_pending": "This feature will be connected in the integration phase.", "all": "All", "yes": "Yes", @@ -222,7 +224,9 @@ "not_available": "Results not yet available.", "check_pending": "Checking the published result against the ballots on chain…", "check_verified_title": "Verified against the ballots on chain", - "check_verified_body": "The election accepted these counts only with a zero-knowledge proof that they decrypt the sum of every ballot, and the {{ballots}} ballots on chain were added up again here to that same sum: {{voters}} voters. Anyone can repeat this check with no key and no trust.", + "check_verified_body": "The election accepted these counts only with a zero-knowledge proof that they decrypt the sum of every ballot, and the {{ballots}} ballots on chain were added up again here to that same sum: {{voters}}. Anyone can repeat this check with no key and no trust.", + "voters_count_other": "{{count}} voters", + "voters_count_one": "{{count}} voter", "check_failed_title": "Does not match the ballots on chain", "check_failed_body": "The published counts could not be verified against the ballots recorded on chain. Do not rely on this result." }, @@ -602,8 +606,11 @@ "wallet_request_rejected": "You cancelled the request in your wallet. Nothing was done.", "passkey_cancelled": "You dismissed the passkey prompt. Nothing was created: you can try again.", "list_unreadable": "This list could not be read from the chain, which is not the same as it being empty. Check your connection and try again.", + "election_unreadable": "This election could not be read from the chain, which is not the same as it not existing. Check your connection and try again.", "passkey_unproven": "The passkey was created, but we could not confirm yet that it can decrypt. Press again: the same one is asked, and no new passkey is created.", "epoch_already_cast": "You already cast a ballot in the last hour. Your latest ballot is the one that counts; you can change it again once the hour is up.", + "epoch_already_cast_until": "You already cast a ballot this hour. Your latest ballot is the one that counts; you can change it again from {{time}}.", + "epoch_wait_title": "Your latest vote already counts", "ballot_already_cast": "This ballot was already recorded. There is nothing else to do: your vote is in.", "ballot_expired": "This ballot was prepared too long ago to be accepted. Please vote again; a fresh ballot takes a few seconds." }, diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 04cc9d0..0b4a86c 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -65,6 +65,8 @@ "retry": "Intentar de nuevo", "close": "Cerrar", "confirm": "Confirmar", + "hour": "Hora", + "minute": "Minuto", "integration_pending": "Esta función se conectará en la fase de integración.", "all": "Todo", "yes": "Sí", @@ -224,7 +226,10 @@ "not_available": "Los resultados aún no están disponibles.", "check_pending": "Comprobando el resultado publicado con las papeletas de la cadena…", "check_verified_title": "Verificado con las papeletas de la cadena", - "check_verified_body": "La elección aceptó estos recuentos solo con una prueba de conocimiento cero de que descifran la suma de todas las papeletas, y las {{ballots}} papeletas de la cadena se han vuelto a sumar aquí hasta esa misma suma: {{voters}} votantes. Cualquiera puede repetir esta comprobación sin clave y sin confiar en nadie.", + "check_verified_body": "La elección aceptó estos recuentos solo con una prueba de conocimiento cero de que descifran la suma de todas las papeletas, y las {{ballots}} papeletas de la cadena se han vuelto a sumar aquí hasta esa misma suma: {{voters}}. Cualquiera puede repetir esta comprobación sin clave y sin confiar en nadie.", + "voters_count_other": "{{count}} votantes", + "voters_count_one": "{{count}} votante", + "voters_count_many": "{{count}} votantes", "check_failed_title": "No cuadra con las papeletas de la cadena", "check_failed_body": "Los recuentos publicados no se han podido verificar con las papeletas registradas en la cadena. No te fíes de este resultado." }, @@ -607,8 +612,11 @@ "wallet_request_rejected": "Cancelaste la petición en tu cartera. No se ha hecho nada.", "passkey_cancelled": "Cancelaste el diálogo de la passkey. No se ha creado nada: puedes intentarlo otra vez.", "list_unreadable": "Esta lista no se ha podido leer de la cadena, que no es lo mismo que esté vacía. Revisa la conexión e inténtalo otra vez.", + "election_unreadable": "Esta elección no se ha podido leer de la cadena, que no es lo mismo que no exista. Revisa la conexión e inténtalo otra vez.", "passkey_unproven": "La passkey se creó, pero aún no hemos podido confirmar que sirva para descifrar. Pulsa otra vez: se le preguntará a esa misma, sin crear ninguna nueva.", "epoch_already_cast": "Ya emitiste una papeleta en la última hora. Cuenta tu última papeleta; podrás cambiarla otra vez cuando pase la hora.", + "epoch_already_cast_until": "Ya emitiste una papeleta en esta hora. Cuenta tu última papeleta; podrás cambiarla otra vez a partir de las {{time}}.", + "epoch_wait_title": "Tu último voto ya cuenta", "ballot_already_cast": "Esta papeleta ya estaba registrada. No tienes que hacer nada más: tu voto está dentro.", "ballot_expired": "Esta papeleta se preparó hace demasiado tiempo para ser aceptada. Vuelve a votar; una papeleta nueva tarda unos segundos." }, @@ -880,19 +888,19 @@ "timeline": { "title": "Calendario", "created": "Creada", - "enrollment": "Inscripcion", - "gap": "Entre la inscripcion y la votacion", - "voting": "Votacion", + "enrollment": "Inscripción", + "gap": "Entre la inscripción y la votación", + "voting": "Votación", "results": "Recuento y resultados", "from_date": "Desde el {{date}}", - "ended_early": "Se acorto", - "abandoned": "Esta eleccion se detuvo, asi que este calendario no se cumplio hasta el final." + "ended_early": "Se acortó", + "abandoned": "Esta elección se detuvo, así que este calendario no se cumplió hasta el final." }, "sort": { - "newest": "Mas recientes primero", - "oldest": "Mas antiguas primero", + "newest": "Más recientes primero", + "oldest": "Más antiguas primero", "closing": "Las que cierran antes", - "enrolled": "Con mas inscritos", - "partial_notice": "ordenadas sobre lo leido hasta ahora" + "enrolled": "Con más inscritos", + "partial_notice": "ordenadas sobre lo leído hasta ahora" } } diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index b822528..c4bc4e2 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -65,6 +65,8 @@ "retry": "Réessayer", "close": "Fermer", "confirm": "Confirmer", + "hour": "Heure", + "minute": "Minute", "integration_pending": "Cette fonctionnalité sera connectée lors de la phase d'intégration.", "all": "Tout", "yes": "Oui", @@ -224,7 +226,10 @@ "not_available": "Résultats pas encore disponibles.", "check_pending": "Vérification du résultat publié avec les bulletins enregistrés sur la chaîne…", "check_verified_title": "Vérifié avec les bulletins sur la chaîne", - "check_verified_body": "L'élection n'a accepté ces décomptes qu'avec une preuve à divulgation nulle de connaissance qu'ils déchiffrent la somme de tous les bulletins, et les {{ballots}} bulletins présents sur la chaîne ont été additionnés à nouveau ici jusqu'à cette même somme : {{voters}} votants. Chacun peut refaire cette vérification sans clé et sans confiance.", + "check_verified_body": "L'élection n'a accepté ces décomptes qu'avec une preuve à divulgation nulle de connaissance qu'ils déchiffrent la somme de tous les bulletins, et les {{ballots}} bulletins présents sur la chaîne ont été additionnés à nouveau ici jusqu'à cette même somme : {{voters}}. Chacun peut refaire cette vérification sans clé et sans confiance.", + "voters_count_one": "{{count}} votant", + "voters_count_other": "{{count}} votants", + "voters_count_many": "{{count}} votants", "check_failed_title": "Ne correspond pas aux bulletins sur la chaîne", "check_failed_body": "Les décomptes publiés n'ont pas pu être vérifiés avec les bulletins enregistrés sur la chaîne. Ne vous fiez pas à ce résultat." }, @@ -607,8 +612,11 @@ "wallet_request_rejected": "Vous avez annulé la demande dans votre portefeuille. Rien n’a été fait.", "passkey_cancelled": "Vous avez fermé la demande de passkey. Rien n’a été créé : vous pouvez réessayer.", "list_unreadable": "Cette liste n’a pas pu être lue depuis la chaîne, ce qui n’est pas la même chose qu’une liste vide. Vérifiez la connexion et réessayez.", + "election_unreadable": "Cette élection n’a pas pu être lue depuis la chaîne, ce qui ne veut pas dire qu’elle n’existe pas. Vérifiez la connexion et réessayez.", "passkey_unproven": "La passkey a été créée, mais nous n’avons pas encore pu confirmer qu’elle peut déchiffrer. Appuyez à nouveau : c’est la même qui sera interrogée, sans en créer de nouvelle.", "epoch_already_cast": "Vous avez déjà déposé un bulletin au cours de la dernière heure. Votre dernier bulletin est celui qui compte ; vous pourrez le modifier à nouveau une fois l'heure écoulée.", + "epoch_already_cast_until": "Vous avez déjà déposé un bulletin cette heure-ci. Votre dernier bulletin est celui qui compte ; vous pourrez le modifier à nouveau à partir de {{time}}.", + "epoch_wait_title": "Votre dernier vote compte déjà", "ballot_already_cast": "Ce bulletin était déjà enregistré. Vous n'avez rien d'autre à faire : votre vote est pris en compte.", "ballot_expired": "Ce bulletin a été préparé il y a trop longtemps pour être accepté. Votez à nouveau ; un nouveau bulletin prend quelques secondes." }, @@ -879,20 +887,20 @@ }, "timeline": { "title": "Calendrier", - "created": "Creee", + "created": "Créée", "enrollment": "Inscription", "gap": "Entre l'inscription et le vote", "voting": "Vote", - "results": "Depouillement et resultats", - "from_date": "A partir du {{date}}", - "ended_early": "Ecourte", - "abandoned": "Cette election a ete interrompue, ce calendrier n'a donc pas ete suivi jusqu'au bout." + "results": "Dépouillement et résultats", + "from_date": "À partir du {{date}}", + "ended_early": "Écourté", + "abandoned": "Cette élection a été interrompue, ce calendrier n’a donc pas été suivi jusqu’au bout." }, "sort": { - "newest": "Les plus recentes d'abord", - "oldest": "Les plus anciennes d'abord", - "closing": "Cloture la plus proche", + "newest": "Les plus récentes d’abord", + "oldest": "Les plus anciennes d’abord", + "closing": "Clôture la plus proche", "enrolled": "Le plus d'inscrits", - "partial_notice": "triees sur ce qui a ete lu jusqu'ici" + "partial_notice": "triées sur ce qui a été lu jusqu’ici" } } diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index 29c2016..2c6580b 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -65,6 +65,8 @@ "retry": "पुनः प्रयास करें", "close": "बंद करें", "confirm": "पुष्टि करें", + "hour": "घंटा", + "minute": "मिनट", "integration_pending": "यह सुविधा एकीकरण चरण में जुड़ेगी।", "all": "सभी", "yes": "हाँ", @@ -222,7 +224,9 @@ "not_available": "परिणाम अभी उपलब्ध नहीं हैं।", "check_pending": "प्रकाशित परिणाम की चेन पर दर्ज मतपत्रों से जाँच की जा रही है…", "check_verified_title": "चेन पर दर्ज मतपत्रों से सत्यापित", - "check_verified_body": "चुनाव ने ये गिनतियाँ तभी स्वीकार कीं जब साथ में शून्य-ज्ञान प्रमाण था कि ये सभी मतपत्रों के योग का विकोडन हैं, और चेन पर मौजूद {{ballots}} मतपत्रों को यहाँ फिर से जोड़कर वही योग मिला: {{voters}} मतदाता। कोई भी बिना कुंजी और बिना भरोसे के यह जाँच दोहरा सकता है।", + "check_verified_body": "चुनाव ने ये गिनतियाँ तभी स्वीकार कीं जब साथ में शून्य-ज्ञान प्रमाण था कि ये सभी मतपत्रों के योग का विकोडन हैं, और चेन पर मौजूद {{ballots}} मतपत्रों को यहाँ फिर से जोड़कर वही योग मिला: {{voters}}। कोई भी बिना कुंजी और बिना भरोसे के यह जाँच दोहरा सकता है।", + "voters_count_one": "{{count}} मतदाता", + "voters_count_other": "{{count}} मतदाता", "check_failed_title": "चेन पर दर्ज मतपत्रों से मेल नहीं खाता", "check_failed_body": "प्रकाशित गिनती को चेन पर दर्ज मतपत्रों से सत्यापित नहीं किया जा सका। इस परिणाम पर भरोसा न करें।" }, @@ -602,8 +606,11 @@ "wallet_request_rejected": "आपने अपने वॉलेट में अनुरोध रद्द कर दिया। कुछ नहीं हुआ।", "passkey_cancelled": "आपने पासकी का संवाद बंद कर दिया। कुछ नहीं बना: आप दोबारा कोशिश कर सकते हैं।", "list_unreadable": "यह सूची चेन से नहीं पढ़ी जा सकी, जो खाली होने जैसा नहीं है। कनेक्शन जाँचें और फिर से कोशिश करें।", + "election_unreadable": "यह चुनाव चेन से नहीं पढ़ा जा सका, जिसका मतलब यह नहीं कि यह मौजूद नहीं है। कनेक्शन जाँचें और फिर से कोशिश करें।", "passkey_unproven": "पासकी बन गई, पर अभी यह पक्का नहीं हो सका कि वह डिक्रिप्ट कर सकती है। दोबारा दबाएँ: उसी से पूछा जाएगा, नई नहीं बनेगी।", "epoch_already_cast": "आप पिछले एक घंटे में पहले ही एक मतपत्र डाल चुके हैं। आपका नवीनतम मतपत्र ही गिना जाएगा; घंटा पूरा होने पर आप इसे फिर से बदल सकते हैं।", + "epoch_already_cast_until": "आप इस घंटे में पहले ही एक मतपत्र डाल चुके हैं। आपका नवीनतम मतपत्र ही गिना जाता है; आप इसे {{time}} से फिर बदल सकते हैं।", + "epoch_wait_title": "आपका नवीनतम वोट पहले से गिना जा रहा है", "ballot_already_cast": "यह मतपत्र पहले ही दर्ज हो चुका है। आपको और कुछ नहीं करना है: आपका मत दर्ज है।", "ballot_expired": "यह मतपत्र बहुत पहले तैयार किया गया था, इसलिए स्वीकार नहीं किया जा सकता। कृपया फिर से मतदान करें; नया मतपत्र बनने में कुछ ही सेकंड लगते हैं।" }, diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index eb1a480..b2a39e8 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -65,6 +65,8 @@ "retry": "Riprova", "close": "Chiudi", "confirm": "Conferma", + "hour": "Ora", + "minute": "Minuto", "integration_pending": "Questa funzione sarà collegata nella fase di integrazione.", "all": "Tutti", "yes": "Sì", @@ -224,7 +226,10 @@ "not_available": "Risultati non ancora disponibili.", "check_pending": "Verifica del risultato pubblicato con le schede sulla chain…", "check_verified_title": "Verificato con le schede sulla chain", - "check_verified_body": "L'elezione ha accettato questi conteggi solo con una prova a conoscenza zero che decifrano la somma di tutte le schede, e le {{ballots}} schede sulla catena sono state risommate qui fino alla stessa somma: {{voters}} votanti. Chiunque può ripetere questa verifica senza chiavi e senza fiducia.", + "check_verified_body": "L'elezione ha accettato questi conteggi solo con una prova a conoscenza zero che decifrano la somma di tutte le schede, e le {{ballots}} schede sulla catena sono state risommate qui fino alla stessa somma: {{voters}}. Chiunque può ripetere questa verifica senza chiavi e senza fiducia.", + "voters_count_other": "{{count}} votanti", + "voters_count_one": "{{count}} votante", + "voters_count_many": "{{count}} votanti", "check_failed_title": "Non corrisponde alle schede sulla chain", "check_failed_body": "Non è stato possibile verificare i conteggi pubblicati con le schede registrate sulla chain. Non fare affidamento su questo risultato." }, @@ -607,8 +612,11 @@ "wallet_request_rejected": "Hai annullato la richiesta nel tuo wallet. Non è stato fatto nulla.", "passkey_cancelled": "Hai chiuso la richiesta della passkey. Non è stato creato nulla: puoi riprovare.", "list_unreadable": "Non è stato possibile leggere questa lista dalla catena, che non è lo stesso che sia vuota. Controlla la connessione e riprova.", + "election_unreadable": "Non è stato possibile leggere questa elezione dalla catena, che non vuol dire che non esista. Controlla la connessione e riprova.", "passkey_unproven": "La passkey è stata creata, ma non abbiamo ancora potuto confermare che sappia decifrare. Premi di nuovo: viene interrogata la stessa, senza crearne una nuova.", "epoch_already_cast": "Hai già espresso una scheda nell'ultima ora. Conta la tua ultima scheda; potrai cambiarla di nuovo allo scadere dell'ora.", + "epoch_already_cast_until": "Hai già espresso una scheda in quest’ora. Conta la tua ultima scheda; potrai cambiarla di nuovo dalle {{time}}.", + "epoch_wait_title": "Il tuo ultimo voto conta già", "ballot_already_cast": "Questa scheda era già stata registrata. Non devi fare altro: il tuo voto è dentro.", "ballot_expired": "Questa scheda è stata preparata troppo tempo fa per essere accettata. Vota di nuovo; una nuova scheda richiede pochi secondi." }, @@ -886,13 +894,13 @@ "results": "Spoglio e risultati", "from_date": "Dal {{date}}", "ended_early": "Chiusa in anticipo", - "abandoned": "Questa elezione e stata interrotta, quindi questo calendario non e stato rispettato fino alla fine." + "abandoned": "Questa elezione è stata interrotta, quindi questo calendario non è stato rispettato fino alla fine." }, "sort": { - "newest": "Piu recenti prima", - "oldest": "Piu vecchie prima", + "newest": "Più recenti prima", + "oldest": "Più vecchie prima", "closing": "In chiusura prima", - "enrolled": "Piu iscritti", + "enrolled": "Più iscritti", "partial_notice": "ordinate su quanto letto finora" } } diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index 3ab2272..f600a89 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -65,6 +65,8 @@ "retry": "再試行", "close": "閉じる", "confirm": "確認", + "hour": "時", + "minute": "分", "integration_pending": "この機能は統合フェーズで接続されます。", "all": "すべて", "yes": "賛成", @@ -220,7 +222,8 @@ "not_available": "結果はまだ利用できません。", "check_pending": "公開された結果をチェーン上の投票用紙と照合しています…", "check_verified_title": "チェーン上の投票用紙と照合済み", - "check_verified_body": "この選挙は、これらの集計がすべての投票の合計を復号したものであるというゼロ知識証明があって初めて受理しました。さらに、チェーン上の{{ballots}}票をここで改めて合計し、同じ合計になることを確認しました。投票者は{{voters}}人です。誰でも鍵なし・信頼なしでこの確認を再現できます。", + "check_verified_body": "この選挙は、これらの集計がすべての投票の合計を復号したものであるというゼロ知識証明があって初めて受理しました。さらに、チェーン上の{{ballots}}票をここで改めて合計し、同じ合計になることを確認しました。投票者は{{voters}}です。誰でも鍵なし・信頼なしでこの確認を再現できます。", + "voters_count_other": "{{count}}人", "check_failed_title": "チェーン上の投票用紙と一致しません", "check_failed_body": "公開された集計をチェーン上に記録された投票用紙と照合できませんでした。この結果を信頼しないでください。" }, @@ -597,8 +600,11 @@ "wallet_request_rejected": "ウォレットでリクエストをキャンセルしました。何も実行されていません。", "passkey_cancelled": "パスキーの要求を閉じました。何も作成されていません。もう一度お試しください。", "list_unreadable": "この一覧をチェーンから読み取れませんでした。空だったわけではありません。接続を確かめてやり直してください。", + "election_unreadable": "この選挙をチェーンから読み取れませんでした。存在しないわけではありません。接続を確かめてやり直してください。", "passkey_unproven": "パスキーは作成されましたが、復号に使えるかまだ確認できていません。もう一度押してください。同じパスキーに尋ねます。新しいものは作成されません。", "epoch_already_cast": "この1時間以内にすでに投票しています。最新の投票が有効です。1時間経過すると再び変更できます。", + "epoch_already_cast_until": "この1時間のうちにすでに投票しています。最新の投票が有効です。{{time}} から再び変更できます。", + "epoch_wait_title": "最新の投票はすでに有効です", "ballot_already_cast": "この投票はすでに記録されています。これ以上の操作は不要です。あなたの票は届いています。", "ballot_expired": "この投票は準備されてから時間が経ちすぎたため受け付けられません。もう一度投票してください。新しい投票は数秒で作成できます。" }, diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index 06767ea..9ebcaff 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -65,6 +65,8 @@ "retry": "다시 시도", "close": "닫기", "confirm": "확인", + "hour": "시", + "minute": "분", "integration_pending": "이 기능은 통합 단계에서 연결됩니다.", "all": "전체", "yes": "찬성", @@ -220,7 +222,8 @@ "not_available": "결과가 아직 없습니다.", "check_pending": "공개된 결과를 체인상의 투표지와 대조하는 중…", "check_verified_title": "체인상의 투표지와 대조 완료", - "check_verified_body": "이 선거는 이 집계가 모든 투표지의 합을 복호화한 것이라는 영지식 증명이 있을 때만 이를 받아들였고, 체인에 있는 {{ballots}}장의 투표지를 여기서 다시 더해 같은 합을 얻었습니다: 투표자 {{voters}}명. 누구나 키 없이, 신뢰 없이 이 확인을 반복할 수 있습니다.", + "check_verified_body": "이 선거는 이 집계가 모든 투표지의 합을 복호화한 것이라는 영지식 증명이 있을 때만 이를 받아들였고, 체인에 있는 {{ballots}}장의 투표지를 여기서 다시 더해 같은 합을 얻었습니다: 투표자 {{voters}}. 누구나 키 없이, 신뢰 없이 이 확인을 반복할 수 있습니다.", + "voters_count_other": "{{count}}명", "check_failed_title": "체인상의 투표지와 일치하지 않음", "check_failed_body": "공개된 집계를 체인에 기록된 투표지와 대조해 검증할 수 없었습니다. 이 결과를 신뢰하지 마세요." }, @@ -597,8 +600,11 @@ "wallet_request_rejected": "지갑에서 요청을 취소했습니다. 아무 작업도 수행되지 않았습니다.", "passkey_cancelled": "패스키 요청을 닫았습니다. 아무것도 생성되지 않았습니다. 다시 시도할 수 있습니다.", "list_unreadable": "이 목록을 체인에서 읽지 못했습니다. 비어 있는 것과는 다릅니다. 연결을 확인하고 다시 시도하세요.", + "election_unreadable": "이 선거를 체인에서 읽지 못했습니다. 존재하지 않는다는 뜻은 아닙니다. 연결을 확인하고 다시 시도하세요.", "passkey_unproven": "패스키는 생성되었지만 복호화에 쓸 수 있는지 아직 확인하지 못했습니다. 다시 누르세요. 같은 패스키에 물어보며, 새로 만들지 않습니다.", "epoch_already_cast": "지난 한 시간 안에 이미 투표지를 제출했습니다. 가장 최근 투표지가 반영되며, 한 시간이 지나면 다시 변경할 수 있습니다.", + "epoch_already_cast_until": "이번 시간에 이미 투표지를 제출했습니다. 가장 최근 투표지가 반영되며, {{time}}부터 다시 변경할 수 있습니다.", + "epoch_wait_title": "가장 최근 투표가 이미 반영되었습니다", "ballot_already_cast": "이 투표지는 이미 기록되었습니다. 더 할 일은 없습니다. 투표가 반영되었습니다.", "ballot_expired": "이 투표지는 너무 오래전에 준비되어 받아들여질 수 없습니다. 다시 투표해 주세요. 새 투표지는 몇 초면 만들어집니다." }, diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 825d094..405a456 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -65,6 +65,8 @@ "retry": "Opnieuw proberen", "close": "Sluiten", "confirm": "Bevestigen", + "hour": "Uur", + "minute": "Minuut", "integration_pending": "Deze functie wordt verbonden in de integratiefase.", "all": "Alles", "yes": "Ja", @@ -222,7 +224,9 @@ "not_available": "Resultaten nog niet beschikbaar.", "check_pending": "De gepubliceerde uitslag wordt gecontroleerd tegen de stembiljetten op de chain…", "check_verified_title": "Geverifieerd tegen de stembiljetten op de chain", - "check_verified_body": "De verkiezing heeft deze aantallen alleen aanvaard met een zero-knowledge-bewijs dat ze de som van alle stembiljetten ontsleutelen, en de {{ballots}} stembiljetten op de chain zijn hier opnieuw opgeteld tot diezelfde som: {{voters}} kiezers. Iedereen kan deze controle herhalen, zonder sleutel en zonder vertrouwen.", + "check_verified_body": "De verkiezing heeft deze aantallen alleen aanvaard met een zero-knowledge-bewijs dat ze de som van alle stembiljetten ontsleutelen, en de {{ballots}} stembiljetten op de chain zijn hier opnieuw opgeteld tot diezelfde som: {{voters}}. Iedereen kan deze controle herhalen, zonder sleutel en zonder vertrouwen.", + "voters_count_other": "{{count}} kiezers", + "voters_count_one": "{{count}} kiezer", "check_failed_title": "Klopt niet met de stembiljetten op de chain", "check_failed_body": "De gepubliceerde aantallen konden niet worden geverifieerd tegen de stembiljetten op de chain. Vertrouw niet op deze uitslag." }, @@ -602,8 +606,11 @@ "wallet_request_rejected": "Je hebt het verzoek in je wallet geannuleerd. Er is niets gebeurd.", "passkey_cancelled": "Je hebt het passkey-verzoek gesloten. Er is niets aangemaakt: je kunt het opnieuw proberen.", "list_unreadable": "Deze lijst kon niet van de chain gelezen worden, wat niet hetzelfde is als leeg zijn. Controleer de verbinding en probeer het opnieuw.", + "election_unreadable": "Deze verkiezing kon niet van de chain gelezen worden, wat niet betekent dat ze niet bestaat. Controleer de verbinding en probeer het opnieuw.", "passkey_unproven": "De passkey is aangemaakt, maar we konden nog niet bevestigen dat hij kan ontsleutelen. Druk nogmaals: dezelfde wordt gevraagd, er wordt geen nieuwe aangemaakt.", "epoch_already_cast": "Je hebt het afgelopen uur al een stembiljet uitgebracht. Je laatste stembiljet telt; je kunt het opnieuw wijzigen zodra het uur voorbij is.", + "epoch_already_cast_until": "Je hebt dit uur al een stembiljet uitgebracht. Je laatste stembiljet telt; vanaf {{time}} kun je het opnieuw wijzigen.", + "epoch_wait_title": "Je laatste stem telt al", "ballot_already_cast": "Dit stembiljet was al geregistreerd. Je hoeft niets meer te doen: je stem is binnen.", "ballot_expired": "Dit stembiljet is te lang geleden voorbereid om nog te worden aanvaard. Stem opnieuw; een nieuw stembiljet duurt een paar seconden." }, diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 9dbb61d..129cb2d 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -65,6 +65,8 @@ "retry": "Tentar novamente", "close": "Fechar", "confirm": "Confirmar", + "hour": "Hora", + "minute": "Minuto", "integration_pending": "Esta funcionalidade será conectada na fase de integração.", "all": "Tudo", "yes": "Sim", @@ -224,7 +226,10 @@ "not_available": "Resultados ainda não disponíveis.", "check_pending": "A verificar o resultado publicado com os boletins na blockchain…", "check_verified_title": "Verificado com os boletins na blockchain", - "check_verified_body": "A eleição aceitou estas contagens apenas com uma prova de conhecimento zero de que decifram a soma de todos os boletins, e os {{ballots}} boletins na cadeia foram somados de novo aqui até essa mesma soma: {{voters}} votantes. Qualquer pessoa pode repetir esta verificação sem chave e sem confiança.", + "check_verified_body": "A eleição aceitou estas contagens apenas com uma prova de conhecimento zero de que decifram a soma de todos os boletins, e os {{ballots}} boletins na cadeia foram somados de novo aqui até essa mesma soma: {{voters}}. Qualquer pessoa pode repetir esta verificação sem chave e sem confiança.", + "voters_count_one": "{{count}} votante", + "voters_count_other": "{{count}} votantes", + "voters_count_many": "{{count}} votantes", "check_failed_title": "Não corresponde aos boletins na blockchain", "check_failed_body": "Não foi possível verificar as contagens publicadas com os boletins registados na blockchain. Não confie neste resultado." }, @@ -607,8 +612,11 @@ "wallet_request_rejected": "Cancelou o pedido na sua carteira. Não foi feito nada.", "passkey_cancelled": "Fechou o pedido da passkey. Não foi criado nada: pode tentar de novo.", "list_unreadable": "Não foi possível ler esta lista a partir da cadeia, o que não é o mesmo que estar vazia. Verifica a ligação e tenta de novo.", + "election_unreadable": "Não foi possível ler esta eleição a partir da cadeia, o que não quer dizer que não exista. Verifica a ligação e tenta de novo.", "passkey_unproven": "A passkey foi criada, mas ainda não conseguimos confirmar que consegue decifrar. Prima outra vez: é a mesma que será consultada, sem criar nenhuma nova.", "epoch_already_cast": "Já emitiu um boletim na última hora. Conta o seu último boletim; poderá alterá-lo novamente quando passar a hora.", + "epoch_already_cast_until": "Já emitiu um boletim nesta hora. Conta o seu último boletim; poderá alterá-lo novamente a partir das {{time}}.", + "epoch_wait_title": "O seu último voto já conta", "ballot_already_cast": "Este boletim já estava registado. Não precisa de fazer mais nada: o seu voto está lá.", "ballot_expired": "Este boletim foi preparado há demasiado tempo para ser aceite. Vote novamente; um boletim novo demora poucos segundos." }, @@ -878,21 +886,21 @@ "reserved_votes": "Há cerca de {{votes}} votos reservados para esta eleição." }, "timeline": { - "title": "Calendario", + "title": "Calendário", "created": "Criada", - "enrollment": "Inscricao", - "gap": "Entre a inscricao e a votacao", - "voting": "Votacao", + "enrollment": "Inscrição", + "gap": "Entre a inscrição e a votação", + "voting": "Votação", "results": "Apuramento e resultados", "from_date": "A partir de {{date}}", "ended_early": "Encerrada mais cedo", - "abandoned": "Esta eleicao foi interrompida, por isso este calendario nao foi cumprido ate ao fim." + "abandoned": "Esta eleição foi interrompida, por isso este calendário não foi cumprido até ao fim." }, "sort": { "newest": "Mais recentes primeiro", "oldest": "Mais antigas primeiro", "closing": "A fechar mais cedo", "enrolled": "Mais inscritos", - "partial_notice": "ordenadas sobre o que foi lido ate agora" + "partial_notice": "ordenadas sobre o que foi lido até agora" } } diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 77c1ca2..5caf6ef 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -65,6 +65,8 @@ "retry": "Повторить", "close": "Закрыть", "confirm": "Подтвердить", + "hour": "Час", + "minute": "Минута", "integration_pending": "Эта функция будет подключена на этапе интеграции.", "all": "Все", "yes": "Да", @@ -226,7 +228,11 @@ "not_available": "Результаты ещё не доступны.", "check_pending": "Проверяем опубликованный результат по бюллетеням в блокчейне…", "check_verified_title": "Проверено по бюллетеням в блокчейне", - "check_verified_body": "Выборы приняли эти итоги только вместе с доказательством с нулевым разглашением того, что они являются расшифровкой суммы всех бюллетеней, а {{ballots}} бюллетеней в блокчейне были заново сложены здесь в ту же сумму: {{voters}} избирателей. Любой может повторить эту проверку без ключа и без доверия.", + "check_verified_body": "Выборы приняли эти итоги только вместе с доказательством с нулевым разглашением того, что они являются расшифровкой суммы всех бюллетеней, а {{ballots}} бюллетеней в блокчейне были заново сложены здесь в ту же сумму: {{voters}}. Любой может повторить эту проверку без ключа и без доверия.", + "voters_count_many": "{{count}} избирателей", + "voters_count_one": "{{count}} избиратель", + "voters_count_few": "{{count}} избирателя", + "voters_count_other": "{{count}} избирателя", "check_failed_title": "Не совпадает с бюллетенями в блокчейне", "check_failed_body": "Опубликованные итоги не удалось проверить по бюллетеням, записанным в блокчейне. Не полагайтесь на этот результат." }, @@ -612,8 +618,11 @@ "wallet_request_rejected": "Вы отменили запрос в кошельке. Ничего не выполнено.", "passkey_cancelled": "Вы отклонили запрос passkey. Ничего не создано: можно попробовать снова.", "list_unreadable": "Этот список не удалось прочитать из сети, а это не то же самое, что пустой список. Проверьте соединение и повторите.", + "election_unreadable": "Эти выборы не удалось прочитать из сети, но это не значит, что их нет. Проверьте подключение и попробуйте ещё раз.", "passkey_unproven": "Passkey создан, но мы ещё не смогли убедиться, что он умеет расшифровывать. Нажмите ещё раз: спросят тот же самый, новый создаваться не будет.", "epoch_already_cast": "Вы уже подали бюллетень за последний час. Учитывается ваш последний бюллетень; изменить его снова можно будет, когда пройдёт час.", + "epoch_already_cast_until": "Вы уже подали бюллетень в этот час. Учитывается ваш последний бюллетень; изменить его снова можно с {{time}}.", + "epoch_wait_title": "Ваш последний голос уже учтён", "ballot_already_cast": "Этот бюллетень уже был записан. Больше ничего делать не нужно: ваш голос учтён.", "ballot_expired": "Этот бюллетень был подготовлен слишком давно, чтобы его приняли. Проголосуйте ещё раз; новый бюллетень готовится за несколько секунд." }, diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index 5bd4ebe..6fd4d78 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -65,6 +65,8 @@ "retry": "重试", "close": "关闭", "confirm": "确认", + "hour": "时", + "minute": "分", "integration_pending": "此功能将在集成阶段连接。", "all": "全部", "yes": "同意", @@ -220,7 +222,8 @@ "not_available": "结果尚未公布。", "check_pending": "正在将公布的结果与链上的选票进行核对…", "check_verified_title": "已与链上的选票核对", - "check_verified_body": "本次选举只有在附带零知识证明、证明这些计数是全部选票之和的解密结果时才予以接受;链上的 {{ballots}} 张选票也在此处重新相加,得到同样的总和:共 {{voters}} 名投票人。任何人都可以在没有密钥、无需信任的情况下重复这一检查。", + "check_verified_body": "本次选举只有在附带零知识证明、证明这些计数是全部选票之和的解密结果时才予以接受;链上的 {{ballots}} 张选票也在此处重新相加,得到同样的总和:共 {{voters}}。任何人都可以在没有密钥、无需信任的情况下重复这一检查。", + "voters_count_other": "{{count}} 名投票人", "check_failed_title": "与链上的选票不符", "check_failed_body": "无法根据链上记录的选票核实公布的计票结果。请勿依赖此结果。" }, @@ -597,8 +600,11 @@ "wallet_request_rejected": "你在钱包中取消了请求,未执行任何操作。", "passkey_cancelled": "你关闭了通行密钥请求。没有创建任何内容,可以再试一次。", "list_unreadable": "无法从链上读取此列表,这并不等于列表为空。请检查连接后重试。", + "election_unreadable": "无法从链上读取这场选举,这并不表示它不存在。请检查连接后重试。", "passkey_unproven": "通行密钥已创建,但还无法确认它能用于解密。请再按一次:会向同一个密钥提问,不会创建新的。", "epoch_already_cast": "你在过去一小时内已经投过一张选票。以你最新的选票为准;一小时后可以再次更改。", + "epoch_already_cast_until": "你在这一小时内已经投过一张选票。以你最新的选票为准;从 {{time}} 起可以再次更改。", + "epoch_wait_title": "你最新的选票已经生效", "ballot_already_cast": "这张选票已经记录在案。无需再做任何操作:你的投票已被计入。", "ballot_expired": "这张选票准备得太久,已无法被接受。请重新投票;生成新选票只需几秒钟。" }, diff --git a/frontend/src/i18n/plurals.test.ts b/frontend/src/i18n/plurals.test.ts index c8fc0e8..277eeeb 100644 --- a/frontend/src/i18n/plurals.test.ts +++ b/frontend/src/i18n/plurals.test.ts @@ -145,6 +145,7 @@ describe('plural translations', () => { "hi: discover.results_count", "hi: election_mgmt.tally_voters", "hi: gas.votes_remaining", + "hi: results.voters_count", "hi: voter_elections.urgent", "it: voter_elections.urgent", "nl: country_picker.more_results", diff --git a/frontend/src/lib/relay.ts b/frontend/src/lib/relay.ts index 716eb7e..5526e75 100644 --- a/frontend/src/lib/relay.ts +++ b/frontend/src/lib/relay.ts @@ -280,6 +280,13 @@ export function relayErrorMessage(error: unknown): string { // chain as a revert. Matched by name: importing the class here would close an // import cycle through `semaphore.ts`. if ((error as { name?: string } | null)?.name === "EpochAlreadyUsedError") { + // The browser knows exactly when the next hour starts, so it says so + // rather than leaving the voter to guess and try again too early. + const nextAt = (error as { nextAt?: unknown }).nextAt; + if (nextAt instanceof Date && !Number.isNaN(nextAt.getTime())) { + const time = new Intl.DateTimeFormat(i18n.language, { hour: "2-digit", minute: "2-digit" }).format(nextAt); + return i18n.t("errors.epoch_already_cast_until", { time }); + } return i18n.t("errors.epoch_already_cast"); } diff --git a/frontend/src/lib/relayErrors.test.ts b/frontend/src/lib/relayErrors.test.ts index cf53f11..730df8c 100644 --- a/frontend/src/lib/relayErrors.test.ts +++ b/frontend/src/lib/relayErrors.test.ts @@ -120,9 +120,17 @@ describe("relayErrorMessage", () => { } }); - it("explains a second ballot within the hour, caught before any proving", async () => { + it("explains a second ballot within the hour, and says when the next one is allowed", async () => { const { EpochAlreadyUsedError } = await import("./ballot"); - expect(relayErrorMessage(new EpochAlreadyUsedError(new Date()))).toBe(i18n.t("errors.epoch_already_cast")); + const nextAt = new Date("2026-09-27T20:00:00Z"); + const time = new Intl.DateTimeFormat(i18n.language, { hour: "2-digit", minute: "2-digit" }).format(nextAt); + expect(relayErrorMessage(new EpochAlreadyUsedError(nextAt))).toBe( + i18n.t("errors.epoch_already_cast_until", { time }), + ); + }); + + it("still explains the hour when the chain refused it and the time is unknown", () => { + expect(relayErrorMessage({ name: "EpochAlreadyUsedError" })).toBe(i18n.t("errors.epoch_already_cast")); }); it("passes an unrecognised failure through verbatim", () => { diff --git a/frontend/src/pages/ElectionPage.tsx b/frontend/src/pages/ElectionPage.tsx index 253a51d..6260401 100644 --- a/frontend/src/pages/ElectionPage.tsx +++ b/frontend/src/pages/ElectionPage.tsx @@ -8,6 +8,7 @@ import { PageLayout } from '../components/layout/PageLayout'; import { Badge } from '../components/ui/Badge'; import { Button } from '../components/ui/Button'; import { BackButton } from '../components/ui/BackButton'; +import { ElectionUnreadable } from '../components/ui/ElectionUnreadable'; import { useAuth } from '../contexts/AuthContext'; import { useSavedElections } from '../hooks/useSavedElections'; import { usePageMeta } from '../seo/usePageMeta'; @@ -69,7 +70,7 @@ export default function ElectionPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { t } = useTranslation(); - const { election, loading, live, refresh } = useElection(id); + const { election, loading, unreachable, live, refresh } = useElection(id); /** * The schedule can move under a reader who is already here: an organizer who * kept the power can close enrolment or open voting early, and the contract @@ -133,7 +134,7 @@ export default function ElectionPage() { */ const funding = useElectionFunding(election?.contractAddress); // Above the early returns with every other hook, for the reason written there. - const voteCost = useVoteCost(); + const voteCost = useVoteCost(election?.candidates.length); // A slow read must never look like an empty tank, so nothing is blocked while // it is loading, or if the chain refused to answer. const canPayForAVote = @@ -156,6 +157,10 @@ export default function ElectionPage() { ); } + if (!election && unreachable) { + return void refresh()} onBack={() => navigate(-1)} />; + } + if (!election) { return ( @@ -570,10 +575,13 @@ export default function ElectionPage() { {/* Shortened for display and copied in full, the same treatment the confirmation screen gives it. A reference is one unbroken hex token, so at full length it does not wrap and simply leaves the - card on a phone. */} + card on a phone. + Labelled as the ballot's identifier, as the receipt page labels + it: "reference" everywhere else is the transaction hash, and two + different numbers under one name read as two different ballots. */}

- {t('election.reference')}:{' '} + {t('verify_receipt.nullifier')}:{' '} {shortenReference(election.ballotTag ?? '')}

From b29f0322aababd68bd125f0fbd29c81b8aad40d6 Mon Sep 17 00:00:00 2001 From: CodeInIA Date: Sun, 27 Sep 2026 21:54:39 +0200 Subject: [PATCH 21/32] fix(frontend): steady local RPC proxy, and no chain reads for demo elections Found running the smoke suite against a configured local chain: - The dev proxy reused a socket the Hardhat node had just closed as idle, and the read came back as ECONNRESET: an election page failed or waited for good about one load in three. The /rpc proxy no longer keeps connections alive. The smoke suite went from 4 failures in 12 runs of the election pages to 72/72 on all three devices. - The demo elections' placeholder addresses (0xCon1aaa) passed the 0x check in useElectionFunding, so ethers took them for ENS names and logged an error on every render. Only a whole address is read now. --- frontend/src/hooks/useElectionFunding.ts | 5 ++++- frontend/vite.config.ts | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/useElectionFunding.ts b/frontend/src/hooks/useElectionFunding.ts index e76ee53..a351feb 100644 --- a/frontend/src/hooks/useElectionFunding.ts +++ b/frontend/src/hooks/useElectionFunding.ts @@ -26,7 +26,10 @@ export interface ElectionFundingState { } export function useElectionFunding(election: string | undefined): ElectionFundingState { - const live = isChainConfigured() && Boolean(election?.startsWith('0x')); + // A whole address, not just a 0x prefix: the demo elections carry + // placeholders like `0xCon1aaa`, which ethers took for an ENS name and + // failed on every render once a chain was configured. + const live = isChainConfigured() && /^0x[0-9a-fA-F]{40}$/.test(election ?? ''); const [reserved, setReserved] = useState(0); const [free, setFree] = useState(0); const [loading, setLoading] = useState(live); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f82f461..c92f0e9 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,3 +1,4 @@ +import { Agent } from 'node:http' import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' @@ -25,6 +26,11 @@ const TUNNEL_HOSTS = ['.ngrok-free.dev', '.ngrok.io', '.ngrok.app', '.trycloudfl * * `ws: true` on the RPC because a provider may upgrade to a subscription, and a * proxy that drops the upgrade fails in a way that looks like a hung page. + * + * NO KEEP-ALIVE TO THE NODE. The proxy reused a socket the Hardhat node had + * just closed as idle, and the read on it came back as ECONNRESET: a page that + * failed, or waited for good, about one load in three under a test run. A + * fresh connection per request costs nothing on localhost. */ const LOCAL_SERVICES = { '/api': { target: 'http://127.0.0.1:3000', changeOrigin: false }, @@ -32,6 +38,7 @@ const LOCAL_SERVICES = { target: 'http://127.0.0.1:8545', changeOrigin: false, ws: true, + agent: new Agent({ keepAlive: false }), rewrite: (path: string) => path.replace(/^\/rpc/, '') || '/', }, } From 6822cd923b462e8f9b7ae0a7013d96467dda2814 Mon Sep 17 00:00:00 2001 From: CodeInIA Date: Mon, 28 Sep 2026 15:01:38 +0200 Subject: [PATCH 22/32] fix(frontend): scale an unmeasured circuit size by its circuit, not its options voteCostFor scaled the largest measured size by the election's option count. With only five-slot ballots measured, a six-option election came out at 6/5 of their cost, though it proves with the nine-slot circuit: an underestimate, the opposite of what a figure shown as "ballots paid for" may be. It now works out the circuit the election proves with, from the sizes build.mjs ships. --- frontend/src/lib/voteCost.test.ts | 13 +++++++++++++ frontend/src/lib/voteCost.ts | 30 ++++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/voteCost.test.ts b/frontend/src/lib/voteCost.test.ts index 4b644d6..b553aed 100644 --- a/frontend/src/lib/voteCost.test.ts +++ b/frontend/src/lib/voteCost.test.ts @@ -185,6 +185,19 @@ describe('the cost per circuit size', () => { expect(voteCostFor(cost, 3)).toBeCloseTo(0.001); }); + it('scales to the circuit the election proves with, not to its option count', async () => { + // Only five-slot ballots so far. Six options prove with the nine-slot + // circuit, so the quote is nine fifths of the measured cost: scaling by the + // option count (six fifths) promised ballots the reserve could not pay. + relay('0x1', 1_000_000n, 1_000_000_000n, VOTE, 5); + + const { fetchVoteCost, voteCostFor, circuitSizeFor } = await import('./voteCost'); + const cost = await fetchVoteCost(); + + expect(circuitSizeFor(6)).toBe(9); + expect(voteCostFor(cost, 6)).toBeCloseTo(0.001 * (9 / 5)); + }); + it('keeps the platform-wide figure when no election is named', async () => { const { voteCostFor, ASSUMED } = await import('./voteCost'); expect(voteCostFor(ASSUMED, 6)).toBe(ASSUMED.matic); diff --git a/frontend/src/lib/voteCost.ts b/frontend/src/lib/voteCost.ts index ed3cb13..f0e062f 100644 --- a/frontend/src/lib/voteCost.ts +++ b/frontend/src/lib/voteCost.ts @@ -60,21 +60,35 @@ export const ASSUMED: VoteCost = { samples: 0, }; +/** + * The circuit sizes a deployment can have, in slots. Mirrors `ALL_SIZES` in + * circuits/scripts/build.mjs: a deployment ships a subset of these, and the + * factory gives an election the smallest one it fits in. + */ +export const CIRCUIT_SIZES = [5, 9, 17, 33, 51] as const; + +/** The circuit an election with `options` choices, blank included, proves with. */ +export function circuitSizeFor(options: number): number { + return CIRCUIT_SIZES.find(size => size >= options) ?? CIRCUIT_SIZES[CIRCUIT_SIZES.length - 1]; +} + /** * What one ballot costs in an election with `options` choices, blank included. * - * The factory gives an election the smallest circuit it fits in, so the - * smallest measured size that fits is the one its ballots use. A size nobody - * has voted in yet is scaled from the nearest measured one, and only ever - * upwards: a figure shown as "ballots paid for" errs towards enough. + * Its own circuit size when that was measured. Otherwise the next larger + * measured size, or the largest one scaled up to this circuit's slots: a size + * nobody has voted in yet is only ever quoted high, because a figure shown as + * "ballots paid for" has to err towards enough. */ export function voteCostFor(cost: VoteCost, options: number | undefined): number { - const sizes = Object.keys(cost.bySlots ?? {}).map(Number).sort((a, b) => a - b); + const measured = cost.bySlots ?? {}; + const sizes = Object.keys(measured).map(Number).sort((a, b) => a - b); if (!options || sizes.length === 0) return cost.matic; - const fits = sizes.find(size => size >= options); - if (fits !== undefined) return cost.bySlots![fits]; + const circuit = circuitSizeFor(options); + const fits = sizes.find(size => size >= circuit); + if (fits !== undefined) return measured[fits]; const largest = sizes[sizes.length - 1]; - return cost.bySlots![largest] * (options / largest); + return measured[largest] * (circuit / largest); } /** Most recent relays to look at. Enough to be steady, few enough to be cheap. */ From d1651b06a988b13b749c4533d3ef21933f01e670 Mon Sep 17 00:00:00 2001 From: CodeInIA Date: Mon, 28 Sep 2026 15:01:38 +0200 Subject: [PATCH 23/32] feat(frontend): replace a ballot straight away through the previous epoch The contract accepts a ballot naming the current epoch or the last one, each tag once, but the app named only the current one. A voter who had just cast waited for the next clock hour, and a vote forced after the last hour boundary before the close could not be replaced at all. chooseEpoch now uses both. When both are free it draws one at random: the epoch is public calldata, and naming the previous one only once the current one was spent would mark the ballot as a re-vote, in the last hour, where a coercer watches. Inside two minutes of the turn the previous epoch is skipped, since the proof would arrive stale. --- frontend/src/lib/ballot.test.ts | 61 ++++++++++++++++++++++++++++++ frontend/src/lib/ballot.ts | 67 ++++++++++++++++++++++++++++++--- 2 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/ballot.test.ts diff --git a/frontend/src/lib/ballot.test.ts b/frontend/src/lib/ballot.test.ts new file mode 100644 index 0000000..93a1a84 --- /dev/null +++ b/frontend/src/lib/ballot.test.ts @@ -0,0 +1,61 @@ +/** + * Which epoch a ballot names. + * + * The contract accepts the current epoch or the one that just ended. Naming the + * previous one is what lets a voter replace a vote forced on them without + * waiting for the next clock hour, and choosing between the two AT RANDOM is + * what keeps that from showing: a ballot naming the previous epoch must not + * mean "this voter already cast this hour". + */ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('./contracts', () => ({ getElection: () => ({}), getReadProvider: () => ({}) })); +vi.mock('./semaphore', () => ({ fetchElectionGroup: async () => ({}) })); + +import { chooseEpoch, EpochAlreadyUsedError, PREVIOUS_EPOCH_MARGIN_SECONDS } from './ballot'; + +const HOUR = 3600n; +const CURRENT = 490_000n; +/** Ten minutes into the current epoch: plenty of time to prove. */ +const EARLY = CURRENT * HOUR + 600n; +/** One minute before the epoch turns: too late for the previous one. */ +const LATE = (CURRENT + 1n) * HOUR - 60n; + +const base = { current: CURRENT, epochLength: HOUR, now: EARLY, currentUsed: false, previousUsed: false }; + +describe('chooseEpoch', () => { + it('draws between the two when both are free, so neither says anything', () => { + expect(chooseEpoch({ ...base, coin: () => true })).toBe(CURRENT - 1n); + expect(chooseEpoch({ ...base, coin: () => false })).toBe(CURRENT); + }); + + it('gives a voter who just cast an immediate second ballot', () => { + // The case this exists for: a vote forced on the voter used the current + // epoch, and the override takes the previous one rather than waiting. + expect(chooseEpoch({ ...base, currentUsed: true })).toBe(CURRENT - 1n); + expect(chooseEpoch({ ...base, previousUsed: true })).toBe(CURRENT); + }); + + it('refuses once both are spent, and says when the next one opens', () => { + try { + chooseEpoch({ ...base, currentUsed: true, previousUsed: true }); + expect.unreachable(); + } catch (e) { + expect(e).toBeInstanceOf(EpochAlreadyUsedError); + expect((e as EpochAlreadyUsedError).nextAt.getTime()).toBe(Number((CURRENT + 1n) * HOUR) * 1000); + } + }); + + it('never names the previous epoch too close to the turn, where it would arrive stale', () => { + // LATE is inside the margin, which is what makes this case the one it is. + expect((CURRENT + 1n) * HOUR - LATE <= PREVIOUS_EPOCH_MARGIN_SECONDS).toBe(true); + // Both free: the coin is not even asked. + expect(chooseEpoch({ ...base, now: LATE, coin: () => true })).toBe(CURRENT); + // Current spent: the voter waits the minute rather than proving for nothing. + expect(() => chooseEpoch({ ...base, now: LATE, currentUsed: true })).toThrow(EpochAlreadyUsedError); + }); + + it('has no previous epoch at the very start of time', () => { + expect(chooseEpoch({ ...base, current: 0n, now: 600n, coin: () => true })).toBe(0n); + }); +}); diff --git a/frontend/src/lib/ballot.ts b/frontend/src/lib/ballot.ts index 8e57a2e..cf28aef 100644 --- a/frontend/src/lib/ballot.ts +++ b/frontend/src/lib/ballot.ts @@ -30,7 +30,7 @@ import { type Point, type SolidityProof, } from "./ballotCrypto"; -import { getElection } from "./contracts"; +import { getElection, getReadProvider } from "./contracts"; import { eventArgs, queryLogsFrom } from "./logs"; import { fetchElectionGroup } from "./semaphore"; @@ -115,6 +115,52 @@ export class EpochAlreadyUsedError extends Error { } } +/** + * How long before the epoch turns a proof naming the PREVIOUS epoch is still + * worth making: proving and relaying take seconds, and once the epoch turns the + * contract refuses it as stale. Inside this margin the voter waits instead, + * never more than this long, and the new epoch is free. + */ +export const PREVIOUS_EPOCH_MARGIN_SECONDS = 120n; + +/** + * Which epoch a ballot names: the current one, or the one that just ended. + * + * The contract accepts either, each tag once, which is two ballots per voter in + * any hour. The app used to name only the current epoch, so a voter who had + * just cast waited for the next clock hour, and a vote forced after the last + * hour boundary before the close could not be replaced at all. Naming the + * previous epoch gives that voter one immediate override. + * + * CHOSEN AT RANDOM WHEN BOTH ARE FREE, and that is the privacy argument, not a + * detail. The epoch is public calldata. Were the previous epoch used only once + * the current one was spent, a ballot naming it would say "this voter already + * cast this hour": a re-vote, visible, in exactly the last hour where a coercer + * is watching. Drawn at random, a first ballot names either epoch half the + * time, a re-vote names whichever is left, and no single ballot says which it + * is. + */ +export function chooseEpoch(args: { + current: bigint; + epochLength: bigint; + /** Chain time, in seconds: the clock the contract judges staleness by. */ + now: bigint; + currentUsed: boolean; + previousUsed: boolean; + /** Injected for tests; a fair coin otherwise. */ + coin?: () => boolean; +}): bigint { + const { current, epochLength, now, currentUsed, previousUsed } = args; + const coin = args.coin ?? (() => crypto.getRandomValues(new Uint8Array(1))[0] < 128); + const nextAt = (current + 1n) * epochLength; + const previousUsable = current > 0n && !previousUsed && nextAt - now > PREVIOUS_EPOCH_MARGIN_SECONDS; + + if (!currentUsed && previousUsable) return coin() ? current - 1n : current; + if (!currentUsed) return current; + if (previousUsable) return current - 1n; + throw new EpochAlreadyUsedError(new Date(Number(nextAt) * 1000)); +} + /** * Where the proving artefacts for one circuit size are served from. * @@ -154,18 +200,29 @@ export async function prepareBallot( const context = await readBallotContext(address); const secret = identity.secretScalar; - const [ballots, group, epoch, epochLength] = await Promise.all([ + const [ballots, group, current, epochLength, block] = await Promise.all([ fetchBallots(address, context.keys.length), fetchElectionGroup(address), election.currentEpoch() as Promise, election.EPOCH_LENGTH() as Promise, + getReadProvider().getBlock("latest"), ]); // Checked before proving, because the proof would be refused anyway and it // is seconds of work the voter should not wait through for nothing. - if (await election.usedEpochTags(epochTag(poseidon4, secret, context.scope, epoch))) { - throw new EpochAlreadyUsedError(new Date(Number((epoch + 1n) * epochLength) * 1000)); - } + const [currentUsed, previousUsed] = await Promise.all([ + election.usedEpochTags(epochTag(poseidon4, secret, context.scope, current)) as Promise, + current > 0n + ? (election.usedEpochTags(epochTag(poseidon4, secret, context.scope, current - 1n)) as Promise) + : Promise.resolve(true), + ]); + const epoch = chooseEpoch({ + current, + epochLength, + now: BigInt(block?.timestamp ?? Math.floor(Date.now() / 1000)), + currentUsed, + previousUsed, + }); const memberIndex = group.indexOf(identity.commitment); if (memberIndex < 0) throw new Error("This identity is not enrolled in this election"); From 453879fa0d333481e0eafdd9ba5cf55e38a84966 Mon Sep 17 00:00:00 2001 From: CodeInIA Date: Mon, 28 Sep 2026 15:01:38 +0200 Subject: [PATCH 24/32] docs(frontend): terms, how it works and privacy say what the contracts do - Terms said one gas tank pays for all of an organizer's elections and that voting stops in every one when it empties. Since the per-election reserve (2026-09-16) each election pays from its own reserve first, then from the shared free balance, and only that election stops. - "Change your vote whenever you want" was never true under the hourly limit. How it works, privacy and the limit messages now say it: straight away once, then once an hour, and nobody watching can tell. - Privacy states the limit that remains: two votes forced in a row in the last hour before the close cannot be replaced. - Page dates follow their content. --- frontend/src/i18n/locales/ar.json | 17 +++++++++-------- frontend/src/i18n/locales/de.json | 17 +++++++++-------- frontend/src/i18n/locales/en.json | 17 +++++++++-------- frontend/src/i18n/locales/es.json | 17 +++++++++-------- frontend/src/i18n/locales/fr.json | 17 +++++++++-------- frontend/src/i18n/locales/hi.json | 17 +++++++++-------- frontend/src/i18n/locales/it.json | 17 +++++++++-------- frontend/src/i18n/locales/ja.json | 17 +++++++++-------- frontend/src/i18n/locales/ko.json | 17 +++++++++-------- frontend/src/i18n/locales/nl.json | 17 +++++++++-------- frontend/src/i18n/locales/pt.json | 17 +++++++++-------- frontend/src/i18n/locales/ru.json | 17 +++++++++-------- frontend/src/i18n/locales/zh.json | 17 +++++++++-------- frontend/src/pages/public/Privacy.tsx | 2 +- 14 files changed, 118 insertions(+), 105 deletions(-) diff --git a/frontend/src/i18n/locales/ar.json b/frontend/src/i18n/locales/ar.json index 46a2105..9c9f5b3 100644 --- a/frontend/src/i18n/locales/ar.json +++ b/frontend/src/i18n/locales/ar.json @@ -213,8 +213,8 @@ "step1_desc": "يمنحك World ID حسابًا دون كشف هويتك. أما الانتخاب الذي يشترط صوتًا واحدًا لكل شخص فيطلب أيضًا فحص مستند على هاتفك أنت، ولا يكشف هذا الفحص أي شيء آخر.", "step2_title": "صوّت بخصوصية", "step2_desc": "يُشفَّر صوتك على جهازك، ويجعل برهان المعرفة الصفرية احتسابه ممكنًا دون كشف اختيارك. كما يرى كل انتخاب تشارك فيه هوية مختلفة، فلا تكشف السلسلة أبدًا في أي انتخابات شاركت.", - "step3_title": "غيّر صوتك في أي وقت", - "step3_desc": "في حالة الإكراه، يمكنك التصويت مرة أخرى لاحقاً. يُحسب صوتك الأخير فقط.", + "step3_title": "غيّر صوتك دون أن يلاحظ أحد", + "step3_desc": "إذا أُكرهت على التصويت، يمكنك التصويت مجددًا: مرة فورًا، ثم مرة كل ساعة. لا يُحتسب إلا صوتك الأخير، ولا يستطيع أحد يراقب السلسلة أن يعرف أنك غيّرته.", "step4_title": "تحقق من النتائج", "step4_desc": "كل صوت مشفَّر والنتيجة النهائية موجودان على سلسلة الكتل. يمكن لأي شخص إعادة العدّ دون كسر خصوصية أحد.", "built_on": "مبني على", @@ -632,8 +632,8 @@ "list_unreadable": "تعذّرت قراءة هذه القائمة من السلسلة، وهذا ليس كأنها فارغة. تحقق من الاتصال ثم أعد المحاولة.", "election_unreadable": "تعذّرت قراءة هذه الانتخابات من السلسلة، وهذا لا يعني أنها غير موجودة. تحقق من الاتصال ثم حاول مرة أخرى.", "passkey_unproven": "أُنشئ مفتاح المرور، لكننا لم نتأكد بعد من قدرته على فكّ التشفير. اضغط مرة أخرى: سُيسأل المفتاح نفسه، دون إنشاء مفتاح جديد.", - "epoch_already_cast": "لقد أدليت ببطاقة اقتراع خلال الساعة الأخيرة. آخر بطاقة لك هي التي تُحتسب، ويمكنك تغييرها مرة أخرى بعد انقضاء الساعة.", - "epoch_already_cast_until": "لقد أدليت ببطاقة اقتراع خلال هذه الساعة. آخر بطاقة لك هي التي تُحتسب، ويمكنك تغييرها مرة أخرى ابتداءً من {{time}}.", + "epoch_already_cast": "لقد غيّرت صوتك منذ وقت قصير جدًا. تُحتسب بطاقتك الأخيرة؛ يمكنك تغييرها مجددًا خلال ساعة.", + "epoch_already_cast_until": "لقد غيّرت صوتك منذ وقت قصير جدًا. تُحتسب بطاقتك الأخيرة؛ يمكنك تغييرها مجددًا ابتداءً من {{time}}.", "epoch_wait_title": "صوتك الأخير هو المحتسب", "ballot_already_cast": "هذه البطاقة مسجّلة بالفعل. لا حاجة لفعل أي شيء آخر: صوتك محتسب.", "ballot_expired": "أُعدّت هذه البطاقة منذ وقت طويل جدًا فلم تعد مقبولة. يُرجى التصويت مجددًا؛ إعداد بطاقة جديدة يستغرق بضع ثوانٍ." @@ -686,7 +686,7 @@ }, "terms": { "title": "شروط الاستخدام", - "updated": "آخر تحديث: 3 سبتمبر 2026", + "updated": "آخر تحديث: 28 سبتمبر 2026", "what_title": "ما هو Votain", "what_1": "‏Votain مشروع تخرج جامعي: تطبيق تصويت مجهول قابل للتحقق من طرف إلى طرف، مبني على شبكة الاختبار Polygon Amoy. إنه نموذج بحثي أولي وليس خدمة تجارية.", "what_2": "يعمل على شبكة اختبار. الرموز المستخدمة فيه لا قيمة مالية لها، والشبكة لا تضمن الاستمرارية: فشبكات الاختبار تُعاد تهيئتها أو تُغلق من حين لآخر، وقد يزول معها أي انتخاب أُجري هنا.", @@ -698,7 +698,7 @@ "identity_2": "من يملك العبارة يستطيع استعادة الهوية على أي جهاز، بلا مفتاح مرور وبلا مساعدة منا. وإذا فقدت العبارة وكل مفاتيح المرور التي كانت تحفظها، فإن التحقق من جديد عبر World ID يتيح لك البدء من الصفر. وهذه البداية محدودة عن قصد: يمكنك الانضمام إلى انتخابات لم تكن قد سجّلت فيها، لكن لا يمكنك أبداً العودة إلى انتخاب كنت مسجّلاً فيه، لأن النظام لا يستطيع معرفة ما إذا كنت قد صوّت هناك دون كسر سرية الصوت.", "organizer_title": "إذا كنت تنظم انتخابًا", "organizer_1": "أنت مسؤول عما تنشره على السلسلة. تُخزَّن الأسماء والأوصاف وقوائم المرشحين بشكل علني ودائم، ولا يمكن تعديلها أو حذفها بعد النشر. لا تُدرج فيها بيانات شخصية تخص الآخرين.", - "organizer_2": "أنت تموّل رصيد غاز واحدًا يدفع رسوم معاملات الناخبين في جميع انتخاباتك. وإذا نفد، توقف التصويت في كل واحد منها حتى تعيد تعبئته.", + "organizer_2": "يدفع كل اقتراع رسوم ناخبيه أولًا من الغاز الذي حجزته له، ولا يمكنك سحبه حتى ينتهي، ثم من رصيدك الحر الذي تتشاركه كل انتخاباتك. إذا نفد الاثنان يتوقف التصويت في ذلك الاقتراع حتى تعيد التعبئة.", "warranty_title": "بلا ضمان", "warranty_1": "يُقدَّم Votain كما هو، دون أي ضمان من أي نوع. ولم يخضع لتدقيق مستقل. وقد يحتوي على عيوب تؤثر على صحة التصويت أو توافره أو خصوصيته.", "warranty_2": "إلى أقصى حد يسمح به القانون، لا يتحمل المؤلف أي مسؤولية عن خسارة أو ضرر ناشئ عن الاستخدام.", @@ -707,7 +707,7 @@ }, "privacy": { "title": "الخصوصية", - "updated": "آخر تحديث: 16 سبتمبر 2026", + "updated": "آخر تحديث: 28 سبتمبر 2026", "summary_title": "باختصار", "summary_1": "لا نعرف أبدًا اسمك أو وجهك أو بياناتك الحيوية. لا نعرف كيف صوّتّ. تُشفَّر البطاقات قبل أن تغادر جهازك ولا يُفك إلا تشفير المجموع النهائي؛ يستطيع مفتاح المنظّم فتح بطاقة واحدة، لكن لا شيء فيها يدل على صاحبها.", "summary_2": "تصف هذه الصفحة ما يفعله البرنامج فعلًا لا ما يُسمح له بفعله، بما في ذلك المواضع التي لا يحميك فيها التصميم.", @@ -719,13 +719,14 @@ "identity_2": "ليست هذه مسألة شكلية. فلو كان بوسعنا فك تشفيره، لأمكننا حساب المُعرِّف الذي تحمله ورقتك وربط كل صوت بمن أدلى به. التشفير هو ما يجعل ذلك مستحيلًا علينا، لا ما يمنعنا منه فحسب.", "ballot_title": "ورقتك", "ballot_1": "يُشفَّر اختيارك في متصفحك قبل إرساله بمفاتيح يحتفظ بها المنظّم. تجمع الانتخابات كل البطاقات وهي مشفّرة، ولا يُفك إلا تشفير هذا المجموع، مع إثبات يتحقق منه العقد. تستطيع هذه المفاتيح فتح أي بطاقة منفردة، ولهذا لا يوجد في البطاقة ما يدل على من أدلى بها ولا على أي من بطاقاتك سبقها.", - "ballot_2": "يمكنك التصويت من جديد كلما شئت، مرة في الساعة؛ ولا تُحتسب إلا بطاقتك الأخيرة. كل بطاقة جديدة تلغي سابقتها بصمت، ويبدو التصويت الأول والتصويت المعدَّل متطابقين تمامًا على السلسلة، فلا يستطيع أي مراقب معرفة أنك صوّتّ مرة أخرى. وهذا ما يتيح لك أن تستبدل بهدوء صوتًا أُجبرت عليه.", + "ballot_2": "يمكنك التصويت مجددًا كلما شئت: مرة فورًا، ثم مرة كل ساعة. لا تُحتسب إلا بطاقتك الأخيرة. كل بطاقة جديدة تُلغي السابقة بصمت، والصوت الأول والصوت المعدَّل متطابقان تمامًا على السلسلة، فلا يستطيع أي مراقب أن يعرف أنك صوّت مجددًا. هذا ما يتيح لك أن تستبدل بهدوء صوتًا أُجبرت على الإدلاء به.", "onchain_title": "ما هو علني على سلسلة الكتل", "onchain_1": "علني ودائم: ما الانتخابات القائمة، وأن هوية ما مسجّلة في المنصة، وأن شخصًا تحقّقت منه المنصة قد سجّل في كل انتخاب، والأصوات المشفّرة مع مجاميعها المنشورة، والنسخ المختومة من كل هوية تصويت، نسخة لكل مفتاح مرور مسجّل. ما يُسجَّل هو التزام مشتق لذلك الانتخاب وحده، فلا تكشف السلسلة في أي انتخابات شارك الشخص نفسه. يستطيع أي شخص قراءة ذلك كله. وهذه النسخ نصّ مشفر لا يُفتح إلا بمفتاح المرور الذي أنشأه، لكن وجودها وعددها ظاهران.", "onchain_2": "غير علني: الصلة بين هوية مسجَّلة وورقة بعينها، ولا الصلة بين تسجيلات الشخص نفسه في انتخابات مختلفة. وقطع الأولى هو تحديدًا الغرض من برهان المعرفة الصفرية، وهو سبب إرسال كل صوت عبر عقد مشترك واحد بدل عنوان يخصك وحدك. والثانية هي سبب اشتقاق ما يُسجَّل لكل انتخاب على حدة بدل إعادة استخدامه.", "limits_title": "ما لا يحميك منه هذا", "limits_1": "عند إرسال صوتك، يصل الطلب إلى خادمنا عبر اتصالك بالشبكة، فيكون مصدره مرئيًا لنا كما هو لأي موقع تستخدمه. ونحن لا نستخدمه للتعرف على الناخبين، لكن التصميم لا يمنعنا من ذلك، ويجدر بك معرفة هذا قبل أن تعتمد على سرية ورقتك.", "limits_2": "الجهة المُصدِرة أيضًا موضع ثقة: فهي تتحقق من براهين World ID وتسجّل الناخبين وتوقّع كل تسجيل، ما يجعلها الطرف الوحيد القادر على معرفة الانتخابات التي شاركت فيها. وبوسع جهة مُصدِرة غير أمينة أن تسجّل ناخبين غير موجودين. لا تستطيع قراءة ورقتك ولا تغيير الفرز، لكنك تعتمد على أمانتها في تحديد من يحق له التصويت وعلى تكتّمها بشأن أين صوّتّ.", + "limits_3": "يمكنك الإدلاء ببطاقتين على الأكثر خلال ساعة واحدة: هذا هو الحد الذي يمنع أي شخص من استنزاف الغاز الذي يدفعه المنظِّم. لذلك يمكن دائمًا استبدال صوت أُجبرت عليه مرة واحدة فورًا؛ لكن إذا أُجبرت على التصويت مرتين متتاليتين في الساعة الأخيرة قبل الإغلاق، فلن تتمكن من استبدال هذين الصوتين.", "retention_title": "الاحتفاظ والحذف", "retention_1": "لا نحتفظ بشيء على خوادمنا: جلستك تعيش في ملف تعريف ارتباط داخل متصفحك، وكل ما هو دائم موجود على سلسلة الكتل. وإزالة مفتاح مرور تحذف نسخته المختومة من السلسلة؛ أما إزالة الأخير فمرفوضة، لأنها تترك هويتك بلا فائدة إلى الأبد.", "retention_2": "ما كُتب على سلسلة الكتل لا يمكننا ولا يمكن لأحد محوه. هذه سمة ملازمة لأي سجل علني، وهي نفسها السمة التي تتيح لك التحقق من أن صوتك قد أُحصي." diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 91f22c7..5657fb1 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -205,8 +205,8 @@ "step1_desc": "World ID gibt Ihnen ein Konto, ohne zu verraten, wer Sie sind. Eine Wahl, die eine Stimme pro Person verlangt, fordert zusätzlich eine Dokumentprüfung auf Ihrem eigenen Telefon, die sonst nichts preisgibt.", "step2_title": "Privat abstimmen", "step2_desc": "Ihre Stimme wird auf Ihrem Gerät verschlüsselt, und ein Zero-Knowledge-Beweis sorgt dafür, dass sie gezählt wird, ohne Ihre Wahl zu verraten. Jede Wahl, an der Sie teilnehmen, sieht zudem eine andere Identität, sodass die Kette nie sagt, an welchen Sie teilgenommen haben.", - "step3_title": "Stimme jederzeit ändern", - "step3_desc": "Bei Nötigung können Sie später erneut abstimmen. Nur Ihre letzte Stimme zählt.", + "step3_title": "Ändere deine Stimme, ohne dass es jemand merkt", + "step3_desc": "Wirst du unter Druck gesetzt, kannst du erneut abstimmen: einmal sofort und danach einmal pro Stunde. Nur deine letzte Stimme zählt, und niemand, der die Chain beobachtet, kann sehen, dass du sie geändert hast.", "step4_title": "Ergebnisse verifizieren", "step4_desc": "Jede verschlüsselte Stimme und das Endergebnis liegen auf der Blockchain. Jeder kann nachzählen, ohne die Anonymität von irgendjemandem zu brechen.", "built_on": "Aufgebaut auf", @@ -608,8 +608,8 @@ "list_unreadable": "Diese Liste konnte nicht von der Chain gelesen werden, was nicht dasselbe ist wie leer zu sein. Prüfen Sie die Verbindung und versuchen Sie es erneut.", "election_unreadable": "Diese Wahl konnte nicht von der Chain gelesen werden, was nicht heißt, dass es sie nicht gibt. Prüfen Sie die Verbindung und versuchen Sie es erneut.", "passkey_unproven": "Der Passkey wurde erstellt, aber wir konnten noch nicht bestätigen, dass er entschlüsseln kann. Drück noch einmal: es wird derselbe gefragt, ohne einen neuen zu erstellen.", - "epoch_already_cast": "Sie haben in der letzten Stunde bereits einen Stimmzettel abgegeben. Es zählt Ihr letzter Stimmzettel; nach Ablauf der Stunde können Sie ihn erneut ändern.", - "epoch_already_cast_until": "Sie haben in dieser Stunde bereits einen Stimmzettel abgegeben. Es zählt Ihr letzter Stimmzettel; ab {{time}} können Sie ihn erneut ändern.", + "epoch_already_cast": "Du hast deine Stimme erst vor Kurzem geändert. Es zählt dein letzter Stimmzettel; innerhalb einer Stunde kannst du ihn erneut ändern.", + "epoch_already_cast_until": "Du hast deine Stimme erst vor Kurzem geändert. Es zählt dein letzter Stimmzettel; ab {{time}} kannst du ihn erneut ändern.", "epoch_wait_title": "Ihre letzte Stimme zählt bereits", "ballot_already_cast": "Dieser Stimmzettel wurde bereits erfasst. Sie müssen nichts weiter tun: Ihre Stimme ist abgegeben.", "ballot_expired": "Dieser Stimmzettel wurde vor zu langer Zeit vorbereitet, um noch angenommen zu werden. Bitte stimmen Sie erneut ab; ein neuer Stimmzettel dauert nur wenige Sekunden." @@ -662,7 +662,7 @@ }, "terms": { "title": "Nutzungsbedingungen", - "updated": "Zuletzt aktualisiert: 3. September 2026", + "updated": "Zuletzt aktualisiert: 28. September 2026", "what_title": "Was Votain ist", "what_1": "Votain ist eine Bachelorarbeit: eine Ende-zu-Ende überprüfbare, anonyme Wahlanwendung auf dem Polygon-Amoy-Testnetz. Es ist ein Forschungsprototyp, kein kommerzieller Dienst.", "what_2": "Es läuft auf einem TESTNETZ. Die verwendeten Token haben keinen Geldwert, und das Netz garantiert keinen Fortbestand: Testnetze werden gelegentlich zurückgesetzt oder eingestellt, und jede hier durchgeführte Wahl kann mit ihnen verschwinden.", @@ -674,7 +674,7 @@ "identity_2": "Wer die Phrase hat, kann die Identität auf jedem Gerät wiederherstellen, ohne Passkey und ohne unsere Hilfe. Verlieren Sie die Phrase und jeden Passkey, der sie hielt, können Sie mit einer erneuten World-ID-Verifizierung von vorn beginnen. Dieser Neuanfang ist bewusst begrenzt: Sie können Wahlen beitreten, für die Sie noch nicht eingeschrieben waren, aber nie wieder eine betreten, in der Sie es schon waren, denn das System kann nicht feststellen, ob Sie dort bereits gewählt haben, ohne die Anonymität der Stimme zu brechen.", "organizer_title": "Wenn Sie eine Wahl organisieren", "organizer_1": "Sie sind für das verantwortlich, was Sie auf die Blockchain schreiben. Namen, Beschreibungen und Kandidatenlisten werden öffentlich und dauerhaft gespeichert und lassen sich nach der Veröffentlichung weder ändern noch löschen. Nehmen Sie darin keine personenbezogenen Daten Dritter auf.", - "organizer_2": "Sie füllen einen einzigen Gas-Vorrat, der die Transaktionsgebühren der Wählenden ALLER Ihrer Wahlen bezahlt. Ist er leer, steht die Abstimmung in jeder von ihnen still, bis Sie ihn auffüllen.", + "organizer_2": "Jede Wahl bezahlt die Gebühren ihrer Wählenden zuerst aus dem Gas, das Sie für sie reserviert haben und bis zu ihrem Ende nicht abheben können, danach aus Ihrem freien Guthaben, das sich alle Ihre Wahlen teilen. Sind beide aufgebraucht, stoppt die Abstimmung in dieser Wahl, bis Sie nachladen.", "warranty_title": "Keine Gewährleistung", "warranty_1": "Votain wird ohne jede Gewährleistung bereitgestellt. Es wurde nicht unabhängig geprüft. Es kann Mängel enthalten, die Richtigkeit, Verfügbarkeit oder Vertraulichkeit einer Abstimmung beeinträchtigen.", "warranty_2": "Soweit gesetzlich zulässig, übernimmt der Autor keine Haftung für Verluste oder Schäden aus der Nutzung.", @@ -683,7 +683,7 @@ }, "privacy": { "title": "Datenschutz", - "updated": "Zuletzt aktualisiert: 16. September 2026", + "updated": "Zuletzt aktualisiert: 28. September 2026", "summary_title": "Kurz gefasst", "summary_1": "Wir erfahren nie Ihren Namen, Ihr Gesicht oder Ihre biometrischen Daten. Wir wissen nicht, wie Sie abgestimmt haben. Stimmzettel werden verschlüsselt, bevor sie Ihr Gerät verlassen, und nur das Endergebnis wird entschlüsselt; der Schlüssel des Veranstalters könnte einen einzelnen Stimmzettel öffnen, aber nichts darauf verrät, von wem er stammt.", "summary_2": "Diese Seite beschreibt, was die Software tatsächlich tut, nicht was ihr erlaubt wäre, einschließlich der Stellen, an denen das Design Sie nicht schützt.", @@ -695,13 +695,14 @@ "identity_2": "Das ist keine Formalie. Könnten wir es entschlüsseln, könnten wir die Kennung Ihres Stimmzettels berechnen und jede Stimme der Person zuordnen, die sie abgegeben hat. Die Verschlüsselung macht uns das unmöglich, sie verbietet es uns nicht nur.", "ballot_title": "Ihr Stimmzettel", "ballot_1": "Ihre Wahl wird in Ihrem Browser verschlüsselt, bevor sie gesendet wird, mit Schlüsseln, die der Veranstalter verwahrt. Die Wahl addiert alle Stimmzettel verschlüsselt, und nur diese Summe wird je entschlüsselt, mit einem Beweis, den der Vertrag prüft. Diese Schlüssel könnten jeden einzelnen Stimmzettel öffnen, deshalb verrät nichts auf einem Stimmzettel, wer ihn abgegeben hat oder welcher Ihrer Stimmzettel ihm vorausging.", - "ballot_2": "Sie können so oft erneut abstimmen, wie Sie möchten, einmal pro Stunde; nur Ihr letzter Stimmzettel zählt. Jeder neue Stimmzettel hebt den vorherigen stillschweigend auf, und eine erste und eine geänderte Stimme sehen auf der Chain genau gleich aus, sodass niemand erkennen kann, dass Sie erneut abgestimmt haben. Genau das erlaubt es Ihnen, eine erzwungene Stimme unbemerkt zu ersetzen.", + "ballot_2": "Du kannst so oft erneut abstimmen, wie du willst: einmal sofort und danach einmal pro Stunde. Nur dein letzter Stimmzettel zählt. Jeder neue Stimmzettel hebt den vorherigen stillschweigend auf, und eine erste und eine geänderte Stimme sehen auf der Chain genau gleich aus, sodass niemand erkennen kann, dass du erneut abgestimmt hast. Genau das erlaubt dir, eine erzwungene Stimme unbemerkt zu ersetzen.", "onchain_title": "Was auf der Blockchain öffentlich ist", "onchain_1": "Öffentlich und dauerhaft: welche Wahlen existieren, dass eine Identität auf der Plattform registriert ist, dass sich jemand von der Plattform Verifiziertes für jede Wahl angemeldet hat, die verschlüsselten Stimmen mit ihren veröffentlichten Summen, und die versiegelten Kopien jeder Wahlidentität, eine je registriertem Passkey. Angemeldet wird ein Commitment, das nur für diese eine Wahl abgeleitet wurde, sodass die Kette nicht sagt, an welchen Wahlen eine einzelne Person teilgenommen hat. Alles davon ist lesbar. Die Kopien sind Chiffrat und öffnen sich nur mit dem Passkey, der sie erzeugt hat, aber ihre Existenz und ihre Anzahl sind sichtbar.", "onchain_2": "Nicht öffentlich: die Verbindung zwischen einer angemeldeten Identität und einem bestimmten Stimmzettel, und ebenso wenig die Verbindung zwischen den Anmeldungen derselben Person in verschiedenen Wahlen. Die erste zu brechen ist der Zweck des Zero-Knowledge-Beweises, und deshalb wird jede Stimme über einen gemeinsamen Vertrag eingereicht statt von einer eigenen Adresse. Die zweite ist der Grund, warum das Angemeldete je Wahl abgeleitet und nicht wiederverwendet wird.", "limits_title": "Wovor dies Sie nicht schützt", "limits_1": "Wenn Sie eine Stimme abgeben, erreicht die Anfrage unseren Server über Ihre Netzwerkverbindung, ihr Ursprung ist für uns also sichtbar wie für jede Website, die Sie nutzen. Wir verwenden ihn nicht, um Wählende zu identifizieren, aber das Design hindert uns nicht daran, und das sollten Sie wissen, bevor Sie sich auf die Anonymität Ihrer Stimme verlassen.", "limits_2": "Der Aussteller ist ebenfalls ein Vertrauenspunkt: Er prüft World-ID-Nachweise, registriert Wählende und signiert jede Anmeldung, womit er die einzige Stelle ist, die weiß, an welchen Wahlen Sie teilgenommen haben. Ein unehrlicher Aussteller könnte Wählende eintragen, die es nicht gibt. Ihren Stimmzettel lesen oder die Auszählung ändern könnte er nicht, aber Sie verlassen sich darauf, dass er ehrlich angibt, wer wählen darf, und verschwiegen bleibt, wo Sie gewählt haben.", + "limits_3": "Du kannst innerhalb einer Stunde höchstens zwei Stimmzettel abgeben: Das ist die Grenze, die verhindert, dass jemand das Gas des Veranstalters aufbraucht. Eine erzwungene Stimme lässt sich deshalb immer einmal sofort ersetzen; wirst du aber in der letzten Stunde vor Schluss zweimal hintereinander zur Stimmabgabe gezwungen, kannst du diese Stimmen nicht mehr ersetzen.", "retention_title": "Aufbewahrung und Löschung", "retention_1": "Auf unseren Servern bewahren wir nichts auf: Ihre Sitzung liegt in einem Cookie in Ihrem Browser, alles Dauerhafte liegt auf der Blockchain. Das Entfernen eines Passkeys löscht seine versiegelte Kopie von der Chain; das Entfernen des letzten wird abgelehnt, weil Ihre Identität dann dauerhaft unbrauchbar wäre.", "retention_2": "Was bereits auf der Blockchain steht, können weder wir noch sonst jemand löschen. Das gehört zu einem öffentlichen Register und ist dieselbe Eigenschaft, die Ihnen erlaubt zu prüfen, dass Ihre Stimme gezählt wurde." diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index d37d542..7d2caf1 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -205,8 +205,8 @@ "step1_desc": "World ID gives you an account without revealing who you are. An election that needs one person, one vote also asks for a document check on your own phone, which discloses nothing else.", "step2_title": "Cast your vote privately", "step2_desc": "Your vote is encrypted on your device, and a zero-knowledge proof gets it counted without revealing your choice. Each election you join also sees a different identity, so the chain never says which elections you took part in.", - "step3_title": "Change your vote anytime", - "step3_desc": "If coerced, you can vote again later. Only your last vote counts, so your true choice is always safe.", + "step3_title": "Change your vote, unseen", + "step3_desc": "If you are coerced, you can vote again: straight away once, and after that once an hour. Only your last vote counts, and nobody watching the chain can tell that you changed it.", "step4_title": "Verify the results", "step4_desc": "Every encrypted ballot and the final tally live on the blockchain. Anyone can recount them without breaking anyone’s anonymity.", "built_on": "Built on", @@ -608,8 +608,8 @@ "list_unreadable": "This list could not be read from the chain, which is not the same as it being empty. Check your connection and try again.", "election_unreadable": "This election could not be read from the chain, which is not the same as it not existing. Check your connection and try again.", "passkey_unproven": "The passkey was created, but we could not confirm yet that it can decrypt. Press again: the same one is asked, and no new passkey is created.", - "epoch_already_cast": "You already cast a ballot in the last hour. Your latest ballot is the one that counts; you can change it again once the hour is up.", - "epoch_already_cast_until": "You already cast a ballot this hour. Your latest ballot is the one that counts; you can change it again from {{time}}.", + "epoch_already_cast": "You have changed your vote too recently. Your latest ballot is the one that counts; you can change it again within the hour.", + "epoch_already_cast_until": "You have changed your vote too recently. Your latest ballot is the one that counts; you can change it again from {{time}}.", "epoch_wait_title": "Your latest vote already counts", "ballot_already_cast": "This ballot was already recorded. There is nothing else to do: your vote is in.", "ballot_expired": "This ballot was prepared too long ago to be accepted. Please vote again; a fresh ballot takes a few seconds." @@ -662,7 +662,7 @@ }, "terms": { "title": "Terms of use", - "updated": "Last updated: 3 September 2026", + "updated": "Last updated: 28 September 2026", "what_title": "What Votain is", "what_1": "Votain is a Bachelor's thesis project: an end-to-end verifiable, anonymous voting application built on the Polygon Amoy test network. It is a research prototype, not a commercial service.", "what_2": "It runs on a TEST network. The tokens it uses have no monetary value and the network offers no guarantee of persistence: test networks are reset or discontinued from time to time, and any election held here can disappear with them.", @@ -674,7 +674,7 @@ "identity_2": "Whoever has the phrase can restore the identity on any device, with no passkey and no help from us. If you lose the phrase and every passkey holding it, verifying with World ID again lets you start over. Starting over is deliberately limited: you can join elections you had not yet enrolled in, but never re-enter one you had, because the system cannot tell whether you already voted there without breaking the anonymity of the ballot.", "organizer_title": "If you organise an election", "organizer_1": "You are responsible for what you put on chain. Election names, descriptions and candidate lists are stored publicly and permanently, and cannot be edited or deleted once published. Do not put personal data of third parties in them.", - "organizer_2": "You fund a single gas tank that pays transaction fees for the voters of ALL your elections. If it empties, voting stops across every one of them until you top it up.", + "organizer_2": "Each election pays its voters’ transaction fees first from the gas you reserved for it, which you cannot withdraw until it ends, and then from your free balance, which all your elections share. If both run out, voting in that election stops until you top up.", "warranty_title": "No warranty", "warranty_1": "Votain is provided as is, without warranty of any kind. It has not been independently audited. It may contain defects that affect the correctness, availability or privacy of a vote.", "warranty_2": "To the maximum extent permitted by law, the author accepts no liability for any loss or damage arising from its use.", @@ -683,7 +683,7 @@ }, "privacy": { "title": "Privacy", - "updated": "Last updated: 16 September 2026", + "updated": "Last updated: 28 September 2026", "summary_title": "In short", "summary_1": "We never learn your name, your face or your biometrics. We do not know how you voted. Ballots are encrypted before they leave your device and only the final total is ever decrypted; the organizer's key could open a single ballot, but nothing on it says whose it is.", "summary_2": "This page describes what actually happens in the software rather than what is merely permitted, including the places where the design does not protect you.", @@ -695,13 +695,14 @@ "identity_2": "This is not a formality. If we could decrypt it, we could compute the identifier your ballot carries and match every vote to the person who cast it. The encryption is what makes that impossible for us, not just forbidden.", "ballot_title": "Your ballot", "ballot_1": "Your choice is encrypted in your browser before it is submitted, using keys held by the organizer. The election adds every ballot together while still encrypted, and only that sum is ever decrypted, with a proof the contract checks. Those keys could open any one ballot, which is why nothing on a ballot says who cast it or which of your ballots came before it.", - "ballot_2": "You may vote again as many times as you like, once an hour; only your last ballot counts. Each new ballot silently cancels your previous one, and a first vote and a changed one look exactly alike on chain, so nobody watching can tell that you voted again. That is what lets you quietly replace a vote you were pressured into casting.", + "ballot_2": "You may vote again as many times as you like: straight away once, and after that once an hour. Only your last ballot counts. Each new ballot silently cancels your previous one, and a first vote and a changed one look exactly alike on chain, so nobody watching can tell that you voted again. That is what lets you quietly replace a vote you were pressured into casting.", "onchain_title": "What is public on the blockchain", "onchain_1": "Public and permanent: which elections exist, that a given identity is registered on the platform, that somebody the platform verified enrolled in each election, the encrypted ballots with their published totals, and the sealed copies of each voting identity, one per registered passkey. What enrols is a commitment derived for that election alone, so the chain does not say which elections any one person joined. Anyone can read all of it. The sealed copies are ciphertext and open only with the passkey that made them, but their existence and their number are visible.", "onchain_2": "Not public: the link between an enrolled identity and any particular ballot, nor the link between the same person's enrolments in different elections. Breaking the first is what the zero-knowledge proof exists for, and it is why every vote is submitted through one shared contract rather than from an address of your own. The second is why what enrols is derived for each election instead of being reused.", "limits_title": "What this does not protect you from", "limits_1": "When you submit a vote, the request reaches our server from your network connection, so its origin is visible to us in the same way it is to any website you use. We do not use it to identify voters, but the design does not prevent us from doing so, and you should know that before relying on the anonymity of your ballot.", "limits_2": "The issuer is also a point of trust: it verifies World ID proofs, registers voters and signs each enrolment, which makes it the one party able to tell which elections you joined. A dishonest issuer could add voters who do not exist. It could not read your ballot or alter the count, but you are relying on it being honest about who is allowed to vote and discreet about where you voted.", + "limits_3": "You can cast at most two ballots within an hour: that is the limit that stops anyone from draining the gas the organizer pays for. A vote forced on you can therefore always be replaced once, straight away; but if you are forced to vote twice in a row in the last hour before voting closes, you will not be able to replace those votes.", "retention_title": "Retention and deletion", "retention_1": "We keep nothing on our own servers: your session lives in a cookie in your browser, and everything durable is on the blockchain. Removing a passkey removes its sealed copy from the chain; removing the last one is refused, because it would leave your identity permanently unusable.", "retention_2": "Anything already written to the blockchain cannot be deleted by us or by anyone else. That is inherent to a public ledger and is the same property that lets you verify your vote was counted." diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 0b4a86c..0fb0e9a 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -207,8 +207,8 @@ "step1_desc": "World ID te da una cuenta sin revelar quién eres. Una elección que exija un voto por persona pide además una comprobación de documento en tu propio móvil, que no revela nada más.", "step2_title": "Vota en privado", "step2_desc": "Tu voto se cifra en tu dispositivo y una prueba de conocimiento cero hace que se cuente sin revelar tu elección. Además, cada elección en la que participas ve una identidad distinta, así que la cadena nunca dice en cuáles participaste.", - "step3_title": "Cambia tu voto cuando quieras", - "step3_desc": "Si te coaccionan, puedes volver a votar más tarde. Solo cuenta tu último voto, así tu elección real siempre está segura.", + "step3_title": "Cambia tu voto sin que se note", + "step3_desc": "Si te coaccionan, puedes volver a votar: una vez enseguida, y después una vez por hora. Solo cuenta tu último voto, y nadie que observe la cadena puede saber que lo cambiaste.", "step4_title": "Verifica los resultados", "step4_desc": "Cada voto cifrado y el recuento final están en la cadena de bloques. Cualquiera puede recontarlos sin romper el anonimato de nadie.", "built_on": "Construido sobre", @@ -614,8 +614,8 @@ "list_unreadable": "Esta lista no se ha podido leer de la cadena, que no es lo mismo que esté vacía. Revisa la conexión e inténtalo otra vez.", "election_unreadable": "Esta elección no se ha podido leer de la cadena, que no es lo mismo que no exista. Revisa la conexión e inténtalo otra vez.", "passkey_unproven": "La passkey se creó, pero aún no hemos podido confirmar que sirva para descifrar. Pulsa otra vez: se le preguntará a esa misma, sin crear ninguna nueva.", - "epoch_already_cast": "Ya emitiste una papeleta en la última hora. Cuenta tu última papeleta; podrás cambiarla otra vez cuando pase la hora.", - "epoch_already_cast_until": "Ya emitiste una papeleta en esta hora. Cuenta tu última papeleta; podrás cambiarla otra vez a partir de las {{time}}.", + "epoch_already_cast": "Has cambiado tu voto hace muy poco. Cuenta tu última papeleta; podrás cambiarla otra vez dentro de una hora.", + "epoch_already_cast_until": "Has cambiado tu voto hace muy poco. Cuenta tu última papeleta; podrás cambiarla otra vez a partir de las {{time}}.", "epoch_wait_title": "Tu último voto ya cuenta", "ballot_already_cast": "Esta papeleta ya estaba registrada. No tienes que hacer nada más: tu voto está dentro.", "ballot_expired": "Esta papeleta se preparó hace demasiado tiempo para ser aceptada. Vuelve a votar; una papeleta nueva tarda unos segundos." @@ -668,7 +668,7 @@ }, "terms": { "title": "Condiciones de uso", - "updated": "Última actualización: 3 de septiembre de 2026", + "updated": "Última actualización: 28 de septiembre de 2026", "what_title": "Qué es Votain", "what_1": "Votain es un Trabajo de Fin de Grado: una aplicación de voto anónimo y verificable de extremo a extremo, construida sobre la red de pruebas Polygon Amoy. Es un prototipo de investigación, no un servicio comercial.", "what_2": "Funciona sobre una red de PRUEBAS. Los tokens que utiliza no tienen valor monetario y la red no garantiza su permanencia: las redes de pruebas se reinician o se retiran cada cierto tiempo, y cualquier elección celebrada aquí puede desaparecer con ellas.", @@ -680,7 +680,7 @@ "identity_2": "Quien tenga la frase puede restaurar la identidad en cualquier dispositivo, sin passkey y sin ayuda nuestra. Si pierdes la frase y todas las passkeys que la guardaban, verificarte de nuevo con World ID te permite empezar de cero. Empezar de cero está limitado a propósito: podrás unirte a elecciones en las que aún no te habías inscrito, pero nunca volver a entrar en una en la que ya estabas, porque el sistema no puede saber si ya votaste allí sin romper el anonimato de la papeleta.", "organizer_title": "Si organizas una elección", "organizer_1": "Eres responsable de lo que publicas en la cadena. Los nombres, descripciones y listas de candidatos se almacenan de forma pública y permanente, y no se pueden editar ni borrar una vez publicados. No incluyas en ellos datos personales de terceros.", - "organizer_2": "Financias un único depósito de gas que paga las comisiones de los votantes de TODAS tus elecciones. Si se agota, la votación se detiene en todas ellas hasta que lo recargues.", + "organizer_2": "Cada elección paga las comisiones de sus votantes primero con el gas que reservaste para ella, que no puedes retirar hasta que termine, y después con tu saldo libre, que comparten todas tus elecciones. Si se agotan los dos, la votación de esa elección se detiene hasta que recargues.", "warranty_title": "Sin garantía", "warranty_1": "Votain se ofrece tal cual, sin garantía de ningún tipo. No ha sido auditado de forma independiente. Puede contener defectos que afecten a la corrección, la disponibilidad o la privacidad de una votación.", "warranty_2": "En la máxima medida permitida por la ley, el autor no asume responsabilidad alguna por pérdidas o daños derivados de su uso.", @@ -689,7 +689,7 @@ }, "privacy": { "title": "Privacidad", - "updated": "Última actualización: 16 de septiembre de 2026", + "updated": "Última actualización: 28 de septiembre de 2026", "summary_title": "En resumen", "summary_1": "Nunca conocemos tu nombre, tu cara ni tus datos biométricos. No sabemos cómo has votado. Las papeletas se cifran antes de salir de tu dispositivo y solo se descifra el total final; la clave del organizador podría abrir una papeleta suelta, pero nada en ella dice de quién es.", "summary_2": "Esta página describe lo que el software hace realmente, no lo que estaría permitido hacer, incluidos los puntos en los que el diseño no te protege.", @@ -701,13 +701,14 @@ "identity_2": "Esto no es un formalismo. Si pudiéramos descifrarlo, podríamos calcular el identificador que lleva tu papeleta y emparejar cada voto con quien lo emitió. El cifrado es lo que nos lo hace imposible, no solo lo que nos lo prohíbe.", "ballot_title": "Tu papeleta", "ballot_1": "Tu elección se cifra en tu navegador antes de enviarse, con claves que custodia el organizador. La elección suma todas las papeletas sin descifrarlas y solo se descifra esa suma, con una prueba que el contrato comprueba. Esas claves podrían abrir cualquier papeleta suelta, y por eso nada en una papeleta dice quién la emitió ni cuál de tus papeletas vino antes.", - "ballot_2": "Puedes volver a votar tantas veces como quieras, una vez por hora; solo cuenta tu última papeleta. Cada papeleta nueva anula en silencio la anterior, y un primer voto y uno cambiado son idénticos en la cadena, así que nadie que observe puede saber que volviste a votar. Eso es lo que te permite sustituir discretamente un voto que te obligaron a emitir.", + "ballot_2": "Puedes volver a votar tantas veces como quieras: una vez enseguida, y después una vez por hora. Solo cuenta tu última papeleta. Cada papeleta nueva anula en silencio la anterior, y un primer voto y uno cambiado son idénticos en la cadena, así que nadie que observe puede saber que volviste a votar. Eso es lo que te permite sustituir discretamente un voto que te obligaron a emitir.", "onchain_title": "Qué es público en la cadena de bloques", "onchain_1": "Público y permanente: qué elecciones existen, que una identidad está registrada en la plataforma, que alguien verificado por la plataforma se inscribió en cada elección, los votos cifrados con sus totales publicados, y las copias cifradas de cada identidad de voto, una por passkey registrada. Lo que se inscribe es un compromiso derivado solo para esa elección, así que la cadena no dice en qué elecciones participó una misma persona. Cualquiera puede leerlo todo. Esas copias son texto cifrado y solo se abren con la passkey que las creó, pero su existencia y su número sí se ven.", "onchain_2": "No es público: el vínculo entre una identidad inscrita y una papeleta concreta, ni el vínculo entre las inscripciones de una misma persona en elecciones distintas. Romper el primero es justamente para lo que existe la prueba de conocimiento cero, y es la razón de que todos los votos se envíen a través de un mismo contrato compartido en lugar de desde una dirección propia. El segundo es la razón de que lo que se inscribe se derive para cada elección en vez de reutilizarse.", "limits_title": "De qué no te protege", "limits_1": "Al enviar un voto, la petición llega a nuestro servidor desde tu conexión, así que su origen nos resulta visible igual que a cualquier web que uses. No lo utilizamos para identificar votantes, pero el diseño no nos lo impide, y debes saberlo antes de confiar en el anonimato de tu papeleta.", "limits_2": "El emisor es además un punto de confianza: verifica las pruebas de World ID, registra a los votantes y firma cada inscripción, lo que lo convierte en la única parte capaz de saber en qué elecciones participaste. Un emisor deshonesto podría dar de alta votantes inexistentes. No podría leer tu papeleta ni alterar el recuento, pero confías en que sea honesto sobre quién puede votar y discreto sobre dónde votaste.", + "limits_3": "Puedes emitir como mucho dos papeletas dentro de una hora: es el límite que impide que alguien agote el gas que paga el organizador. Por eso, un voto que te obliguen a emitir siempre se puede sustituir una vez, enseguida; pero si te obligan a votar dos veces seguidas en la última hora antes del cierre, ya no podrás sustituir esos votos.", "retention_title": "Conservación y borrado", "retention_1": "No guardamos nada en nuestros servidores: tu sesión vive en una cookie de tu navegador y todo lo duradero está en la cadena de bloques. Quitar una passkey borra de la cadena su copia cifrada; quitar la última se rechaza, porque dejaría tu identidad inservible para siempre.", "retention_2": "Lo ya escrito en la cadena de bloques no podemos borrarlo nosotros ni nadie. Es inherente a un registro público, y es la misma propiedad que te permite verificar que tu voto fue contado." diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index c4bc4e2..cce6109 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -207,8 +207,8 @@ "step1_desc": "World ID vous donne un compte sans révéler qui vous êtes. Une élection exigeant une voix par personne demande en plus une vérification de document sur votre propre téléphone, qui ne divulgue rien d'autre.", "step2_title": "Votez en privé", "step2_desc": "Votre vote est chiffré sur votre appareil et une preuve à divulgation nulle le fait compter sans révéler votre choix. Chaque élection à laquelle vous participez voit aussi une identité différente, si bien que la chaîne ne dit jamais auxquelles vous avez pris part.", - "step3_title": "Changez de vote à tout moment", - "step3_desc": "En cas de coercition, vous pouvez voter à nouveau plus tard. Seul votre dernier vote compte.", + "step3_title": "Changez votre vote sans que cela se voie", + "step3_desc": "Si l’on vous contraint, vous pouvez voter à nouveau : une fois tout de suite, puis une fois par heure. Seul votre dernier vote compte, et personne qui observe la chaîne ne peut savoir que vous l’avez changé.", "step4_title": "Vérifiez les résultats", "step4_desc": "Chaque bulletin chiffré et le décompte final sont sur la blockchain. Chacun peut les recompter sans briser l’anonymat de personne.", "built_on": "Construit sur", @@ -614,8 +614,8 @@ "list_unreadable": "Cette liste n’a pas pu être lue depuis la chaîne, ce qui n’est pas la même chose qu’une liste vide. Vérifiez la connexion et réessayez.", "election_unreadable": "Cette élection n’a pas pu être lue depuis la chaîne, ce qui ne veut pas dire qu’elle n’existe pas. Vérifiez la connexion et réessayez.", "passkey_unproven": "La passkey a été créée, mais nous n’avons pas encore pu confirmer qu’elle peut déchiffrer. Appuyez à nouveau : c’est la même qui sera interrogée, sans en créer de nouvelle.", - "epoch_already_cast": "Vous avez déjà déposé un bulletin au cours de la dernière heure. Votre dernier bulletin est celui qui compte ; vous pourrez le modifier à nouveau une fois l'heure écoulée.", - "epoch_already_cast_until": "Vous avez déjà déposé un bulletin cette heure-ci. Votre dernier bulletin est celui qui compte ; vous pourrez le modifier à nouveau à partir de {{time}}.", + "epoch_already_cast": "Vous avez changé votre vote très récemment. Votre dernier bulletin est celui qui compte ; vous pourrez le changer à nouveau d’ici une heure.", + "epoch_already_cast_until": "Vous avez changé votre vote très récemment. Votre dernier bulletin est celui qui compte ; vous pourrez le changer à nouveau à partir de {{time}}.", "epoch_wait_title": "Votre dernier vote compte déjà", "ballot_already_cast": "Ce bulletin était déjà enregistré. Vous n'avez rien d'autre à faire : votre vote est pris en compte.", "ballot_expired": "Ce bulletin a été préparé il y a trop longtemps pour être accepté. Votez à nouveau ; un nouveau bulletin prend quelques secondes." @@ -668,7 +668,7 @@ }, "terms": { "title": "Conditions d'utilisation", - "updated": "Dernière mise à jour : 3 septembre 2026", + "updated": "Dernière mise à jour : 28 septembre 2026", "what_title": "Ce qu'est Votain", "what_1": "Votain est un projet de fin d'études : une application de vote anonyme et vérifiable de bout en bout, construite sur le réseau de test Polygon Amoy. C'est un prototype de recherche, pas un service commercial.", "what_2": "Il fonctionne sur un réseau de TEST. Les jetons utilisés n'ont aucune valeur monétaire et le réseau ne garantit aucune pérennité : les réseaux de test sont réinitialisés ou arrêtés de temps à autre, et toute élection tenue ici peut disparaître avec eux.", @@ -680,7 +680,7 @@ "identity_2": "Quiconque détient la phrase peut restaurer l'identité sur n'importe quel appareil, sans clé d'accès et sans notre aide. Si vous perdez la phrase et toutes les clés d'accès qui la détenaient, une nouvelle vérification World ID vous permet de repartir de zéro. Ce redémarrage est volontairement limité : vous pourrez rejoindre les élections où vous n'étiez pas encore inscrit, mais jamais revenir dans une élection où vous l'étiez, car le système ne peut pas savoir si vous y avez déjà voté sans briser l'anonymat du bulletin.", "organizer_title": "Si vous organisez une élection", "organizer_1": "Vous êtes responsable de ce que vous publiez sur la chaîne. Les noms, descriptions et listes de candidats sont stockés publiquement et de façon permanente, et ne peuvent être ni modifiés ni supprimés une fois publiés. N'y faites pas figurer de données personnelles de tiers.", - "organizer_2": "Vous alimentez une réserve de gas unique qui paie les frais de transaction des électeurs de TOUTES vos élections. Si elle se vide, le vote s'arrête dans chacune d'elles jusqu'à ce que vous la rechargiez.", + "organizer_2": "Chaque élection paie les frais de ses votants d’abord avec le gaz que vous lui avez réservé, que vous ne pouvez pas retirer avant sa fin, puis avec votre solde libre, que partagent toutes vos élections. Si les deux s’épuisent, le vote de cette élection s’arrête jusqu’à ce que vous rechargiez.", "warranty_title": "Aucune garantie", "warranty_1": "Votain est fourni en l'état, sans garantie d'aucune sorte. Il n'a pas fait l'objet d'un audit indépendant. Il peut contenir des défauts affectant l'exactitude, la disponibilité ou la confidentialité d'un vote.", "warranty_2": "Dans la mesure maximale permise par la loi, l'auteur décline toute responsabilité pour les pertes ou dommages découlant de son utilisation.", @@ -689,7 +689,7 @@ }, "privacy": { "title": "Confidentialité", - "updated": "Dernière mise à jour : 16 septembre 2026", + "updated": "Dernière mise à jour : 28 septembre 2026", "summary_title": "En bref", "summary_1": "Nous ne connaissons jamais votre nom, votre visage ni vos données biométriques. Nous ne savons pas comment vous avez voté. Les bulletins sont chiffrés avant de quitter votre appareil et seul le total final est déchiffré ; la clé de l'organisateur pourrait ouvrir un bulletin isolé, mais rien n'y indique à qui il appartient.", "summary_2": "Cette page décrit ce que le logiciel fait réellement, et non ce qu'il serait autorisé à faire, y compris les points où la conception ne vous protège pas.", @@ -701,13 +701,14 @@ "identity_2": "Ce n'est pas une formalité. Si nous pouvions le déchiffrer, nous pourrions calculer l'identifiant que porte votre bulletin et associer chaque vote à son auteur. Le chiffrement est ce qui nous en rend incapables, pas seulement ce qui nous l'interdit.", "ballot_title": "Votre bulletin", "ballot_1": "Votre choix est chiffré dans votre navigateur avant d'être envoyé, avec des clés détenues par l'organisateur. L'élection additionne tous les bulletins sans les déchiffrer, et seule cette somme est déchiffrée, avec une preuve que le contrat vérifie. Ces clés pourraient ouvrir n'importe quel bulletin isolé, c'est pourquoi rien sur un bulletin n'indique qui l'a déposé ni lequel de vos bulletins l'a précédé.", - "ballot_2": "Vous pouvez voter à nouveau autant de fois que vous le souhaitez, une fois par heure ; seul votre dernier bulletin compte. Chaque nouveau bulletin annule discrètement le précédent, et un premier vote et un vote modifié sont identiques sur la chaîne : personne ne peut voir que vous avez voté à nouveau. C'est ce qui vous permet de remplacer discrètement un vote qu'on vous a imposé.", + "ballot_2": "Vous pouvez voter à nouveau autant de fois que vous le souhaitez : une fois tout de suite, puis une fois par heure. Seul votre dernier bulletin compte. Chaque nouveau bulletin annule silencieusement le précédent, et un premier vote et un vote modifié sont identiques sur la chaîne, si bien que personne ne peut savoir que vous avez voté à nouveau. C’est ce qui vous permet de remplacer discrètement un vote qu’on vous a forcé à exprimer.", "onchain_title": "Ce qui est public sur la blockchain", "onchain_1": "Public et permanent : quelles élections existent, qu'une identité est enregistrée sur la plateforme, que quelqu'un de vérifié s'est inscrit à chaque élection, les bulletins chiffrés avec leurs totaux publiés, et les copies scellées de chaque identité de vote, une par passkey enregistrée. Ce qui s'inscrit est un engagement dérivé pour cette élection seule, de sorte que la chaîne ne dit pas à quelles élections une même personne a participé. Tout le monde peut tout lire. Ces copies sont chiffrées et ne s'ouvrent qu'avec la passkey qui les a créées, mais leur existence et leur nombre sont visibles.", "onchain_2": "Non public : le lien entre une identité inscrite et un bulletin particulier, ni le lien entre les inscriptions d'une même personne à des élections différentes. Briser le premier est précisément la raison d'être de la preuve à divulgation nulle, et c'est pourquoi chaque vote est soumis via un même contrat partagé plutôt que depuis une adresse qui vous serait propre. Le second explique pourquoi ce qui s'inscrit est dérivé pour chaque élection au lieu d'être réutilisé.", "limits_title": "Ce contre quoi cela ne vous protège pas", "limits_1": "Lorsque vous envoyez un vote, la requête atteint notre serveur depuis votre connexion : son origine nous est donc visible, comme pour n'importe quel site que vous utilisez. Nous ne nous en servons pas pour identifier les électeurs, mais la conception ne nous en empêche pas, et vous devez le savoir avant de vous fier à l'anonymat de votre bulletin.", "limits_2": "L'émetteur est aussi un point de confiance : il vérifie les preuves World ID, enregistre les électeurs et signe chaque inscription, ce qui en fait la seule partie capable de savoir à quelles élections vous avez participé. Un émetteur malhonnête pourrait ajouter des électeurs inexistants. Il ne pourrait ni lire votre bulletin ni modifier le décompte, mais vous comptez sur son honnêteté quant à qui a le droit de voter et sur sa discrétion quant à où vous avez voté.", + "limits_3": "Vous pouvez déposer au plus deux bulletins en une heure : c’est la limite qui empêche quiconque d’épuiser le gaz que paie l’organisateur. Un vote qu’on vous a imposé peut donc toujours être remplacé une fois, tout de suite ; mais si l’on vous force à voter deux fois de suite dans la dernière heure avant la clôture, vous ne pourrez plus remplacer ces votes.", "retention_title": "Conservation et suppression", "retention_1": "Nous ne gardons rien sur nos serveurs : votre session vit dans un cookie de votre navigateur et tout ce qui est durable est sur la blockchain. Retirer une passkey supprime sa copie scellée de la chaîne ; retirer la dernière est refusé, car cela rendrait votre identité définitivement inutilisable.", "retention_2": "Ce qui est déjà inscrit sur la blockchain ne peut être effacé ni par nous ni par personne. C'est inhérent à un registre public, et c'est la propriété même qui vous permet de vérifier que votre vote a été compté." diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index 2c6580b..4bb8b32 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -205,8 +205,8 @@ "step1_desc": "World ID आपको एक खाता देता है, यह बताए बिना कि आप कौन हैं। जिस चुनाव में प्रति व्यक्ति एक वोट ज़रूरी है, वह आपके अपने फ़ोन पर दस्तावेज़ जाँच भी माँगता है, जो और कुछ उजागर नहीं करती।", "step2_title": "निजी तौर पर मतदान करें", "step2_desc": "आपका मत आपके उपकरण पर कूटबद्ध होता है और शून्य ज्ञान प्रमाण उसे आपकी पसंद बताए बिना गिनवा देता है। साथ ही, आप जिस भी चुनाव में शामिल होते हैं वह एक अलग पहचान देखता है, इसलिए श्रृंखला कभी नहीं बताती कि आपने किन चुनावों में भाग लिया।", - "step3_title": "कभी भी अपना वोट बदलें", - "step3_desc": "जबरदस्ती की स्थिति में, आप बाद में दोबारा मतदान कर सकते हैं। केवल आपका अंतिम वोट गिना जाता है।", + "step3_title": "बिना किसी को पता चले अपना वोट बदलें", + "step3_desc": "अगर आप पर दबाव डाला जाए, तो आप फिर से वोट कर सकते हैं: एक बार तुरंत, और उसके बाद घंटे में एक बार। केवल आपका अंतिम वोट गिना जाता है, और चेन देखने वाला कोई नहीं जान सकता कि आपने इसे बदला।", "step4_title": "परिणाम सत्यापित करें", "step4_desc": "हर एन्क्रिप्टेड मत और अंतिम गणना ब्लॉकचेन पर हैं। कोई भी उन्हें दोबारा गिन सकता है, बिना किसी की गुमनामी तोड़े।", "built_on": "पर निर्मित", @@ -608,8 +608,8 @@ "list_unreadable": "यह सूची चेन से नहीं पढ़ी जा सकी, जो खाली होने जैसा नहीं है। कनेक्शन जाँचें और फिर से कोशिश करें।", "election_unreadable": "यह चुनाव चेन से नहीं पढ़ा जा सका, जिसका मतलब यह नहीं कि यह मौजूद नहीं है। कनेक्शन जाँचें और फिर से कोशिश करें।", "passkey_unproven": "पासकी बन गई, पर अभी यह पक्का नहीं हो सका कि वह डिक्रिप्ट कर सकती है। दोबारा दबाएँ: उसी से पूछा जाएगा, नई नहीं बनेगी।", - "epoch_already_cast": "आप पिछले एक घंटे में पहले ही एक मतपत्र डाल चुके हैं। आपका नवीनतम मतपत्र ही गिना जाएगा; घंटा पूरा होने पर आप इसे फिर से बदल सकते हैं।", - "epoch_already_cast_until": "आप इस घंटे में पहले ही एक मतपत्र डाल चुके हैं। आपका नवीनतम मतपत्र ही गिना जाता है; आप इसे {{time}} से फिर बदल सकते हैं।", + "epoch_already_cast": "आपने अभी हाल ही में अपना वोट बदला है। आपका अंतिम मतपत्र ही गिना जाता है; आप इसे एक घंटे के भीतर फिर से बदल सकते हैं।", + "epoch_already_cast_until": "आपने अभी हाल ही में अपना वोट बदला है। आपका अंतिम मतपत्र ही गिना जाता है; आप इसे {{time}} से फिर बदल सकते हैं।", "epoch_wait_title": "आपका नवीनतम वोट पहले से गिना जा रहा है", "ballot_already_cast": "यह मतपत्र पहले ही दर्ज हो चुका है। आपको और कुछ नहीं करना है: आपका मत दर्ज है।", "ballot_expired": "यह मतपत्र बहुत पहले तैयार किया गया था, इसलिए स्वीकार नहीं किया जा सकता। कृपया फिर से मतदान करें; नया मतपत्र बनने में कुछ ही सेकंड लगते हैं।" @@ -662,7 +662,7 @@ }, "terms": { "title": "उपयोग की शर्तें", - "updated": "अंतिम अद्यतन: 3 सितंबर 2026", + "updated": "अंतिम अद्यतन: 28 सितंबर 2026", "what_title": "Votain क्या है", "what_1": "Votain एक स्नातक शोध परियोजना है: Polygon Amoy परीक्षण नेटवर्क पर बना, आद्योपांत सत्यापन योग्य गुमनाम मतदान अनुप्रयोग। यह एक शोध प्रारूप है, कोई व्यावसायिक सेवा नहीं।", "what_2": "यह एक परीक्षण नेटवर्क पर चलता है। इसमें उपयोग होने वाले टोकनों का कोई मौद्रिक मूल्य नहीं है और नेटवर्क स्थायित्व की कोई गारंटी नहीं देता: परीक्षण नेटवर्क समय समय पर पुनः आरंभ या बंद कर दिए जाते हैं, और यहाँ हुआ कोई भी चुनाव उनके साथ लुप्त हो सकता है।", @@ -674,7 +674,7 @@ "identity_2": "जिसके पास वाक्यांश है वह किसी भी डिवाइस पर पहचान बहाल कर सकता है, बिना पासकी के और बिना हमारी मदद के। यदि वाक्यांश और उसे संभालने वाली हर पासकी खो जाए, तो World ID से दोबारा सत्यापन करके नए सिरे से शुरू किया जा सकता है। यह शुरुआत जानबूझकर सीमित है: आप उन चुनावों में शामिल हो सकते हैं जिनमें आपने अभी पंजीकरण नहीं कराया था, पर उस चुनाव में दोबारा कभी नहीं जिसमें करा चुके थे, क्योंकि मत की गुमनामी तोड़े बिना यह प्रणाली नहीं जान सकती कि आपने वहाँ पहले मतदान किया था या नहीं।", "organizer_title": "यदि आप चुनाव आयोजित करते हैं", "organizer_1": "शृंखला पर आप जो प्रकाशित करते हैं उसके लिए आप उत्तरदायी हैं। नाम, विवरण और उम्मीदवार सूचियाँ सार्वजनिक और स्थायी रूप से संग्रहीत होती हैं, और प्रकाशन के बाद उन्हें न बदला जा सकता है न हटाया। उनमें दूसरों का व्यक्तिगत डेटा शामिल न करें।", - "organizer_2": "आप एक ही गैस कोष में धन डालते हैं जो आपके सभी चुनावों के मतदाताओं का लेनदेन शुल्क चुकाता है। वह खाली हो जाए तो हर चुनाव में मतदान रुक जाता है, जब तक आप उसे फिर से न भरें।", + "organizer_2": "हर चुनाव अपने मतदाताओं का शुल्क पहले उस गैस से चुकाता है जो आपने उसके लिए आरक्षित की है, जिसे चुनाव खत्म होने तक निकाला नहीं जा सकता, और फिर आपके मुक्त शेष से, जिसे आपके सभी चुनाव साझा करते हैं। दोनों खत्म होने पर उस चुनाव में मतदान तब तक रुक जाता है जब तक आप फिर से भर न दें।", "warranty_title": "कोई वारंटी नहीं", "warranty_1": "Votain जैसा है वैसा ही प्रदान किया जाता है, बिना किसी प्रकार की वारंटी के। इसका स्वतंत्र अंकेक्षण नहीं हुआ है। इसमें ऐसी त्रुटियाँ हो सकती हैं जो मतदान की शुद्धता, उपलब्धता या गोपनीयता को प्रभावित करें।", "warranty_2": "कानून द्वारा अनुमत अधिकतम सीमा तक, लेखक उपयोग से उत्पन्न किसी हानि या क्षति की जिम्मेदारी स्वीकार नहीं करता।", @@ -683,7 +683,7 @@ }, "privacy": { "title": "गोपनीयता", - "updated": "अंतिम अद्यतन: 16 सितंबर 2026", + "updated": "अंतिम अद्यतन: 28 सितंबर 2026", "summary_title": "संक्षेप में", "summary_1": "हम आपका नाम, चेहरा या बायोमेट्रिक डेटा कभी नहीं जानते। हम नहीं जानते कि आपने कैसे वोट दिया। मतपत्र आपके डिवाइस से निकलने से पहले एन्क्रिप्ट होते हैं और केवल अंतिम योग ही डिक्रिप्ट किया जाता है; आयोजक की कुंजी किसी एक मतपत्र को खोल सकती है, पर उस पर कुछ भी नहीं बताता कि वह किसका है।", "summary_2": "यह पृष्ठ बताता है कि सॉफ़्टवेयर वास्तव में क्या करता है, न कि उसे क्या करने की छूट है, और उन जगहों को भी बताता है जहाँ यह रचना आपकी रक्षा नहीं करती।", @@ -695,13 +695,14 @@ "identity_2": "यह औपचारिकता भर नहीं है। यदि हम उसे खोल पाते, तो आपके मतपत्र में निहित पहचानक की गणना करके हर मत को उसके देने वाले से जोड़ सकते थे। कूटलेखन ही वह है जो हमारे लिए इसे असंभव बनाता है, केवल वर्जित नहीं करता।", "ballot_title": "आपका मतपत्र", "ballot_1": "आपका विकल्प भेजे जाने से पहले आपके ब्राउज़र में आयोजक के पास रखी कुंजियों से एन्क्रिप्ट होता है। चुनाव सभी मतपत्रों को एन्क्रिप्टेड रहते हुए ही जोड़ता है, और केवल वह योग ही डिक्रिप्ट होता है, एक ऐसे प्रमाण के साथ जिसे अनुबंध जाँचता है। ये कुंजियाँ किसी भी एक मतपत्र को खोल सकती हैं, इसीलिए किसी मतपत्र पर कुछ भी नहीं बताता कि उसे किसने डाला या आपका कौन-सा मतपत्र उससे पहले आया।", - "ballot_2": "आप जितनी बार चाहें फिर से मतदान कर सकते हैं, घंटे में एक बार; केवल आपका अंतिम मतपत्र गिना जाता है। हर नया मतपत्र चुपचाप पिछले को रद्द कर देता है, और पहला मत और बदला हुआ मत चेन पर बिल्कुल एक जैसे दिखते हैं, इसलिए देखने वाला कोई नहीं जान सकता कि आपने फिर से मतदान किया। यही आपको दबाव में डाला गया मत चुपचाप बदलने देता है।", + "ballot_2": "आप जितनी बार चाहें फिर से वोट कर सकते हैं: एक बार तुरंत, और उसके बाद घंटे में एक बार। केवल आपका अंतिम मतपत्र गिना जाता है। हर नया मतपत्र चुपचाप पिछले को रद्द कर देता है, और पहला वोट व बदला हुआ वोट चेन पर बिल्कुल एक जैसे दिखते हैं, इसलिए कोई नहीं जान सकता कि आपने फिर से वोट किया। इसी से आप दबाव में डाले गए वोट को चुपचाप बदल सकते हैं।", "onchain_title": "ब्लॉकचेन पर क्या सार्वजनिक है", "onchain_1": "सार्वजनिक और स्थायी: कौन से चुनाव मौजूद हैं, कि कोई पहचान मंच पर पंजीकृत है, कि मंच द्वारा सत्यापित किसी व्यक्ति ने प्रत्येक चुनाव में पंजीकरण किया, कूटबद्ध मत और उनके प्रकाशित योग, तथा हर मतदान पहचान की सीलबंद प्रतियाँ, प्रत्येक पंजीकृत पासकी के लिए एक। पंजीकरण में वही प्रतिबद्धता जाती है जो केवल उसी चुनाव के लिए व्युत्पन्न हुई है, इसलिए श्रृंखला यह नहीं बताती कि एक ही व्यक्ति किन-किन चुनावों में शामिल हुआ। इन सबको कोई भी पढ़ सकता है। वे प्रतियाँ कूटबद्ध हैं और केवल उसी पासकी से खुलती हैं जिसने उन्हें बनाया, पर उनका होना और उनकी संख्या दिखती है।", "onchain_2": "सार्वजनिक नहीं: किसी पंजीकृत पहचान और किसी विशेष मतपत्र के बीच का संबंध, और न ही एक ही व्यक्ति के अलग-अलग चुनावों के पंजीकरणों के बीच का संबंध। पहले संबंध को तोड़ना ही शून्य ज्ञान प्रमाण का उद्देश्य है, और इसी कारण हर मत आपके अपने पते से नहीं, बल्कि एक साझा अनुबंध से भेजा जाता है। दूसरे संबंध के कारण पंजीकरण का मान हर चुनाव के लिए अलग से व्युत्पन्न होता है, दोबारा इस्तेमाल नहीं होता।", "limits_title": "यह आपको किससे नहीं बचाता", "limits_1": "जब आप मत भेजते हैं, अनुरोध आपके संपर्क से होकर हमारे सर्वर तक पहुँचता है, इसलिए उसका स्रोत हमें उसी तरह दिखता है जैसे आपकी उपयोग की गई किसी भी वेबसाइट को। हम उसका उपयोग मतदाताओं को पहचानने के लिए नहीं करते, पर यह रचना हमें रोकती भी नहीं, और अपने मतपत्र की गुमनामी पर भरोसा करने से पहले आपको यह जानना चाहिए।", "limits_2": "जारीकर्ता भी विश्वास का एक बिंदु है: वह World ID प्रमाणों की जाँच करता है, मतदाताओं को पंजीकृत करता है और हर पंजीकरण पर हस्ताक्षर करता है, जिससे वही एकमात्र पक्ष है जो जान सकता है कि आप किन चुनावों में शामिल हुए। बेईमान जारीकर्ता ऐसे मतदाता जोड़ सकता है जो अस्तित्व में ही न हों। वह न आपका मतपत्र पढ़ सकता है न गणना बदल सकता है, पर कौन मत दे सकता है इस बारे में आप उसकी ईमानदारी पर और आपने कहाँ मत दिया इस बारे में उसकी विवेकशीलता पर निर्भर हैं।", + "limits_3": "आप एक घंटे के भीतर अधिकतम दो मतपत्र डाल सकते हैं: यही सीमा किसी को आयोजक के भुगतान वाले गैस को खत्म करने से रोकती है। इसलिए आप पर थोपा गया वोट हमेशा एक बार तुरंत बदला जा सकता है; लेकिन अगर मतदान बंद होने से पहले के आखिरी घंटे में आपको लगातार दो बार वोट देने के लिए मजबूर किया जाए, तो आप उन वोटों को बदल नहीं पाएँगे।", "retention_title": "प्रतिधारण और विलोपन", "retention_1": "हम अपने सर्वरों पर कुछ नहीं रखते: आपका सत्र आपके ब्राउज़र की कुकी में रहता है और स्थायी सब कुछ ब्लॉकचेन पर है। कोई पासकी हटाने पर उसकी सीलबंद प्रति श्रृंखला से हट जाती है; आख़िरी को हटाना अस्वीकार किया जाता है, क्योंकि इससे आपकी पहचान हमेशा के लिए बेकार हो जाती।", "retention_2": "जो ब्लॉकचेन पर लिखा जा चुका है उसे न हम मिटा सकते हैं न कोई और। यह किसी सार्वजनिक बही की सहज विशेषता है, और यही वह विशेषता है जो आपको यह जाँचने देती है कि आपका मत गिना गया।" diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index b2a39e8..961e952 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -207,8 +207,8 @@ "step1_desc": "World ID ti dà un account senza rivelare chi sei. Un’elezione che richiede un voto a testa chiede anche una verifica del documento sul tuo telefono, che non rivela altro.", "step2_title": "Vota in privato", "step2_desc": "Il tuo voto viene cifrato sul tuo dispositivo e una prova a conoscenza zero lo fa contare senza rivelare la tua scelta. Ogni elezione a cui partecipi vede inoltre un'identità diversa, così la catena non dice mai a quali hai preso parte.", - "step3_title": "Cambia voto in qualsiasi momento", - "step3_desc": "In caso di coercizione, puoi votare di nuovo in seguito. Conta solo il tuo ultimo voto.", + "step3_title": "Cambia il tuo voto senza che si veda", + "step3_desc": "Se vieni costretto, puoi votare di nuovo: una volta subito, e poi una volta all’ora. Conta solo il tuo ultimo voto, e nessuno che osservi la catena può sapere che l’hai cambiato.", "step4_title": "Verifica i risultati", "step4_desc": "Ogni voto cifrato e il conteggio finale stanno sulla blockchain. Chiunque può ricontarli senza violare l’anonimato di nessuno.", "built_on": "Costruito su", @@ -614,8 +614,8 @@ "list_unreadable": "Non è stato possibile leggere questa lista dalla catena, che non è lo stesso che sia vuota. Controlla la connessione e riprova.", "election_unreadable": "Non è stato possibile leggere questa elezione dalla catena, che non vuol dire che non esista. Controlla la connessione e riprova.", "passkey_unproven": "La passkey è stata creata, ma non abbiamo ancora potuto confermare che sappia decifrare. Premi di nuovo: viene interrogata la stessa, senza crearne una nuova.", - "epoch_already_cast": "Hai già espresso una scheda nell'ultima ora. Conta la tua ultima scheda; potrai cambiarla di nuovo allo scadere dell'ora.", - "epoch_already_cast_until": "Hai già espresso una scheda in quest’ora. Conta la tua ultima scheda; potrai cambiarla di nuovo dalle {{time}}.", + "epoch_already_cast": "Hai cambiato il tuo voto da troppo poco. Conta la tua ultima scheda; potrai cambiarla di nuovo entro un’ora.", + "epoch_already_cast_until": "Hai cambiato il tuo voto da troppo poco. Conta la tua ultima scheda; potrai cambiarla di nuovo dalle {{time}}.", "epoch_wait_title": "Il tuo ultimo voto conta già", "ballot_already_cast": "Questa scheda era già stata registrata. Non devi fare altro: il tuo voto è dentro.", "ballot_expired": "Questa scheda è stata preparata troppo tempo fa per essere accettata. Vota di nuovo; una nuova scheda richiede pochi secondi." @@ -668,7 +668,7 @@ }, "terms": { "title": "Termini di utilizzo", - "updated": "Ultimo aggiornamento: 3 settembre 2026", + "updated": "Ultimo aggiornamento: 28 settembre 2026", "what_title": "Che cos'è Votain", "what_1": "Votain è un progetto di tesi triennale: un'applicazione di voto anonimo e verificabile end to end, costruita sulla rete di test Polygon Amoy. È un prototipo di ricerca, non un servizio commerciale.", "what_2": "Funziona su una rete di TEST. I token che usa non hanno valore monetario e la rete non garantisce alcuna permanenza: le reti di test vengono azzerate o dismesse di tanto in tanto, e ogni elezione tenuta qui può sparire insieme a loro.", @@ -680,7 +680,7 @@ "identity_2": "Chi ha la frase può ripristinare l'identità su qualunque dispositivo, senza passkey e senza il nostro aiuto. Se perdi la frase e ogni passkey che la custodiva, verificarti di nuovo con World ID ti permette di ripartire da zero. Ripartire è volutamente limitato: potrai unirti alle elezioni a cui non ti eri ancora iscritto, ma mai rientrare in una in cui lo eri, perché il sistema non può sapere se vi hai già votato senza rompere l'anonimato della scheda.", "organizer_title": "Se organizzi un'elezione", "organizer_1": "Sei responsabile di ciò che pubblichi sulla catena. Nomi, descrizioni ed elenchi di candidati sono memorizzati in modo pubblico e permanente, e non possono essere modificati né cancellati una volta pubblicati. Non inserirvi dati personali di terzi.", - "organizer_2": "Alimenti un unico serbatoio di gas che paga le commissioni di transazione dei votanti di TUTTE le tue elezioni. Se si svuota, il voto si ferma in ognuna di esse finché non lo ricarichi.", + "organizer_2": "Ogni elezione paga le commissioni dei suoi votanti prima con il gas che le hai riservato, che non puoi ritirare finché non termina, e poi con il tuo saldo libero, condiviso da tutte le tue elezioni. Se si esauriscono entrambi, il voto in quell’elezione si ferma finché non ricarichi.", "warranty_title": "Nessuna garanzia", "warranty_1": "Votain è fornito così com'è, senza garanzie di alcun tipo. Non è stato sottoposto ad audit indipendente. Può contenere difetti che incidono sulla correttezza, sulla disponibilità o sulla riservatezza di una votazione.", "warranty_2": "Nella misura massima consentita dalla legge, l'autore non si assume alcuna responsabilità per perdite o danni derivanti dal suo utilizzo.", @@ -689,7 +689,7 @@ }, "privacy": { "title": "Privacy", - "updated": "Ultimo aggiornamento: 16 settembre 2026", + "updated": "Ultimo aggiornamento: 28 settembre 2026", "summary_title": "In breve", "summary_1": "Non conosciamo mai il tuo nome, il tuo volto né i tuoi dati biometrici. Non sappiamo come hai votato. Le schede vengono cifrate prima di lasciare il tuo dispositivo e viene decifrato solo il totale finale; la chiave dell'organizzatore potrebbe aprire una singola scheda, ma nulla su di essa dice di chi sia.", "summary_2": "Questa pagina descrive ciò che il software fa davvero, non ciò che gli sarebbe consentito fare, compresi i punti in cui la progettazione non ti protegge.", @@ -701,13 +701,14 @@ "identity_2": "Non è una formalità. Se potessimo decifrarlo, potremmo calcolare l'identificativo che la tua scheda porta con sé e collegare ogni voto a chi l'ha espresso. È la cifratura a renderci questo impossibile, non solo a vietarcelo.", "ballot_title": "La tua scheda", "ballot_1": "La tua scelta viene cifrata nel browser prima dell'invio, con chiavi custodite dall'organizzatore. L'elezione somma tutte le schede ancora cifrate e viene decifrata solo quella somma, con una prova che il contratto verifica. Quelle chiavi potrebbero aprire qualsiasi singola scheda, ed è per questo che nulla su una scheda dice chi l'ha espressa né quale delle tue schede l'ha preceduta.", - "ballot_2": "Puoi votare di nuovo tutte le volte che vuoi, una volta all'ora; conta solo la tua ultima scheda. Ogni nuova scheda annulla in silenzio la precedente, e un primo voto e uno cambiato sono identici sulla catena, quindi nessuno che osservi può capire che hai votato di nuovo. È questo che ti permette di sostituire discretamente un voto che ti hanno costretto a esprimere.", + "ballot_2": "Puoi votare di nuovo tutte le volte che vuoi: una volta subito, e poi una volta all’ora. Conta solo la tua ultima scheda. Ogni nuova scheda annulla in silenzio la precedente, e un primo voto e uno cambiato sono identici sulla catena, quindi nessuno può sapere che hai votato di nuovo. È questo che ti permette di sostituire discretamente un voto che ti hanno costretto a esprimere.", "onchain_title": "Che cosa è pubblico sulla blockchain", "onchain_1": "Pubblico e permanente: quali elezioni esistono, che una identità è registrata sulla piattaforma, che qualcuno verificato dalla piattaforma si è iscritto a ciascuna elezione, i voti cifrati con i totali pubblicati, e le copie sigillate di ogni identità di voto, una per passkey registrata. Ciò che si iscrive è un impegno derivato solo per quella elezione, quindi la catena non dice a quali elezioni ha partecipato una stessa persona. Chiunque può leggere tutto. Quelle copie sono testo cifrato e si aprono solo con la passkey che le ha create, ma la loro esistenza e il loro numero sono visibili.", "onchain_2": "Non è pubblico: il collegamento tra un'identità iscritta e una scheda specifica, né il collegamento tra le iscrizioni di una stessa persona a elezioni diverse. Spezzare il primo è esattamente lo scopo della prova a conoscenza zero, ed è il motivo per cui ogni voto viene inviato attraverso uno stesso contratto condiviso anziché da un indirizzo tutto tuo. Il secondo è il motivo per cui ciò che si iscrive viene derivato per ogni elezione invece di essere riutilizzato.", "limits_title": "Da che cosa questo non ti protegge", "limits_1": "Quando invii un voto, la richiesta raggiunge il nostro server dalla tua connessione, quindi la sua origine ci è visibile come lo è per qualsiasi sito che usi. Non la usiamo per identificare i votanti, ma la progettazione non ce lo impedisce, e devi saperlo prima di affidarti all'anonimato della tua scheda.", "limits_2": "Anche l'emittente è un punto di fiducia: verifica le prove World ID, registra i votanti e firma ogni iscrizione, il che ne fa l'unica parte in grado di sapere a quali elezioni hai partecipato. Un emittente disonesto potrebbe iscrivere votanti inesistenti. Non potrebbe leggere la tua scheda né alterare il conteggio, ma stai confidando che sia onesto su chi ha diritto di votare e discreto su dove hai votato.", + "limits_3": "Puoi esprimere al massimo due schede nell’arco di un’ora: è il limite che impedisce a chiunque di esaurire il gas pagato dall’organizzatore. Un voto che ti hanno imposto si può quindi sempre sostituire una volta, subito; ma se vieni costretto a votare due volte di seguito nell’ultima ora prima della chiusura, non potrai più sostituire quei voti.", "retention_title": "Conservazione ed eliminazione", "retention_1": "Non conserviamo nulla sui nostri server: la tua sessione vive in un cookie del tuo browser e tutto ciò che è durevole sta sulla blockchain. Rimuovere una passkey elimina dalla catena la sua copia sigillata; rimuovere l’ultima viene rifiutato, perché renderebbe la tua identità inutilizzabile per sempre.", "retention_2": "Ciò che è già scritto sulla blockchain non può essere cancellato né da noi né da nessun altro. È intrinseco a un registro pubblico, ed è la stessa proprietà che ti consente di verificare che il tuo voto sia stato conteggiato." diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index f600a89..2cb5542 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -203,8 +203,8 @@ "step1_desc": "World ID は、あなたが誰かを明かさずにアカウントを与えます。一人一票を求める選挙では、加えてご自身の端末で書類の確認を行いますが、それ以外は何も開示されません。", "step2_title": "プライベートに投票", "step2_desc": "投票はお使いの端末で暗号化され、ゼロ知識証明によって選択を明かさずに集計されます。さらに、参加する選挙ごとに別の識別情報が使われるため、どの選挙に参加したかはチェーンからは分かりません。", - "step3_title": "いつでも投票を変更", - "step3_desc": "強制された場合、後で再投票できます。最後の投票のみが有効です。", + "step3_title": "誰にも気づかれずに投票を変更", + "step3_desc": "強要された場合でも、再投票できます。すぐに1回、その後は1時間に1回です。有効なのは最後の投票だけで、チェーンを見ている誰にも変更したことはわかりません。", "step4_title": "結果を確認", "step4_desc": "暗号化された投票と最終集計はすべてブロックチェーン上にあります。誰の匿名性も損なわずに、誰でも数え直せます。", "built_on": "基盤技術", @@ -602,8 +602,8 @@ "list_unreadable": "この一覧をチェーンから読み取れませんでした。空だったわけではありません。接続を確かめてやり直してください。", "election_unreadable": "この選挙をチェーンから読み取れませんでした。存在しないわけではありません。接続を確かめてやり直してください。", "passkey_unproven": "パスキーは作成されましたが、復号に使えるかまだ確認できていません。もう一度押してください。同じパスキーに尋ねます。新しいものは作成されません。", - "epoch_already_cast": "この1時間以内にすでに投票しています。最新の投票が有効です。1時間経過すると再び変更できます。", - "epoch_already_cast_until": "この1時間のうちにすでに投票しています。最新の投票が有効です。{{time}} から再び変更できます。", + "epoch_already_cast": "つい先ほど投票を変更したばかりです。最新の投票が有効です。1時間以内に再び変更できます。", + "epoch_already_cast_until": "つい先ほど投票を変更したばかりです。最新の投票が有効です。{{time}} から再び変更できます。", "epoch_wait_title": "最新の投票はすでに有効です", "ballot_already_cast": "この投票はすでに記録されています。これ以上の操作は不要です。あなたの票は届いています。", "ballot_expired": "この投票は準備されてから時間が経ちすぎたため受け付けられません。もう一度投票してください。新しい投票は数秒で作成できます。" @@ -656,7 +656,7 @@ }, "terms": { "title": "利用規約", - "updated": "最終更新:2026年9月3日", + "updated": "最終更新:2026年9月28日", "what_title": "Votain とは", "what_1": "Votain は学士論文のプロジェクトです。Polygon Amoy テストネット上に構築された、エンドツーエンドで検証可能な匿名投票アプリケーションであり、研究用のプロトタイプであって商用サービスではありません。", "what_2": "テストネット上で動作します。使用するトークンに金銭的価値はなく、ネットワークの存続も保証されません。テストネットは時折リセットまたは終了され、ここで行われた選挙もそれとともに失われることがあります。", @@ -668,7 +668,7 @@ "identity_2": "フレーズを持つ人は、パスキーがなくても当社の助けがなくても、どの端末でもアイデンティティを復元できます。フレーズとそれを保管していたパスキーをすべて失った場合は、World IDで再度認証すればやり直せます。やり直しは意図的に制限されています。まだ登録していなかった選挙には参加できますが、すでに登録していた選挙に戻ることはできません。そこで投票済みかどうかを、票の匿名性を壊さずに知ることはできないからです。", "organizer_title": "選挙を主催する場合", "organizer_1": "チェーンに公開した内容については、あなたが責任を負います。名称、説明、候補者一覧は公開され恒久的に保存され、公開後は編集も削除もできません。第三者の個人データを含めないでください。", - "organizer_2": "あなたが入金するのは単一のガス残高で、これがあなたの「すべての」選挙の投票者の取引手数料を負担します。残高が尽きると、補充するまでいずれの選挙でも投票が止まります。", + "organizer_2": "各選挙は投票者の手数料を、まずその選挙のために予約したガス(終了まで引き出せません)から支払い、次にすべての選挙で共有する自由残高から支払います。両方が尽きると、補充するまでその選挙の投票は止まります。", "warranty_title": "無保証", "warranty_1": "Votain は現状のまま提供され、いかなる保証もありません。独立した監査も受けていません。投票の正確性、可用性、秘匿性に影響する不具合が含まれている可能性があります。", "warranty_2": "法律で認められる最大限の範囲において、作者は利用によって生じた損失や損害について責任を負いません。", @@ -677,7 +677,7 @@ }, "privacy": { "title": "プライバシー", - "updated": "最終更新:2026年9月16日", + "updated": "最終更新:2026年9月28日", "summary_title": "要約", "summary_1": "あなたの名前、顔、生体情報を私たちが知ることはありません。あなたがどう投票したかも知りません。投票はデバイスを離れる前に暗号化され、復号されるのは最終的な合計だけです。主催者の鍵なら個々の投票を開くこともできますが、その投票が誰のものかを示す情報は何もありません。", "summary_2": "このページは、ソフトウェアが許されていることではなく実際に行っていることを説明します。設計上あなたを守れない箇所も含みます。", @@ -689,13 +689,14 @@ "identity_2": "これは形式的な話ではありません。もし復号できてしまえば、あなたの投票が持つ識別子を計算し、どの票を誰が投じたかを結び付けられてしまいます。暗号化は当方にそれを禁じているだけでなく、不可能にしているのです。", "ballot_title": "あなたの投票", "ballot_1": "あなたの選択は送信前にブラウザ内で、主催者が保持する鍵で暗号化されます。選挙はすべての投票を暗号化したまま合計し、復号されるのはその合計だけで、コントラクトが検証する証明が伴います。これらの鍵は個々の投票を開くこともできるため、投票には誰が投じたのか、あなたのどの投票がその前にあったのかを示すものが一切含まれていません。", - "ballot_2": "何度でも投票し直すことができます(1時間に1回まで)。有効なのは最後の投票だけです。新しい投票は前の投票を静かに取り消し、最初の投票と変更後の投票はチェーン上でまったく同じに見えるため、誰も再投票に気づけません。これにより、強要されて投じた票をひそかに差し替えることができます。", + "ballot_2": "何度でも再投票できます。すぐに1回、その後は1時間に1回です。有効なのは最後の投票だけです。新しい投票は前の投票を静かに取り消し、最初の投票と変更後の投票はチェーン上でまったく同じに見えるため、再投票したことは誰にもわかりません。これにより、強要された投票をひそかに置き換えられます。", "onchain_title": "ブロックチェーン上で公開される情報", "onchain_1": "公開かつ恒久:どの選挙が存在するか、ある識別情報がプラットフォームに登録されていること、プラットフォームが検証した誰かが各選挙に登録したこと、暗号化された投票と公表された集計、そして各投票用識別情報の封をされた控え(登録済みパスキーごとに一つ)。登録されるのはその選挙のためだけに導出されたコミットメントなので、同じ人がどの選挙に参加したかはチェーンからは分かりません。誰でもすべて読めます。控えは暗号文で、作成したパスキーでしか開きませんが、その存在と個数は見えます。", "onchain_2": "公開されない情報:登録済みの識別情報と特定の投票との結び付き、そして同じ人物による別々の選挙への登録どうしの結び付きです。前者を断ち切ることこそがゼロ知識証明の目的であり、各票が専用のアドレスからではなく共通の契約を通じて送信される理由でもあります。後者のために、登録されるものは選挙ごとに導出され、使い回されません。", "limits_title": "これで守られないこと", "limits_1": "投票を送信すると、その要求はあなたの回線から当方のサーバーに届くため、送信元は、あなたが利用するあらゆるウェブサイトと同様に当方から見えます。当方はそれを投票者の特定に使いませんが、設計がそれを妨げているわけではありません。投票の匿名性に頼る前に知っておいてください。", "limits_2": "発行者も信頼の置きどころです。World ID の証明を検証し、投票者を登録し、各登録に署名します。そのため、あなたがどの選挙に参加したかを知りうる唯一の当事者でもあります。不誠実な発行者は存在しない投票者を登録できてしまいます。あなたの投票を読むことも集計を変えることもできませんが、誰に投票資格があるかについて誠実であること、そしてどこで投票したかについて口が堅いことに依拠しています。", + "limits_3": "1時間のうちに投票できるのは最大2回です。これは、主催者が支払うガスを誰かが使い果たすのを防ぐための上限です。そのため、強要された投票はいつでもすぐに1回置き換えられます。ただし、締め切り前の最後の1時間に続けて2回投票を強要された場合、それらの票は置き換えられません。", "retention_title": "保存と削除", "retention_1": "当方のサーバーには何も残しません。セッションはブラウザーの Cookie にあり、永続するものはすべてブロックチェーン上にあります。パスキーを削除すると、その封をされた控えがチェーンから消えます。最後の一つの削除は拒否されます。識別情報が永久に使えなくなるからです。", "retention_2": "すでにブロックチェーンに書き込まれたものは、当方にも他の誰にも消せません。これは公開台帳に本来備わる性質であり、自分の票が数えられたことをあなたが確認できるのも同じ性質によります。" diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index 9ebcaff..efc5a14 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -203,8 +203,8 @@ "step1_desc": "World ID는 당신이 누구인지 밝히지 않고 계정을 제공합니다. 1인 1표가 필요한 선거는 본인 휴대폰에서 서류 확인도 요청하며, 그 밖에는 아무것도 공개하지 않습니다.", "step2_title": "비공개로 투표", "step2_desc": "표는 사용자의 기기에서 암호화되고, 영지식 증명 덕분에 선택을 드러내지 않고도 집계됩니다. 또한 참여하는 선거마다 서로 다른 신원이 사용되므로, 어떤 선거에 참여했는지는 체인에 드러나지 않습니다.", - "step3_title": "언제든지 투표 변경", - "step3_desc": "강압이 있는 경우 나중에 다시 투표할 수 있습니다. 마지막 투표만 집계됩니다.", + "step3_title": "아무도 모르게 투표 변경", + "step3_desc": "강요를 받았다면 다시 투표할 수 있습니다. 한 번은 바로, 그다음부터는 한 시간에 한 번입니다. 마지막 투표만 반영되며, 체인을 지켜보는 누구도 바꿨다는 사실을 알 수 없습니다.", "step4_title": "결과 확인", "step4_desc": "암호화된 모든 표와 최종 집계는 블록체인에 있습니다. 누구의 익명성도 깨지 않고 누구나 다시 셀 수 있습니다.", "built_on": "기반 기술", @@ -602,8 +602,8 @@ "list_unreadable": "이 목록을 체인에서 읽지 못했습니다. 비어 있는 것과는 다릅니다. 연결을 확인하고 다시 시도하세요.", "election_unreadable": "이 선거를 체인에서 읽지 못했습니다. 존재하지 않는다는 뜻은 아닙니다. 연결을 확인하고 다시 시도하세요.", "passkey_unproven": "패스키는 생성되었지만 복호화에 쓸 수 있는지 아직 확인하지 못했습니다. 다시 누르세요. 같은 패스키에 물어보며, 새로 만들지 않습니다.", - "epoch_already_cast": "지난 한 시간 안에 이미 투표지를 제출했습니다. 가장 최근 투표지가 반영되며, 한 시간이 지나면 다시 변경할 수 있습니다.", - "epoch_already_cast_until": "이번 시간에 이미 투표지를 제출했습니다. 가장 최근 투표지가 반영되며, {{time}}부터 다시 변경할 수 있습니다.", + "epoch_already_cast": "방금 투표를 바꾸셨습니다. 가장 최근 투표지가 반영되며, 한 시간 안에 다시 바꿀 수 있습니다.", + "epoch_already_cast_until": "방금 투표를 바꾸셨습니다. 가장 최근 투표지가 반영되며, {{time}}부터 다시 바꿀 수 있습니다.", "epoch_wait_title": "가장 최근 투표가 이미 반영되었습니다", "ballot_already_cast": "이 투표지는 이미 기록되었습니다. 더 할 일은 없습니다. 투표가 반영되었습니다.", "ballot_expired": "이 투표지는 너무 오래전에 준비되어 받아들여질 수 없습니다. 다시 투표해 주세요. 새 투표지는 몇 초면 만들어집니다." @@ -656,7 +656,7 @@ }, "terms": { "title": "이용 약관", - "updated": "최종 업데이트: 2026년 9월 3일", + "updated": "최종 업데이트: 2026년 9월 28일", "what_title": "Votain이란", "what_1": "Votain은 학사 졸업 프로젝트입니다. Polygon Amoy 테스트넷 위에 구축된 종단 간 검증 가능한 익명 투표 애플리케이션이며, 상용 서비스가 아니라 연구용 프로토타입입니다.", "what_2": "테스트넷에서 동작합니다. 사용하는 토큰에는 금전적 가치가 없고 네트워크의 존속도 보장되지 않습니다. 테스트넷은 이따금 초기화되거나 종료되며, 여기서 진행한 선거도 그와 함께 사라질 수 있습니다.", @@ -668,7 +668,7 @@ "identity_2": "문구를 가진 사람은 패스키 없이도, 저희 도움 없이도 어느 기기에서나 신원을 복원할 수 있습니다. 문구와 그것을 보관하던 패스키를 모두 잃으면 World ID로 다시 인증해 처음부터 시작할 수 있습니다. 다시 시작하는 것은 의도적으로 제한됩니다. 아직 등록하지 않은 선거에는 참여할 수 있지만, 이미 등록했던 선거에는 다시 들어갈 수 없습니다. 표의 익명성을 깨지 않고서는 거기서 이미 투표했는지 시스템이 알 수 없기 때문입니다.", "organizer_title": "선거를 주최하는 경우", "organizer_1": "체인에 게시하는 내용에 대한 책임은 귀하에게 있습니다. 이름, 설명, 후보 목록은 공개적이고 영구적으로 저장되며 게시 후에는 수정하거나 삭제할 수 없습니다. 제3자의 개인정보를 포함하지 마십시오.", - "organizer_2": "귀하가 충전하는 것은 하나의 가스 잔액이며, 이는 귀하의 모든 선거에 참여하는 투표자의 거래 수수료를 지불합니다. 잔액이 바닥나면 다시 충전할 때까지 모든 선거에서 투표가 멈춥니다.", + "organizer_2": "각 선거는 투표자의 수수료를 먼저 그 선거를 위해 예약한 가스(선거가 끝날 때까지 인출할 수 없음)로 내고, 그다음 모든 선거가 함께 쓰는 자유 잔액으로 냅니다. 둘 다 바닥나면 충전할 때까지 그 선거의 투표가 멈춥니다.", "warranty_title": "보증 없음", "warranty_1": "Votain은 있는 그대로 제공되며 어떠한 보증도 하지 않습니다. 독립적인 감사를 받지 않았습니다. 투표의 정확성, 가용성, 비밀성에 영향을 주는 결함이 있을 수 있습니다.", "warranty_2": "법이 허용하는 최대 범위에서, 저자는 사용으로 인해 발생한 손실이나 손해에 대해 책임지지 않습니다.", @@ -677,7 +677,7 @@ }, "privacy": { "title": "개인정보", - "updated": "최종 업데이트: 2026년 9월 16일", + "updated": "최종 업데이트: 2026년 9월 28일", "summary_title": "요약", "summary_1": "우리는 당신의 이름, 얼굴, 생체 정보를 결코 알지 못합니다. 당신이 어떻게 투표했는지도 모릅니다. 투표지는 기기를 떠나기 전에 암호화되고 최종 합계만 복호화됩니다. 주최자의 키로 투표지 한 장을 열 수는 있지만, 그 투표지가 누구의 것인지 알려 주는 정보는 없습니다.", "summary_2": "이 페이지는 소프트웨어가 허용되는 일이 아니라 실제로 하는 일을 설명하며, 설계상 귀하를 보호하지 못하는 지점도 포함합니다.", @@ -689,13 +689,14 @@ "identity_2": "형식적인 이야기가 아닙니다. 저희가 복호화할 수 있다면 귀하의 투표가 지닌 식별자를 계산해 각 표를 누가 던졌는지 연결할 수 있습니다. 암호화는 저희에게 그것을 금지하는 데 그치지 않고 불가능하게 만듭니다.", "ballot_title": "귀하의 투표", "ballot_1": "당신의 선택은 제출되기 전에 브라우저에서 주최자가 보관하는 키로 암호화됩니다. 선거는 모든 투표지를 암호화된 채로 더하고, 그 합만 복호화하며 컨트랙트가 검증하는 증명이 함께 제출됩니다. 이 키는 투표지 한 장을 열 수도 있기 때문에, 투표지에는 누가 제출했는지, 당신의 어느 투표지가 그 앞에 있었는지 알려 주는 정보가 전혀 없습니다.", - "ballot_2": "원하는 만큼 다시 투표할 수 있으며, 한 시간에 한 번까지 가능합니다. 마지막 투표지만 반영됩니다. 새 투표지는 이전 투표지를 조용히 취소하고, 첫 투표와 바꾼 투표는 체인에서 똑같아 보이므로 지켜보는 누구도 당신이 다시 투표했다는 것을 알 수 없습니다. 그래서 강요받아 던진 표를 조용히 바꿀 수 있습니다.", + "ballot_2": "원하는 만큼 다시 투표할 수 있습니다. 한 번은 바로, 그다음부터는 한 시간에 한 번입니다. 마지막 투표지만 반영됩니다. 새 투표지는 이전 투표지를 조용히 취소하며, 첫 투표와 바꾼 투표는 체인에서 똑같아 보이므로 누구도 다시 투표했다는 사실을 알 수 없습니다. 그래서 강요받은 투표를 조용히 바꿀 수 있습니다.", "onchain_title": "블록체인에서 공개되는 것", "onchain_1": "공개되며 영구적입니다: 어떤 선거가 존재하는지, 어떤 신원이 플랫폼에 등록되어 있는지, 플랫폼이 검증한 누군가가 각 선거에 등록했다는 사실, 암호화된 표와 공개된 집계, 그리고 각 투표 신원의 봉인된 사본(등록된 패스키마다 하나). 등록되는 것은 그 선거만을 위해 파생된 커밋먼트이므로, 같은 사람이 어떤 선거들에 참여했는지는 체인에 드러나지 않습니다. 누구나 전부 읽을 수 있습니다. 사본은 암호문이며 그것을 만든 패스키로만 열리지만, 존재와 개수는 보입니다.", "onchain_2": "공개되지 않는 것: 등록된 신원과 특정 표 사이의 연결, 그리고 같은 사람이 서로 다른 선거에 등록한 기록들 사이의 연결입니다. 앞의 연결을 끊는 것이 바로 영지식 증명의 목적이며, 각 표가 전용 주소가 아니라 하나의 공용 컨트랙트를 통해 제출되는 이유이기도 합니다. 뒤의 연결 때문에 등록되는 값은 재사용되지 않고 선거마다 새로 파생됩니다.", "limits_title": "이것이 막아주지 못하는 것", "limits_1": "투표를 제출하면 요청이 귀하의 네트워크 연결을 통해 저희 서버에 도달하므로, 그 출처는 귀하가 쓰는 어떤 웹사이트에서와 마찬가지로 저희에게 보입니다. 저희는 그것으로 투표자를 식별하지 않지만 설계가 그것을 막지는 못하며, 투표의 익명성에 기대기 전에 이 점을 알아야 합니다.", "limits_2": "발급자 역시 신뢰의 지점입니다. World ID 증명을 검증하고, 투표자를 등록하며, 모든 등록에 서명합니다. 그래서 회원님이 어떤 선거에 참여했는지 알 수 있는 유일한 당사자이기도 합니다. 부정직한 발급자는 존재하지 않는 투표자를 등록할 수 있습니다. 표를 읽거나 집계를 바꿀 수는 없지만, 누가 투표할 자격이 있는지에 관한 정직함과 어디에서 투표했는지에 관한 신중함에 의존하게 됩니다.", + "limits_3": "한 시간 안에 투표지는 최대 두 장까지 제출할 수 있습니다. 누군가 주최자가 내는 가스를 소진하지 못하게 막는 한도입니다. 그래서 강요받은 표는 언제든 한 번은 바로 바꿀 수 있지만, 마감 전 마지막 한 시간 안에 연달아 두 번 투표를 강요받으면 그 표들은 바꿀 수 없습니다.", "retention_title": "보관과 삭제", "retention_1": "저희 서버에는 아무것도 보관하지 않습니다. 세션은 브라우저 쿠키에 있고, 지속되는 것은 모두 블록체인에 있습니다. 패스키를 제거하면 그 봉인된 사본이 체인에서 제거되며, 마지막 하나의 제거는 거부됩니다. 신원이 영구히 사용 불가능해지기 때문입니다.", "retention_2": "이미 블록체인에 기록된 것은 저희도 다른 누구도 지울 수 없습니다. 이는 공개 원장에 본래 따르는 성질이며, 귀하가 자신의 표가 집계되었음을 확인할 수 있게 하는 바로 그 성질입니다." diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 405a456..1358e9d 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -205,8 +205,8 @@ "step1_desc": "World ID geeft je een account zonder te onthullen wie je bent. Een verkiezing die één stem per persoon eist, vraagt daarnaast een documentcontrole op je eigen telefoon, die verder niets prijsgeeft.", "step2_title": "Stem in het privé", "step2_desc": "Uw stem wordt op uw apparaat versleuteld en een zero-knowledge-bewijs zorgt dat hij meetelt zonder uw keuze te onthullen. Elke verkiezing waaraan u deelneemt ziet bovendien een andere identiteit, dus de keten zegt nooit aan welke u deelnam.", - "step3_title": "Wijzig je stem op elk moment", - "step3_desc": "Bij dwang kun je later opnieuw stemmen. Alleen je laatste stem telt.", + "step3_title": "Wijzig je stem zonder dat het opvalt", + "step3_desc": "Word je onder druk gezet, dan kun je opnieuw stemmen: één keer meteen, en daarna één keer per uur. Alleen je laatste stem telt, en niemand die de chain bekijkt, kan zien dat je hem hebt gewijzigd.", "step4_title": "Verifieer de resultaten", "step4_desc": "Elke versleutelde stem en de einduitslag staan op de blockchain. Iedereen kan hertellen zonder iemands anonimiteit te breken.", "built_on": "Gebouwd op", @@ -608,8 +608,8 @@ "list_unreadable": "Deze lijst kon niet van de chain gelezen worden, wat niet hetzelfde is als leeg zijn. Controleer de verbinding en probeer het opnieuw.", "election_unreadable": "Deze verkiezing kon niet van de chain gelezen worden, wat niet betekent dat ze niet bestaat. Controleer de verbinding en probeer het opnieuw.", "passkey_unproven": "De passkey is aangemaakt, maar we konden nog niet bevestigen dat hij kan ontsleutelen. Druk nogmaals: dezelfde wordt gevraagd, er wordt geen nieuwe aangemaakt.", - "epoch_already_cast": "Je hebt het afgelopen uur al een stembiljet uitgebracht. Je laatste stembiljet telt; je kunt het opnieuw wijzigen zodra het uur voorbij is.", - "epoch_already_cast_until": "Je hebt dit uur al een stembiljet uitgebracht. Je laatste stembiljet telt; vanaf {{time}} kun je het opnieuw wijzigen.", + "epoch_already_cast": "Je hebt je stem net nog gewijzigd. Je laatste stembiljet telt; binnen een uur kun je het opnieuw wijzigen.", + "epoch_already_cast_until": "Je hebt je stem net nog gewijzigd. Je laatste stembiljet telt; vanaf {{time}} kun je het opnieuw wijzigen.", "epoch_wait_title": "Je laatste stem telt al", "ballot_already_cast": "Dit stembiljet was al geregistreerd. Je hoeft niets meer te doen: je stem is binnen.", "ballot_expired": "Dit stembiljet is te lang geleden voorbereid om nog te worden aanvaard. Stem opnieuw; een nieuw stembiljet duurt een paar seconden." @@ -662,7 +662,7 @@ }, "terms": { "title": "Gebruiksvoorwaarden", - "updated": "Laatst bijgewerkt: 3 september 2026", + "updated": "Laatst bijgewerkt: 28 september 2026", "what_title": "Wat Votain is", "what_1": "Votain is een bachelorscriptieproject: een end-to-end verifieerbare, anonieme stemtoepassing, gebouwd op het Polygon Amoy-testnetwerk. Het is een onderzoeksprototype, geen commerciële dienst.", "what_2": "Het draait op een TESTNETWERK. De tokens die het gebruikt hebben geen geldwaarde en het netwerk garandeert geen voortbestaan: testnetwerken worden af en toe gereset of uitgezet, en elke verkiezing die hier is gehouden kan daarmee verdwijnen.", @@ -674,7 +674,7 @@ "identity_2": "Wie de zin heeft, kan de identiteit op elk apparaat herstellen, zonder passkey en zonder hulp van ons. Raak je de zin kwijt én elke passkey die hem bewaarde, dan kun je met een nieuwe World ID-verificatie opnieuw beginnen. Opnieuw beginnen is bewust beperkt: je kunt meedoen aan verkiezingen waarvoor je je nog niet had ingeschreven, maar nooit terugkeren in een verkiezing waar je dat wel had, omdat het systeem niet kan vaststellen of je daar al gestemd hebt zonder de anonimiteit van de stem te breken.", "organizer_title": "Als u een verkiezing organiseert", "organizer_1": "U bent verantwoordelijk voor wat u op de keten publiceert. Namen, beschrijvingen en kandidatenlijsten worden openbaar en permanent opgeslagen en kunnen na publicatie niet meer worden gewijzigd of verwijderd. Neem daarin geen persoonsgegevens van anderen op.", - "organizer_2": "U vult één enkele gasvoorraad die de transactiekosten betaalt van de kiezers van AL uw verkiezingen. Raakt die leeg, dan ligt het stemmen in elk daarvan stil totdat u hem aanvult.", + "organizer_2": "Elke verkiezing betaalt de kosten van haar kiezers eerst uit het gas dat je ervoor hebt gereserveerd en dat je pas na afloop kunt opnemen, en daarna uit je vrije saldo, dat al je verkiezingen delen. Zijn beide op, dan stopt het stemmen in die verkiezing tot je bijvult.", "warranty_title": "Geen garantie", "warranty_1": "Votain wordt geleverd zoals het is, zonder enige garantie. Het is niet onafhankelijk geaudit. Het kan gebreken bevatten die de juistheid, de beschikbaarheid of de vertrouwelijkheid van een stemming raken.", "warranty_2": "Voor zover de wet dat toestaat aanvaardt de auteur geen aansprakelijkheid voor verlies of schade die uit het gebruik voortvloeit.", @@ -683,7 +683,7 @@ }, "privacy": { "title": "Privacy", - "updated": "Laatst bijgewerkt: 16 september 2026", + "updated": "Laatst bijgewerkt: 28 september 2026", "summary_title": "Kort gezegd", "summary_1": "We komen nooit je naam, je gezicht of je biometrische gegevens te weten. We weten niet hoe je hebt gestemd. Stembiljetten worden versleuteld voordat ze je apparaat verlaten en alleen het eindtotaal wordt ooit ontsleuteld; de sleutel van de organisator zou één stembiljet kunnen openen, maar niets erop zegt van wie het is.", "summary_2": "Deze pagina beschrijft wat de software werkelijk doet, niet wat zij zou mogen doen, inclusief de plekken waar het ontwerp u niet beschermt.", @@ -695,13 +695,14 @@ "identity_2": "Dat is geen formaliteit. Konden wij het wel ontsleutelen, dan konden wij de identificator berekenen die uw stem meedraagt en elke stem koppelen aan wie hem uitbracht. De versleuteling maakt ons dat onmogelijk, zij verbiedt het ons niet alleen.", "ballot_title": "Uw stem", "ballot_1": "Je keuze wordt in je browser versleuteld voordat die wordt verstuurd, met sleutels die de organisator bewaart. De verkiezing telt alle stembiljetten versleuteld bij elkaar op en alleen die som wordt ooit ontsleuteld, met een bewijs dat het contract controleert. Die sleutels zouden elk afzonderlijk stembiljet kunnen openen, en daarom staat er op een stembiljet niets over wie het uitbracht of welk van jouw stembiljetten eraan voorafging.", - "ballot_2": "Je mag zo vaak opnieuw stemmen als je wilt, één keer per uur; alleen je laatste stembiljet telt. Elk nieuw stembiljet trekt je vorige stilletjes in, en een eerste stem en een gewijzigde zien er op de chain precies hetzelfde uit, zodat niemand kan zien dat je opnieuw hebt gestemd. Daardoor kun je ongemerkt een stem vervangen die je onder druk hebt uitgebracht.", + "ballot_2": "Je kunt zo vaak opnieuw stemmen als je wilt: één keer meteen, en daarna één keer per uur. Alleen je laatste stembiljet telt. Elk nieuw stembiljet trekt het vorige stilletjes in, en een eerste en een gewijzigde stem zien er op de chain precies hetzelfde uit, zodat niemand kan zien dat je opnieuw hebt gestemd. Zo kun je een afgedwongen stem ongemerkt vervangen.", "onchain_title": "Wat openbaar is op de blockchain", "onchain_1": "Openbaar en permanent: welke verkiezingen bestaan, dat een identiteit op het platform geregistreerd is, dat iemand die het platform verifieerde zich per verkiezing inschreef, de versleutelde stemmen met hun gepubliceerde totalen, en de verzegelde kopieën van elke stemidentiteit, één per geregistreerde passkey. Wat zich inschrijft is een commitment dat alleen voor die verkiezing is afgeleid, dus de keten zegt niet aan welke verkiezingen één persoon deelnam. Iedereen kan alles lezen. Die kopieën zijn cijfertekst en gaan alleen open met de passkey die ze maakte, maar hun bestaan en hun aantal zijn zichtbaar.", "onchain_2": "Niet openbaar: het verband tussen een ingeschreven identiteit en een specifieke stem, en evenmin het verband tussen de inschrijvingen van dezelfde persoon in verschillende verkiezingen. Het eerste verbreken is precies waar het zero-knowledge-bewijs voor dient, en daarom wordt elke stem ingediend via één gedeeld contract in plaats van vanaf een eigen adres. Het tweede is waarom wat zich inschrijft per verkiezing wordt afgeleid in plaats van hergebruikt.", "limits_title": "Waartegen dit u niet beschermt", "limits_1": "Wanneer u een stem indient, bereikt het verzoek onze server via uw netwerkverbinding, dus de herkomst ervan is voor ons zichtbaar zoals bij elke website die u gebruikt. Wij gebruiken die niet om kiezers te identificeren, maar het ontwerp belet ons dat niet, en dat moet u weten voordat u op de anonimiteit van uw stem vertrouwt.", "limits_2": "Ook de uitgever is een vertrouwenspunt: hij controleert World ID-bewijzen, registreert kiezers en ondertekent elke inschrijving, waarmee hij de enige partij is die weet aan welke verkiezingen u deelnam. Een oneerlijke uitgever zou kiezers kunnen inschrijven die niet bestaan. Uw stem lezen of de telling wijzigen kan hij niet, maar u vertrouwt erop dat hij eerlijk is over wie mag stemmen en discreet over waar u stemde.", + "limits_3": "Je kunt binnen een uur hooguit twee stembiljetten uitbrengen: dat is de grens die voorkomt dat iemand het gas opmaakt dat de organisator betaalt. Een afgedwongen stem kun je dus altijd één keer meteen vervangen; maar word je in het laatste uur vóór de sluiting twee keer achter elkaar gedwongen te stemmen, dan kun je die stemmen niet meer vervangen.", "retention_title": "Bewaren en verwijderen", "retention_1": "Wij bewaren niets op onze eigen servers: je sessie leeft in een cookie in je browser en alles wat duurzaam is staat op de blockchain. Een passkey verwijderen haalt de verzegelde kopie van de chain; de laatste verwijderen wordt geweigerd, omdat je identiteit dan voorgoed onbruikbaar zou zijn.", "retention_2": "Wat al op de blockchain staat, kan noch door ons noch door iemand anders worden gewist. Dat hoort bij een openbaar register, en het is dezelfde eigenschap die u in staat stelt te controleren dat uw stem is meegeteld." diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 129cb2d..c9c257d 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -207,8 +207,8 @@ "step1_desc": "O World ID dá-te uma conta sem revelar quem és. Uma eleição que exija um voto por pessoa pede também uma verificação de documento no teu telemóvel, que não revela mais nada.", "step2_title": "Vote em privado", "step2_desc": "O seu voto é cifrado no seu dispositivo e uma prova de conhecimento zero faz com que seja contado sem revelar a sua escolha. Cada eleição em que participa vê também uma identidade diferente, por isso a cadeia nunca diz em quais participou.", - "step3_title": "Mude seu voto a qualquer hora", - "step3_desc": "Em caso de coerção, você pode votar novamente depois. Só seu último voto conta.", + "step3_title": "Mude o seu voto sem que se note", + "step3_desc": "Se for coagido, pode votar de novo: uma vez de imediato, e depois uma vez por hora. Só conta o seu último voto, e ninguém que observe a cadeia consegue saber que o mudou.", "step4_title": "Verifique os resultados", "step4_desc": "Cada voto cifrado e a contagem final estão na blockchain. Qualquer pessoa pode recontá-los sem quebrar o anonimato de ninguém.", "built_on": "Construído sobre", @@ -614,8 +614,8 @@ "list_unreadable": "Não foi possível ler esta lista a partir da cadeia, o que não é o mesmo que estar vazia. Verifica a ligação e tenta de novo.", "election_unreadable": "Não foi possível ler esta eleição a partir da cadeia, o que não quer dizer que não exista. Verifica a ligação e tenta de novo.", "passkey_unproven": "A passkey foi criada, mas ainda não conseguimos confirmar que consegue decifrar. Prima outra vez: é a mesma que será consultada, sem criar nenhuma nova.", - "epoch_already_cast": "Já emitiu um boletim na última hora. Conta o seu último boletim; poderá alterá-lo novamente quando passar a hora.", - "epoch_already_cast_until": "Já emitiu um boletim nesta hora. Conta o seu último boletim; poderá alterá-lo novamente a partir das {{time}}.", + "epoch_already_cast": "Mudou o seu voto há muito pouco tempo. Conta o seu último boletim; poderá alterá-lo novamente dentro de uma hora.", + "epoch_already_cast_until": "Mudou o seu voto há muito pouco tempo. Conta o seu último boletim; poderá alterá-lo novamente a partir das {{time}}.", "epoch_wait_title": "O seu último voto já conta", "ballot_already_cast": "Este boletim já estava registado. Não precisa de fazer mais nada: o seu voto está lá.", "ballot_expired": "Este boletim foi preparado há demasiado tempo para ser aceite. Vote novamente; um boletim novo demora poucos segundos." @@ -668,7 +668,7 @@ }, "terms": { "title": "Condições de utilização", - "updated": "Última atualização: 3 de setembro de 2026", + "updated": "Última atualização: 28 de setembro de 2026", "what_title": "O que é o Votain", "what_1": "O Votain é um projeto de fim de curso: uma aplicação de voto anónimo e verificável de ponta a ponta, construída sobre a rede de testes Polygon Amoy. É um protótipo de investigação, não um serviço comercial.", "what_2": "Funciona numa rede de TESTES. Os tokens que utiliza não têm valor monetário e a rede não garante permanência: as redes de testes são reiniciadas ou descontinuadas de tempos a tempos, e qualquer eleição aqui realizada pode desaparecer com elas.", @@ -680,7 +680,7 @@ "identity_2": "Quem tiver a frase pode restaurar a identidade em qualquer dispositivo, sem passkey e sem ajuda nossa. Se perder a frase e todas as passkeys que a guardavam, verificar-se outra vez com o World ID permite recomeçar. Recomeçar é limitado de propósito: poderá juntar-se a eleições em que ainda não se tinha inscrito, mas nunca voltar a entrar numa em que já estava, porque o sistema não consegue saber se já lá votou sem quebrar o anonimato do boletim.", "organizer_title": "Se organizar uma eleição", "organizer_1": "É responsável pelo que publica na cadeia. Nomes, descrições e listas de candidatos são guardados de forma pública e permanente, e não podem ser editados nem apagados depois de publicados. Não inclua neles dados pessoais de terceiros.", - "organizer_2": "Financia um único depósito de gas que paga as taxas de transação dos eleitores de TODAS as suas eleições. Se esvaziar, a votação para em todas elas até que o reforce.", + "organizer_2": "Cada eleição paga as taxas dos seus votantes primeiro com o gás que lhe reservou, que não pode levantar até ela terminar, e depois com o seu saldo livre, partilhado por todas as suas eleições. Se ambos se esgotarem, a votação dessa eleição para até recarregar.", "warranty_title": "Sem garantia", "warranty_1": "O Votain é fornecido tal como está, sem garantia de qualquer tipo. Não foi auditado de forma independente. Pode conter defeitos que afetem a correção, a disponibilidade ou a privacidade de uma votação.", "warranty_2": "Na máxima medida permitida por lei, o autor não aceita responsabilidade por perdas ou danos decorrentes da sua utilização.", @@ -689,7 +689,7 @@ }, "privacy": { "title": "Privacidade", - "updated": "Última atualização: 16 de setembro de 2026", + "updated": "Última atualização: 28 de setembro de 2026", "summary_title": "Em resumo", "summary_1": "Nunca ficamos a saber o seu nome, o seu rosto nem os seus dados biométricos. Não sabemos como votou. Os boletins são cifrados antes de saírem do seu dispositivo e só o total final é decifrado; a chave do organizador poderia abrir um boletim isolado, mas nada nele diz de quem é.", "summary_2": "Esta página descreve o que o software faz na realidade, não o que lhe seria permitido fazer, incluindo os pontos em que o desenho não o protege.", @@ -701,13 +701,14 @@ "identity_2": "Isto não é uma formalidade. Se o conseguíssemos decifrar, poderíamos calcular o identificador que o seu boletim transporta e associar cada voto a quem o emitiu. A cifra é o que nos torna isso impossível, não apenas o que no-lo proíbe.", "ballot_title": "O seu boletim", "ballot_1": "A sua escolha é cifrada no navegador antes de ser enviada, com chaves guardadas pelo organizador. A eleição soma todos os boletins ainda cifrados e só essa soma é decifrada, com uma prova que o contrato verifica. Essas chaves poderiam abrir qualquer boletim isolado, e é por isso que nada num boletim diz quem o emitiu nem qual dos seus boletins veio antes.", - "ballot_2": "Pode votar de novo quantas vezes quiser, uma vez por hora; só conta o seu último boletim. Cada boletim novo anula em silêncio o anterior, e um primeiro voto e um alterado são idênticos na cadeia, pelo que ninguém que observe consegue saber que votou de novo. É isso que lhe permite substituir discretamente um voto que foi pressionado a emitir.", + "ballot_2": "Pode votar de novo quantas vezes quiser: uma vez de imediato, e depois uma vez por hora. Só conta o seu último boletim. Cada boletim novo anula em silêncio o anterior, e um primeiro voto e um alterado são idênticos na cadeia, pelo que ninguém consegue saber que votou de novo. É isso que lhe permite substituir discretamente um voto que o obrigaram a emitir.", "onchain_title": "O que é público na cadeia de blocos", "onchain_1": "Público e permanente: que eleições existem, que uma identidade está registada na plataforma, que alguém verificado pela plataforma se inscreveu em cada eleição, os votos cifrados com os totais publicados, e as cópias seladas de cada identidade de voto, uma por passkey registada. O que se inscreve é um compromisso derivado só para essa eleição, por isso a cadeia não diz em que eleições participou uma mesma pessoa. Qualquer pessoa pode ler tudo. Essas cópias são texto cifrado e só abrem com a passkey que as criou, mas a sua existência e o seu número são visíveis.", "onchain_2": "Não é público: a ligação entre uma identidade inscrita e um boletim concreto, nem a ligação entre as inscrições da mesma pessoa em eleições diferentes. Quebrar a primeira é exatamente a razão de ser da prova de conhecimento zero, e é por isso que cada voto é submetido através de um mesmo contrato partilhado em vez de um endereço próprio. A segunda é a razão de o que se inscreve ser derivado para cada eleição em vez de reutilizado.", "limits_title": "Daquilo que isto não o protege", "limits_1": "Ao submeter um voto, o pedido chega ao nosso servidor a partir da sua ligação, pelo que a sua origem nos é visível tal como o é para qualquer site que utilize. Não a usamos para identificar eleitores, mas o desenho não no-lo impede, e deve sabê-lo antes de confiar no anonimato do seu boletim.", "limits_2": "O emissor é também um ponto de confiança: verifica as provas do World ID, regista os eleitores e assina cada inscrição, o que faz dele a única parte capaz de saber em que eleições participou. Um emissor desonesto poderia inscrever eleitores inexistentes. Não conseguiria ler o seu boletim nem alterar a contagem, mas está a confiar que seja honesto sobre quem pode votar e discreto sobre onde votou.", + "limits_3": "Pode emitir no máximo dois boletins no espaço de uma hora: é o limite que impede alguém de esgotar o gás pago pelo organizador. Um voto que o obriguem a emitir pode, por isso, ser sempre substituído uma vez, de imediato; mas se o obrigarem a votar duas vezes seguidas na última hora antes do fecho, já não conseguirá substituir esses votos.", "retention_title": "Conservação e eliminação", "retention_1": "Não guardamos nada nos nossos servidores: a tua sessão vive num cookie do teu navegador e tudo o que é duradouro está na blockchain. Remover uma passkey apaga da cadeia a sua cópia selada; remover a última é recusado, porque deixaria a tua identidade inutilizável para sempre.", "retention_2": "O que já foi escrito na cadeia de blocos não pode ser apagado por nós nem por ninguém. É inerente a um registo público, e é a mesma propriedade que lhe permite verificar que o seu voto foi contado." diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 5caf6ef..bc786be 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -209,8 +209,8 @@ "step1_desc": "World ID даёт вам учётную запись, не раскрывая, кто вы. Голосование, которому нужен один голос от человека, дополнительно просит проверку документа на вашем собственном телефоне, которая больше ничего не раскрывает.", "step2_title": "Голосуйте приватно", "step2_desc": "Ваш голос шифруется на вашем устройстве, а доказательство с нулевым разглашением позволяет учесть его, не раскрывая выбор. Кроме того, каждое голосование видит другую личность, поэтому цепочка никогда не говорит, в каких из них вы участвовали.", - "step3_title": "Меняйте голос в любое время", - "step3_desc": "При принуждении вы можете проголосовать снова позже. Учитывается только ваш последний голос.", + "step3_title": "Меняйте голос незаметно", + "step3_desc": "Если на вас давят, вы можете проголосовать снова: один раз сразу, а затем раз в час. Учитывается только ваш последний голос, и никто, наблюдающий за сетью, не узнает, что вы его изменили.", "step4_title": "Верифицируйте результаты", "step4_desc": "Каждый зашифрованный бюллетень и итоговый подсчёт находятся в блокчейне. Пересчитать их может кто угодно, не нарушая ничьей анонимности.", "built_on": "Построено на", @@ -620,8 +620,8 @@ "list_unreadable": "Этот список не удалось прочитать из сети, а это не то же самое, что пустой список. Проверьте соединение и повторите.", "election_unreadable": "Эти выборы не удалось прочитать из сети, но это не значит, что их нет. Проверьте подключение и попробуйте ещё раз.", "passkey_unproven": "Passkey создан, но мы ещё не смогли убедиться, что он умеет расшифровывать. Нажмите ещё раз: спросят тот же самый, новый создаваться не будет.", - "epoch_already_cast": "Вы уже подали бюллетень за последний час. Учитывается ваш последний бюллетень; изменить его снова можно будет, когда пройдёт час.", - "epoch_already_cast_until": "Вы уже подали бюллетень в этот час. Учитывается ваш последний бюллетень; изменить его снова можно с {{time}}.", + "epoch_already_cast": "Вы только что изменили свой голос. Учитывается ваш последний бюллетень; изменить его снова можно в течение часа.", + "epoch_already_cast_until": "Вы только что изменили свой голос. Учитывается ваш последний бюллетень; изменить его снова можно с {{time}}.", "epoch_wait_title": "Ваш последний голос уже учтён", "ballot_already_cast": "Этот бюллетень уже был записан. Больше ничего делать не нужно: ваш голос учтён.", "ballot_expired": "Этот бюллетень был подготовлен слишком давно, чтобы его приняли. Проголосуйте ещё раз; новый бюллетень готовится за несколько секунд." @@ -674,7 +674,7 @@ }, "terms": { "title": "Условия использования", - "updated": "Последнее обновление: 3 сентября 2026 г.", + "updated": "Последнее обновление: 28 сентября 2026 г.", "what_title": "Что такое Votain", "what_1": "Votain представляет собой дипломный проект: приложение для анонимного голосования с полной проверяемостью, построенное на тестовой сети Polygon Amoy. Это исследовательский прототип, а не коммерческий сервис.", "what_2": "Оно работает в ТЕСТОВОЙ сети. Используемые токены не имеют денежной ценности, а сеть не гарантирует сохранности: тестовые сети время от времени сбрасывают или закрывают, и любое проведённое здесь голосование может исчезнуть вместе с ними.", @@ -686,7 +686,7 @@ "identity_2": "Тот, у кого есть фраза, восстановит личность на любом устройстве, без ключа доступа и без нашей помощи. Если вы потеряли и фразу, и все хранившие её ключи доступа, повторная проверка World ID позволяет начать заново. Начало заново намеренно ограничено: вы сможете присоединиться к голосованиям, куда ещё не записывались, но никогда не вернётесь в те, где уже были, потому что система не может узнать, голосовали ли вы там, не нарушив анонимность бюллетеня.", "organizer_title": "Если вы организуете голосование", "organizer_1": "Вы отвечаете за то, что публикуете в блокчейне. Названия, описания и списки кандидатов хранятся публично и навсегда, и после публикации их нельзя изменить или удалить. Не включайте в них персональные данные других людей.", - "organizer_2": "Вы пополняете единый запас газа, который оплачивает комиссии за транзакции избирателей ВСЕХ ваших голосований. Если он опустеет, голосование остановится в каждом из них, пока вы его не пополните.", + "organizer_2": "Каждые выборы оплачивают комиссии своих избирателей сначала из газа, который вы для них зарезервировали и не можете вывести до их окончания, а затем из вашего свободного баланса, общего для всех ваших выборов. Если закончится и то и другое, голосование на этих выборах остановится, пока вы не пополните баланс.", "warranty_title": "Без гарантий", "warranty_1": "Votain предоставляется как есть, без каких-либо гарантий. Он не проходил независимый аудит. Он может содержать дефекты, влияющие на правильность, доступность или конфиденциальность голосования.", "warranty_2": "В максимальной степени, допускаемой законом, автор не несёт ответственности за убытки или ущерб, возникшие вследствие использования.", @@ -695,7 +695,7 @@ }, "privacy": { "title": "Конфиденциальность", - "updated": "Последнее обновление: 16 сентября 2026 г.", + "updated": "Последнее обновление: 28 сентября 2026 г.", "summary_title": "Коротко", "summary_1": "Мы никогда не узнаём ваше имя, лицо или биометрические данные. Мы не знаем, как вы проголосовали. Бюллетени шифруются до того, как покидают ваше устройство, и расшифровывается только итоговая сумма; ключ организатора мог бы открыть отдельный бюллетень, но ничто в нём не говорит, чей он.", "summary_2": "На этой странице описано, что программа делает на самом деле, а не что ей было бы позволено делать, включая те места, где архитектура вас не защищает.", @@ -707,13 +707,14 @@ "identity_2": "Это не формальность. Если бы мы могли его расшифровать, мы могли бы вычислить идентификатор, который несёт ваш бюллетень, и связать каждый голос с тем, кто его подал. Шифрование делает это для нас невозможным, а не просто запрещает.", "ballot_title": "Ваш бюллетень", "ballot_1": "Ваш выбор шифруется в браузере до отправки ключами, которые хранит организатор. Выборы складывают все бюллетени в зашифрованном виде, и расшифровывается только эта сумма — с доказательством, которое проверяет контракт. Эти ключи могли бы открыть любой отдельный бюллетень, поэтому ничто в бюллетене не говорит, кто его подал и какой из ваших бюллетеней был перед ним.", - "ballot_2": "Вы можете голосовать повторно сколько угодно раз, раз в час; учитывается только последний бюллетень. Каждый новый бюллетень незаметно отменяет предыдущий, а первый и изменённый голос в блокчейне выглядят одинаково, поэтому никто со стороны не может узнать, что вы проголосовали снова. Именно это позволяет незаметно заменить голос, поданный под давлением.", + "ballot_2": "Вы можете переголосовать сколько угодно раз: один раз сразу, а затем раз в час. Учитывается только ваш последний бюллетень. Каждый новый бюллетень незаметно отменяет предыдущий, а первый и изменённый голоса выглядят в сети одинаково, поэтому никто не узнает, что вы проголосовали снова. Именно это позволяет незаметно заменить голос, который вас вынудили отдать.", "onchain_title": "Что публично в блокчейне", "onchain_1": "Публично и навсегда: какие голосования существуют, что личность зарегистрирована на платформе, что кто-то проверенный платформой записался на каждое голосование, зашифрованные бюллетени с опубликованными итогами и запечатанные копии каждой избирательной личности, по одной на зарегистрированный ключ доступа. Записывается обязательство, выведенное только для этого голосования, поэтому цепочка не говорит, в каких голосованиях участвовал один и тот же человек. Всё это доступно для чтения. Копии зашифрованы и открываются только тем ключом доступа, который их создал, но сам факт их наличия и их число видны.", "onchain_2": "Не публично: связь между записавшейся личностью и конкретным бюллетенем, а также связь между записями одного и того же человека в разные голосования. Разорвать первую и есть назначение доказательства с нулевым разглашением, и поэтому каждый голос отправляется через один общий контракт, а не с собственного адреса. Из-за второй то, что записывается, выводится для каждого голосования заново, а не используется повторно.", "limits_title": "От чего это вас не защищает", "limits_1": "Когда вы отправляете голос, запрос приходит на наш сервер через ваше соединение, поэтому его источник виден нам так же, как и любому сайту, которым вы пользуетесь. Мы не используем его для идентификации избирателей, но архитектура нам этого не запрещает, и вам стоит знать об этом, прежде чем полагаться на анонимность вашего бюллетеня.", "limits_2": "Эмитент тоже является точкой доверия: он проверяет доказательства World ID, регистрирует избирателей и подписывает каждую запись, а значит, он единственная сторона, которой известно, в каких голосованиях вы участвовали. Нечестный эмитент мог бы записать несуществующих избирателей. Прочитать ваш бюллетень или изменить подсчёт он не смог бы, но вы полагаетесь на его честность в том, кто имеет право голоса, и на его сдержанность в том, где вы голосовали.", + "limits_3": "В течение часа можно подать не более двух бюллетеней: это ограничение не даёт никому истратить газ, который оплачивает организатор. Поэтому навязанный вам голос всегда можно заменить один раз, сразу; но если вас заставят проголосовать дважды подряд в последний час перед закрытием, заменить эти голоса уже не получится.", "retention_title": "Хранение и удаление", "retention_1": "На своих серверах мы не храним ничего: ваша сессия живёт в cookie в вашем браузере, а всё долговременное находится в блокчейне. Удаление ключа доступа удаляет из цепочки его запечатанную копию; удаление последнего отклоняется, поскольку это навсегда сделало бы вашу личность непригодной.", "retention_2": "То, что уже записано в блокчейн, не можем стереть ни мы, ни кто-либо ещё. Это свойство публичного реестра, и то же самое свойство позволяет вам убедиться, что ваш голос был учтён." diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index 6fd4d78..57a6999 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -203,8 +203,8 @@ "step1_desc": "World ID 给你一个账户,而不透露你是谁。需要一人一票的选举还会在你自己的手机上做证件核验,除此之外不披露任何信息。", "step2_title": "私密投票", "step2_desc": "您的选票在您的设备上加密,零知识证明让它在不暴露选择的情况下被计入。而且您参加的每场选举看到的身份都不同,因此链上永远不会显示您参加过哪些选举。", - "step3_title": "随时更改投票", - "step3_desc": "在胁迫情况下,您可以稍后重新投票。只有最后一票计入。", + "step3_title": "悄悄更改你的投票", + "step3_desc": "如果你被胁迫,可以重新投票:立即一次,之后每小时一次。只计算你的最后一票,任何观察链上数据的人都无法知道你改过票。", "step4_title": "验证结果", "step4_desc": "每一张加密选票和最终计票都在区块链上。任何人都可以重新计票,而不会破坏任何人的匿名性。", "built_on": "基于", @@ -602,8 +602,8 @@ "list_unreadable": "无法从链上读取此列表,这并不等于列表为空。请检查连接后重试。", "election_unreadable": "无法从链上读取这场选举,这并不表示它不存在。请检查连接后重试。", "passkey_unproven": "通行密钥已创建,但还无法确认它能用于解密。请再按一次:会向同一个密钥提问,不会创建新的。", - "epoch_already_cast": "你在过去一小时内已经投过一张选票。以你最新的选票为准;一小时后可以再次更改。", - "epoch_already_cast_until": "你在这一小时内已经投过一张选票。以你最新的选票为准;从 {{time}} 起可以再次更改。", + "epoch_already_cast": "你刚刚更改过投票。以你最新的选票为准;一小时内可以再次更改。", + "epoch_already_cast_until": "你刚刚更改过投票。以你最新的选票为准;从 {{time}} 起可以再次更改。", "epoch_wait_title": "你最新的选票已经生效", "ballot_already_cast": "这张选票已经记录在案。无需再做任何操作:你的投票已被计入。", "ballot_expired": "这张选票准备得太久,已无法被接受。请重新投票;生成新选票只需几秒钟。" @@ -656,7 +656,7 @@ }, "terms": { "title": "使用条款", - "updated": "最后更新:2026年9月3日", + "updated": "最后更新:2026年9月28日", "what_title": "Votain 是什么", "what_1": "Votain 是一个本科毕业设计项目:一个端到端可验证的匿名投票应用,构建在 Polygon Amoy 测试网上。它是研究原型,而非商业服务。", "what_2": "它运行在测试网上。所用代币没有货币价值,网络也不保证长期存续:测试网会不时被重置或停用,在这里举行的任何选举都可能随之消失。", @@ -668,7 +668,7 @@ "identity_2": "拿到助记词的人可以在任何设备上恢复该身份,不需要通行密钥,也不需要我们帮忙。如果助记词和保管它的所有通行密钥都丢失,重新用 World ID 验证可以从头开始。从头开始是有意受限的:你可以参加尚未报名的选举,但永远无法重新进入已报名的选举,因为系统无法在不破坏选票匿名性的前提下判断你是否已在那里投过票。", "organizer_title": "如果您组织一场选举", "organizer_1": "您对发布到链上的内容负责。名称、说明和候选人名单以公开且永久的方式存储,发布后无法修改或删除。请勿在其中包含他人的个人数据。", - "organizer_2": "您充值的是一个统一的 gas 储备,它为您全部选举的投票者支付交易费用。储备耗尽时,每一场选举的投票都会停止,直到您重新充值。", + "organizer_2": "每场选举先用你为它预留的 gas 支付其投票人的手续费(选举结束前不能取回),再用你所有选举共用的可用余额支付。两者都用完时,该选举的投票会暂停,直到你充值。", "warranty_title": "不提供任何担保", "warranty_1": "Votain 按现状提供,不附带任何形式的担保。它未经过独立审计,可能存在影响表决正确性、可用性或私密性的缺陷。", "warranty_2": "在法律允许的最大范围内,作者不对因使用本软件而产生的损失或损害承担责任。", @@ -677,7 +677,7 @@ }, "privacy": { "title": "隐私", - "updated": "最后更新:2026年9月16日", + "updated": "最后更新:2026年9月28日", "summary_title": "简而言之", "summary_1": "我们从不知道你的姓名、面孔或生物特征。我们不知道你投了什么。选票在离开你的设备之前就已加密,最终只有总数会被解密;组织者的密钥可以打开单张选票,但选票上没有任何信息表明它属于谁。", "summary_2": "本页描述软件实际所做的事,而不是它被允许做的事,其中也包括这一设计无法保护您的地方。", @@ -689,13 +689,14 @@ "identity_2": "这不是形式上的说法。如果我们能解密它,我们就能算出您选票所携带的标识符,把每一票和投票的人对应起来。是加密让我们做不到,而不仅仅是规则不许我们这么做。", "ballot_title": "您的选票", "ballot_1": "你的选择在提交前就在浏览器中用组织者持有的密钥加密。选举在加密状态下把所有选票相加,只有这个总和会被解密,并附带合约会检查的证明。这些密钥可以打开任意一张选票,因此选票上没有任何信息表明是谁投的,或者你的哪一张选票在它之前。", - "ballot_2": "你可以随意多次重新投票,每小时一次;只有最后一张选票有效。每张新选票都会悄悄取消你的上一张,而首次投票和更改后的投票在链上看起来完全一样,所以旁观者无法知道你重新投过票。这正是让你能悄悄替换被迫投出的那一票的原因。", + "ballot_2": "你可以随意多次重新投票:立即一次,之后每小时一次。只计算你的最后一张选票。每张新选票都会悄悄作废上一张,第一次投票和更改后的投票在链上看起来完全一样,所以没人能知道你重新投过票。这让你可以悄悄替换被迫投出的一票。", "onchain_title": "链上公开的内容", "onchain_1": "公开且永久:存在哪些选举、某个身份已在平台注册、某位经平台验证的人登记了每场选举、加密选票及其公布的总计,以及每个投票身份的加封副本,每个已注册的通行密钥一份。登记时使用的是仅为该场选举派生的承诺值,因此链上不会显示同一个人参加过哪些选举。任何人都可以读取全部内容。这些副本是密文,只有创建它们的通行密钥才能打开,但它们的存在和数量是可见的。", "onchain_2": "不公开:已报名身份与某张具体选票之间的关联,以及同一个人在不同选举中登记记录之间的关联。切断前者正是零知识证明的用途,也是每一票都通过同一个共享合约提交、而不是从您专属地址发出的原因。后者则是登记值按每场选举派生、而不重复使用的原因。", "limits_title": "它无法为您抵挡的风险", "limits_1": "当您提交投票时,请求会经由您的网络连接到达我们的服务器,因此它的来源对我们可见,就像对您使用的任何网站一样。我们不会用它来识别投票者,但这一设计并未阻止我们这样做,在您依赖选票的匿名性之前应当知道这一点。", "limits_2": "签发方同样是一个信任点:它验证 World ID 证明、注册投票者,并为每一次登记签名,因此它也是唯一能够得知您参加过哪些选举的一方。不诚实的签发方可以注册并不存在的投票者。它无法读取您的选票,也无法更改计票结果,但您确实要信赖它在谁有资格投票这件事上是诚实的,在您于何处投票这件事上是审慎的。", + "limits_3": "一小时内你最多只能投两张选票:这个限制防止有人耗尽组织者支付的 gas。因此,被迫投出的一票总能立即替换一次;但如果你在投票截止前的最后一小时内被迫连续投票两次,就无法再替换那些票。", "retention_title": "保存与删除", "retention_1": "我们的服务器上不保存任何东西:你的会话存在浏览器的 Cookie 中,所有持久内容都在区块链上。移除一个通行密钥会从链上删除它的加封副本;移除最后一个会被拒绝,因为那会让你的身份永久失效。", "retention_2": "已经写入区块链的内容,我们和任何人都无法抹除。这是公共账本固有的特性,也正是让您能够核实自己的票被计入的同一个特性。" diff --git a/frontend/src/pages/public/Privacy.tsx b/frontend/src/pages/public/Privacy.tsx index a32f041..9e5e281 100644 --- a/frontend/src/pages/public/Privacy.tsx +++ b/frontend/src/pages/public/Privacy.tsx @@ -6,7 +6,7 @@ const SECTIONS: LegalSection[] = [ { titleKey: 'privacy.identity_title', bodyKeys: ['privacy.identity_1', 'privacy.identity_2'] }, { titleKey: 'privacy.ballot_title', bodyKeys: ['privacy.ballot_1', 'privacy.ballot_2'] }, { titleKey: 'privacy.onchain_title', bodyKeys: ['privacy.onchain_1', 'privacy.onchain_2'] }, - { titleKey: 'privacy.limits_title', bodyKeys: ['privacy.limits_1', 'privacy.limits_2'] }, + { titleKey: 'privacy.limits_title', bodyKeys: ['privacy.limits_1', 'privacy.limits_2', 'privacy.limits_3'] }, { titleKey: 'privacy.retention_title', bodyKeys: ['privacy.retention_1', 'privacy.retention_2'] }, ]; From 3feb83a07b0fd98597bc730463a325318d9ccb89 Mon Sep 17 00:00:00 2001 From: CodeInIA Date: Mon, 28 Sep 2026 15:01:38 +0200 Subject: [PATCH 25/32] docs: the current ballot scheme, the Amoy deploy steps and the last-hour limit - README: anonymity comes from Votain's own ballot proof over Semaphore identities, not from Semaphore proofs; test counts; the last-hour limit. - Glossary: nullifier and nonce described the scheme the hidden re-votes replaced; adds the epoch tag, fixes the V4 identity commitment. - deployment.md had no step for the contracts: ceremony, attester, deploy:amoy, the manifests to commit, VITE_CIRCUITS_URL, backend addresses. - PLAN.md and the agent log presented Paillier as current; marked as history. - Package guides: test counts, deploy:amoy's ceremony requirement, and backend debt that no longer applied. - deploy.ts: deployVerifiers' comment back above deployVerifiers. --- README.md | 13 +++++++++---- backend/DEVELOPMENT.md | 12 +++++++----- contracts/DEVELOPMENT.md | 4 ++-- contracts/scripts/deploy.ts | 22 +++++++++++----------- docs/PLAN.md | 8 +++++--- docs/ai/state.md | 2 ++ docs/dev/architecture.md | 20 +++++++++++++++++--- docs/dev/deployment.md | 31 +++++++++++++++++++++++++++++++ docs/dev/glossary.md | 12 +++++++----- 9 files changed, 91 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index d5776ab..5a17cfa 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Votain is a Bachelor's thesis project (TFG) demonstrating how modern cryptograph | Property | How it is achieved | |----------|--------------------| | **End-to-end verifiable** | Every ballot is an ElGamal ciphertext on chain with a zero-knowledge proof that it is exactly one valid option, so a stuffed or malformed ballot never gets in. The election adds every ballot into an aggregate itself, and publishes a result only with a proof that the counts are that aggregate's decryption. Anyone can re-add the ballots and re-check the proof with no key, in the results screen or with `npm run tally -- --verify`. The auditor CLI additionally pins a result JSON to IPFS; the in-app tally does not pin yet | -| **Anonymous** | Semaphore V4 zero-knowledge proofs hide voter identity inside the eligible-voters group | +| **Anonymous** | Each ballot carries a zero-knowledge proof that its voter is on the election's roll, over Semaphore V4 identities and a per-election tree, without saying which member cast it | | **Coercion resistant** | A coerced voter can vote again, and nobody watching can tell they did. Every ballot silently cancels the voter's previous one, proved in zero knowledge without saying which, and a first vote and a re-vote look the same on chain. One ballot per voter per hour bounds how fast anyone can spend the organizer's gas, through tags that link nothing. The limit, stated plainly: the organizer's key could open a single ballot and see that it replaced one, though never whose it is (see [Known limits](#known-limits)) | | **Sybil resistant** | World ID v4 proof of personhood under one action fixed by the server, so one person holds one platform identity. Elections that require a document also refuse the same passport or ID card twice, whatever World ID account it arrives with | | **Eligible without identifying** | Age and nationality come from the chip in a passport or national identity card, read over NFC by the [Self](https://self.xyz) app and proved in zero knowledge. The document never leaves the phone, and age is asked as a predicate: the answer is "over 18", never a date of birth | @@ -138,13 +138,13 @@ npx hardhat compile # Backend (requires .env with ISSUER_PRIVATE_KEY, see backend/.env.example) cd backend npm install -npm test # 144 tests, node:test +npm test # 160 tests, node:test npm run dev # http://localhost:3000 # Frontend cd frontend npm install -npm test # 562 tests, vitest +npm test # 597 tests, vitest npm run dev # http://localhost:5173 npm run build ``` @@ -174,7 +174,7 @@ auditor CLI. See [`docs/PLAN.md`](docs/PLAN.md) for the plan and | H11 | Backend in a TEE | ✅ verified on Phala (Intel TDX): image pinned by digest, signed provenance, attestation reports that digest. Served from Heroku between sessions, since Phala is $42/month; one workflow switches either way | | H12, H13 | Thesis and defense | ⏳ | -**891 tests pass**: 185 on the contracts, 144 on the backend, 562 on the frontend. +**987 tests pass**: 8 on the circuits, 222 on the contracts (the end-to-end suite with real Groth16 proofs), 160 on the backend, 597 on the frontend. The Playwright smoke suite renders every screen on desktop, tablet and phone (72). ## Known limits @@ -187,6 +187,11 @@ in [`docs/dev/architecture.md`](docs/dev/architecture.md#known-limits-stated-pla it replaced. Splitting the key among trustees (threshold decryption) would remove the power; on ElGamal that is now a protocol to add, not a primitive to change. +- **Two votes forced in a row in the last hour stand.** One voter can cast at + most two ballots within an hour, the limit that keeps them from draining the + organizer's gas without linking their ballots. A vote forced on a voter can + always be replaced once, straight away; two forced in a row after the last + hour boundary before voting closes cannot be. - **The trusted setup is the deployment's to run.** The default circuit build uses a public development ceremony, which `deploy.ts` refuses off the local chain. diff --git a/backend/DEVELOPMENT.md b/backend/DEVELOPMENT.md index 993c7f7..3b80486 100644 --- a/backend/DEVELOPMENT.md +++ b/backend/DEVELOPMENT.md @@ -357,7 +357,7 @@ Source layout: `sd/issuer.ts` (shared SD-JWT instance + disclosure frame), ```bash npm run dev # tsx watch src/index.ts (hot-reload) -npm test # node --test (session, identity vault, SD-JWT, Status List): 17 tests +npm test # node --test (session, identity vault, SD-JWT, Status List, relay, chain): 160 tests ``` ## Required environment variables @@ -386,10 +386,12 @@ PUBLIC_URL=http://localhost:3000 # base for credentialStatus URLs ## Technical debt (see `docs/dev/state.md`) -- Wire World ID Credentials selective disclosure (passport NFC) to replace the demo attribute - values (`DEMO_VC_*`). Schema is already source-agnostic. -- Deployment on Phala Network TEE (H11): private key generated inside the enclave, `/attestation` - endpoint, image hash referenced from PlatformRegistry. +- The credential carries no identity attributes: the demo country, region and `ageOver18` it + used to assert for everybody were removed, since nothing had verified them. Age and nationality + are proved per election from the voter's document (Self, `eligibility/`); the SD-JWT frame stays + for when a verified source can fill it. +- Phala (H11) is live, image pinned by digest (see `docs/dev/deployment.md`). Still owed: the + signing keys generated inside the enclave rather than supplied through `PHALA_ENV`. ## Target deployment diff --git a/contracts/DEVELOPMENT.md b/contracts/DEVELOPMENT.md index bb6dcda..e641a92 100644 --- a/contracts/DEVELOPMENT.md +++ b/contracts/DEVELOPMENT.md @@ -148,7 +148,7 @@ will ever run. ## Commands ```bash -npx hardhat test # 127 tests, including the E2E suite +npx hardhat test # 222 tests, including the E2E suite # Restricted-election walkthrough against a running local node. Covers every leg # of the eligibility flow except the Self app reading a passport, which needs a @@ -161,7 +161,7 @@ npx hardhat test --coverage # line coverage report -> coverage/ npx hardhat test test/E2E.test.ts # full election with REAL Groth16 proofs npx hardhat compile npm run deploy:local # in-process network -npm run deploy:amoy # Polygon Amoy (needs .env PRIVATE_KEY) +npm run deploy:amoy # Polygon Amoy: needs .env PRIVATE_KEY and a real ceremony (docs/dev/deployment.md) npm run node:local # standalone node on 127.0.0.1:8545 npm run seed:local # demo data against that node npm run tunnel:local # expose that node at rpc.votain.app diff --git a/contracts/scripts/deploy.ts b/contracts/scripts/deploy.ts index d6d99aa..3afd477 100644 --- a/contracts/scripts/deploy.ts +++ b/contracts/scripts/deploy.ts @@ -44,17 +44,6 @@ async function ensurePoseidon(deployer: any, name: PoseidonName): Promise { diff --git a/docs/PLAN.md b/docs/PLAN.md index a7142a1..1a68dbe 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -6,10 +6,12 @@ **Votain** is an end-to-end verifiable, anonymous, coercion-resistant voting dApp on Polygon Amoy. The architecture combines: -- **Smart contracts** in Solidity (Semaphore V4) inside `contracts/`. +- **Smart contracts** in Solidity inside `contracts/`: Semaphore V4 identities and membership trees, exponential ElGamal ballots the election adds into an aggregate itself, and Groth16 verifiers generated from the circuits in `circuits/`. - **Backend issuer** in Node.js emitting Verifiable Credentials (SD-JWT) after validating World ID, inside `backend/`. Will be deployed on a **decentralized TEE** (Phala Network) at the end. -- **Frontend** in React + Vite with **passkey-derived identities**, homomorphic Paillier and `@semaphore-protocol/*` inside `frontend/`. Will be published on **IPFS with CI/CD (Fleek)**. -- **Tally script** off-chain to filter votes by nullifier and decrypt results. +- **Frontend** in React + Vite with **passkey-derived identities**, ballots encrypted and proved in the browser (`snarkjs`) and `@semaphore-protocol/{identity,group}` inside `frontend/`. Published on **IPFS through 4EVERLAND** at `votain.app`. +- **Tally**: in the app or the `scripts-tally` CLI, the organizer decrypts the on-chain aggregate and proves the decryption; the contract only publishes a result that proof accepts. `--verify` re-checks a published one with no key. + +> Sections written before 2026-09-24 describe the ballots as Paillier ciphertexts filtered by nullifier and nonce. That scheme was replaced by exponential ElGamal with hidden re-votes; [`docs/dev/architecture.md`](dev/architecture.md) is the current description. The milestones and their order below still stand. ### Confirmed stack decisions diff --git a/docs/ai/state.md b/docs/ai/state.md index 26a7a8e..76591d0 100644 --- a/docs/ai/state.md +++ b/docs/ai/state.md @@ -1,5 +1,7 @@ # Votain: Agent State +> **Superseded in part on 2026-09-24.** Ballots are no longer Paillier ciphertexts with a public nullifier and nonce: they are exponential ElGamal on Baby Jubjub with hidden re-votes, proved by the circuits in `circuits/`, and the contract proves the tally itself. Entries below that describe Paillier, `VoteCast`, `SemaphoreVerifierV4` or the trusted forwarder are history. The current state is in [`docs/dev/state.md`](../dev/state.md) and [`docs/dev/architecture.md`](../dev/architecture.md). + ## Current milestone: post-Phase-B lifecycle + tally + UX pass ✅, next up: live Amoy deploy ### Post-Phase-B pass (2026-07) diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 1862018..6a53011 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -1580,9 +1580,23 @@ written down so that nobody reads their absence as a guarantee. the voter's SECRET can, since they can compute the voter's tags; that is the same as holding the voter's identity. - **Two ballots an hour at most.** Epoch tags bound how fast one voter can spend - the organizer's tank: a proof may name this epoch or the last, so two in any - hour, and the app uses one. A coerced voter's override is delayed, never - refused. + the organizer's tank: a proof may name this epoch or the last, each tag once, + so two in any hour. Epochs are clock hours (`block.timestamp / 1 hours`), not + an hour from the last ballot. The app (`chooseEpoch` in `lib/ballot.ts`) uses + both: a voter who has just cast can replace that ballot straight away by + naming the previous epoch, unless the hour is under two minutes from turning, + when the proof would arrive stale. +- **Which of the two epochs is drawn at random.** The epoch is public calldata. + Were the previous epoch named only once the current one was spent, a ballot + naming it would say "this voter already cast this hour": a visible re-vote, + in the last hour, where a coercer watches. Drawn at random when both are free, + a first ballot names either half the time and a re-vote names whichever is + left, so no single ballot says which it is. +- **Two votes forced in a row in the last hour stand.** A coercer who forces two + ballots after the last hour boundary before `voteEnd` spends both tags, and + no next hour comes. A sponsored-quota proof ("at most K sponsored ballots", + the voter paying beyond it) would remove the window at the cost of a new + circuit and ceremony. - **The trusted setup is the deployment's to run.** The default build uses a public development ceremony, refused off the local chain. A deployment runs its own phase 2 (`CEREMONY_ENTROPY`, better a multi-party contribution) over diff --git a/docs/dev/deployment.md b/docs/dev/deployment.md index c0e2de7..9062f45 100644 --- a/docs/dev/deployment.md +++ b/docs/dev/deployment.md @@ -222,6 +222,37 @@ touches the backend will drag the hostname back to Heroku and stop the CVM, silently, with every test green, doing exactly what it was told. That is the right behaviour during development and the wrong behaviour in November. +## Deploying the contracts to Amoy + +Not part of any workflow: a contract deployment is a one-off decision, and so is +the ceremony it rests on. In order, because each step needs the one before: + +1. **Run the ceremony.** `cd circuits && PTAU= + CEREMONY_ENTROPY= npm run build`. The phase 1 comes from a public + ceremony (Hermez or the PSE perpetual powers of tau; the historical Hermez URL + answers 403, so take it from a mirror and check its hash). Discard the + entropy afterwards: whoever keeps it can forge ballots. On Windows this needs + the native circom compiler (see the README). +2. **Name the platform attester.** `ELIGIBILITY_ATTESTER_PRIVATE_KEY` in + `backend/.env`, or `PLATFORM_ATTESTER_ADDRESS`, and it must be the key the + production backend signs with: it is frozen into every election. +3. **Deploy.** `PRIVATE_KEY` and `AMOY_RPC_URL` in `contracts/.env`, then + `cd contracts && npm run deploy:amoy`. The script refuses the development + ceremony, a missing circuit build and a missing attester **before its first + transaction**, so a refusal costs nothing. It writes + `contracts/deployments/amoy.json` and mirrors it to + `frontend/src/lib/deployments/amoy.json`: commit both, since the frontend is + built from the repository and only `local.json` is ignored. +4. **Publish the proving files.** `frontend/public/circuits/` holds the wasm, + zkey and vkey of THIS ceremony, tens of megabytes per zkey and too large for + the repository. Pin them (IPFS, say) and set `VITE_CIRCUITS_URL` in the + 4EVERLAND build environment, with `VITE_CHAIN_NETWORK=amoy`. A wrong file does + no harm beyond wasting a voter's time: the chain refuses the proofs it makes. +5. **Point the backend at it.** Production reads addresses from its environment + and ships no manifest: `REGISTRY_ADDRESS` and `PAYMASTER_ADDRESS` in + `PHALA_ENV` (and on Heroku), with the relayer and registrar wallets funded + with POL. The relayer is reimbursed by the paymaster; the registrar is not. + ## Secrets and variables Repository secrets: diff --git a/docs/dev/glossary.md b/docs/dev/glossary.md index 48bb879..fdf9631 100644 --- a/docs/dev/glossary.md +++ b/docs/dev/glossary.md @@ -4,13 +4,13 @@ **ZKP (Zero-Knowledge Proof)**: cryptographic proof that demonstrates knowledge of a secret without revealing it. In Votain: the voter proves they belong to the election group without revealing their identity. -**Groth16**: ZK proof system used by Semaphore V4. Generates small proofs (~288 bytes) with cheap on-chain verification. +**Groth16**: the ZK proof system of Votain's ballot and tally circuits. Small proofs (~288 bytes) with cheap on-chain verification, at the price of a trusted setup (see below). -**Identity Commitment**: Poseidon hash of the user's nullifier and secret. This is what gets registered publicly in the Merkle tree of the group; reveals nothing about the user. +**Identity Commitment**: in Semaphore V4, the Poseidon hash of the identity's public key. This is what gets registered publicly in the Merkle tree of the group; reveals nothing about the user. -**Nullifier**: value derived from the identity secret and the election scope. Detects double voting without revealing identity. In Votain, extended with `nonce` to implement coercion resistance. +**Nullifier**: in Semaphore, a value derived from the identity secret and a scope that detects double signalling. Votain ballots no longer publish one: a public nullifier shows that the same voter cast two ballots, which is what a coercer needs to see a re-vote. The ballot tag and the epoch tag replace it (below). The word survives for World ID, whose nullifier identifies a human to the platform. -**Nonce (coercion resistance)**: per-nullifier counter. Only the vote with the highest `nonce` per nullifier counts in the tally. Allows a coerced user to cast a new vote (nonce+1) that overrides the previous one. +**Re-vote (coercion resistance)**: a voter may cast again, and the later ballot replaces the earlier. Each ballot carries a proved cancellation of that voter's previous one, so the sum of all ballots holds only each voter's last vote, and nothing public says a ballot is a re-vote. It replaced the earlier per-nullifier `nonce`, which made re-votes visible. One ballot per voter per hour (the epoch tag) bounds how fast the organizer's gas can be spent. **Scope**: election identifier within Semaphore. Nullifiers are scope-specific. The same user has different nullifiers in different elections. @@ -20,6 +20,8 @@ **Ballot tag**: the public identifier of one ballot, Poseidon(TAG, secret, scope, k) for the k-th ballot of a voter. Only the voter can compute their tags, so only they can tell which ballots are theirs; a re-vote carries a new tag that links to nothing. +**Epoch tag**: Poseidon(EPOCH, secret, scope, epoch), one per voter per hour. The contract accepts each once, which limits a voter to one ballot an hour without linking their ballots across hours. + **Groth16 / trusted setup**: the zero-knowledge proof system of the ballot and tally circuits. Each circuit needs a one-off setup whose randomness must be destroyed; whoever keeps it can forge proofs. **Paillier Homomorphic Encryption**: the additive scheme Votain used before ElGamal. Its keys are hard to generate among several parties, which is part of why it was replaced. @@ -36,7 +38,7 @@ **Domain claim (`OrganizerDomains`)**: the badge a voter sees on an election is a DOMAIN, not a checkmark, because a checkmark only means something if you trust whoever granted it while a domain carries its own evidence. Proof runs in two directions and needs both: DNS says the domain names the wallet (a TXT record at `_votain.` with the value `v=votain1; address=0x...`), and the chain says the wallet claims the domain (`claim`, a transaction from the organizer's own address). Verifying costs no signature; the signature records the claim, and only once DNS already agrees. The contract stores a claim and not a credential, since domain control is whatever DNS answers right now. See `docs/dev/architecture.md`. -**Semaphore V4**: ZK-based anonymity protocol (PSE/Ethereum Foundation). Allows a group of users to make anonymous signals (votes) without revealing who voted. V4 introduces circuit efficiency improvements. +**Semaphore V4**: ZK-based anonymity protocol (PSE/Ethereum Foundation). Votain uses its identities and its LeanIMT membership trees; the ballot itself is proved by Votain's own circuit, which checks the same membership and adds the vote, the cancellation and the tags. **PolygonScan (Amoy)**: block explorer for Polygon Amoy testnet. Used to verify contracts and transactions publicly. From a408b3142d20886cc58c3cdafe9c80ea937c5b39 Mon Sep 17 00:00:00 2001 From: CodeInIA Date: Mon, 28 Sep 2026 15:07:23 +0200 Subject: [PATCH 26/32] chore(deps): update every package to its latest release npm-check-updates -u in contracts, backend, frontend and scripts-tally; circuits was already current. One major: hardhat-toolbox-mocha-ethers 3 to 4, which moves the contract tests to Mocha 12 and hardhat-mocha 4. The rest are minors and patches (hardhat 3.18, idkit 4.3, vite 8.3.1, vitest 5.0.2, oxlint 1.86, snarkjs 0.7.6, dotenv 18.0.4). After npm install, all green: contracts 222 (the E2E suite with real Groth16 proofs), backend 160, frontend 597, lint, typechecks and builds. oxlint 1.86 adds React's purity rule, which caught the date picker reading the clock during render to mark today. It now reads it when the popover opens. --- backend/package-lock.json | 178 ++-- backend/package.json | 6 +- contracts/package-lock.json | 1136 ++++----------------- contracts/package.json | 10 +- frontend/package-lock.json | 505 ++++----- frontend/package.json | 16 +- frontend/src/components/ui/DatePicker.tsx | 10 +- scripts-tally/package-lock.json | 18 +- scripts-tally/package.json | 6 +- 9 files changed, 516 insertions(+), 1369 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index f25aafb..fb8b4ab 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,7 +15,7 @@ "@worldcoin/idkit-core": "^4.3.0", "cookie-parser": "^1.4.7", "cors": "^2.8.6", - "dotenv": "^18.0.2", + "dotenv": "^18.0.4", "ethers": "^6.17.0", "express": "^5.2.1", "express-rate-limit": "^8.7.0" @@ -24,8 +24,8 @@ "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/node": "^26.6.2", - "oxlint": "^1.85.0", + "@types/node": "^26.6.3", + "oxlint": "^1.86.0", "tsx": "^4.23.15", "typescript": "^7.0.2" }, @@ -1388,9 +1388,9 @@ "license": "Apache-2.0" }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.85.0.tgz", - "integrity": "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.86.0.tgz", + "integrity": "sha512-63Ozq6yQn79B2UxjSZ9huJmI+3/ZPI7hxAe6TyFh9oQL447KnIMoffOdtog+h4KILbQr61ZRvFPW5gYbAUgpgQ==", "cpu": [ "arm" ], @@ -1405,9 +1405,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.85.0.tgz", - "integrity": "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.86.0.tgz", + "integrity": "sha512-JdLGp44phbf/0Du236Dt06yfK6Qw8xhTFeDvHJdjk167r59OfP5+BL31mgMYVnswS8e+j6tFuccSY0QDC/lgCw==", "cpu": [ "arm64" ], @@ -1422,9 +1422,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.85.0.tgz", - "integrity": "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.86.0.tgz", + "integrity": "sha512-h+vkOr4ik6KLFCXdtNVEj9xfnXfNUpPoF95QjORdhiSpLqQePzcN81kGHjAgsw1/G7WBWK5KsFKbI2UK8EyzMA==", "cpu": [ "arm64" ], @@ -1439,9 +1439,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.85.0.tgz", - "integrity": "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.86.0.tgz", + "integrity": "sha512-wbglRuH5eKsp68keTOOeKDHU1astWSRYFsh2bwKQTAPb44rD6kVgzYI1ZrAJRTc6/7PH55ZXFCQx0rQXrj0UdA==", "cpu": [ "x64" ], @@ -1456,9 +1456,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.85.0.tgz", - "integrity": "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.86.0.tgz", + "integrity": "sha512-yTQ7QSOlNfQsrfbeVweTiegQJLKPjUKgXp/JpGiFzza6Hp29S9LAoH77TM3L0RjK39iQRZB5lk5iq2t241Mfag==", "cpu": [ "x64" ], @@ -1473,9 +1473,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.85.0.tgz", - "integrity": "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.86.0.tgz", + "integrity": "sha512-QYJGy0E2Tv8GAayyBIeHFTwQb8u+zrvrOYrifuDGZDr9ad5XsDhiMScpZymhS9fwWjqrgG2ZUwT4vpY0ISqp4Q==", "cpu": [ "arm" ], @@ -1490,9 +1490,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.85.0.tgz", - "integrity": "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.86.0.tgz", + "integrity": "sha512-oQo83CafLT8p3qh0AGkN0tSUIh5Hm63mfDsoqUmBQUBnSZLNBAbyRaAftfGY5a0cKZ34dG6jAtMYgcY5kZqt5g==", "cpu": [ "arm" ], @@ -1507,9 +1507,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.85.0.tgz", - "integrity": "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.86.0.tgz", + "integrity": "sha512-EM6wy5c2UM12qPb7iDJDBAZvHyfEJGH6iosFgaFiaRZpirz99//EHcUyD2KAskz07x1TULuiAtsojf4nvWWksw==", "cpu": [ "arm64" ], @@ -1527,9 +1527,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.85.0.tgz", - "integrity": "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.86.0.tgz", + "integrity": "sha512-vCiUQb9ZNzZalxYRU4mlZwUqT5f+c7Sk9DwUOplWHtik3Dl4r3DVnUB5tBeRuTAUe6MUVCTylNW7TkkN8J1oTw==", "cpu": [ "arm64" ], @@ -1547,9 +1547,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.85.0.tgz", - "integrity": "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.86.0.tgz", + "integrity": "sha512-T0AzE89yjTYmOhB/hb6i+rWTpe2mYt+xiguBhs9DEPIJTZ1H8OYqsE4VxJHHw2Wlq8Mztwvp2P8NV87ZOuhPRw==", "cpu": [ "ppc64" ], @@ -1567,9 +1567,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.85.0.tgz", - "integrity": "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.86.0.tgz", + "integrity": "sha512-s49r0K2f5Oc7fJoMUO1WkdhYK5Ob79uYRwWQ/XNoGysaOq3RUGmGUbHSuXiyydTTA+P9xEdDEnnlZVXiaSJhlw==", "cpu": [ "riscv64" ], @@ -1587,9 +1587,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.85.0.tgz", - "integrity": "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.86.0.tgz", + "integrity": "sha512-JIa2tYR8ayb3nvcMSZzn4O/xTy13S7MHllu7cBD+B5qDQXXSRy/Vwk1aNpiTjZmBJGwKCHOy/NvnY5Xcwg27uQ==", "cpu": [ "riscv64" ], @@ -1607,9 +1607,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.85.0.tgz", - "integrity": "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.86.0.tgz", + "integrity": "sha512-XwXMyBsAuWZkhZU74UNcfQKr1CJJAKnS80dZzOnjl6A4fKcf91IjFgidegQugMHA5SN8ulwDXEDV1yPntjXgQQ==", "cpu": [ "s390x" ], @@ -1627,9 +1627,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.85.0.tgz", - "integrity": "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.86.0.tgz", + "integrity": "sha512-C1WjukSyMnr66b+w1/tV8RFVv6d9v0MzDf4p9IxVXknqgmTHBgZh1pccN1eHzFr0b9Tbb3OXoPsAAdAuHAQfeA==", "cpu": [ "x64" ], @@ -1647,9 +1647,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.85.0.tgz", - "integrity": "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.86.0.tgz", + "integrity": "sha512-ap6KLmvC38c6MdYzsIh25cXQupqYvjd37tMNftzrX1DCtkX1Gcf2B+S2B17dD4lKa5c5gJog7DQJyDo82PBKyw==", "cpu": [ "x64" ], @@ -1667,9 +1667,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.85.0.tgz", - "integrity": "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.86.0.tgz", + "integrity": "sha512-996Q9w/2GLwNyy5Djs39Jt09aFlr4b7TPhoj7dvo5FHmcyiF68nPz73HNc5UAYKIYeK+M9JzPQ4Wg1r1dXvP6A==", "cpu": [ "arm64" ], @@ -1684,9 +1684,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.85.0.tgz", - "integrity": "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.86.0.tgz", + "integrity": "sha512-9uTCHYTknNkug5+46ViSoUMuOuGWK61D87QnblU6CXaWKJC630Z7dbls61vdHxya/cR2ycQZ6Mm6ckgF6Cag5g==", "cpu": [ "arm64" ], @@ -1701,9 +1701,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.85.0.tgz", - "integrity": "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.86.0.tgz", + "integrity": "sha512-a47ga7EKVfMU8rO0i1m3mGwfNRj3FhQyt1d/ukO/E9u1HHF3KpR3tyb3VhuJBGEVPF8uR4exSyHUyx7xbE+uxw==", "cpu": [ "ia32" ], @@ -1718,9 +1718,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.85.0.tgz", - "integrity": "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.86.0.tgz", + "integrity": "sha512-az4dzifCRzES1crwu6pDFXzJTIanrdxXFL9TbiJWrx9LKcuacbR83aPvxDA1LKLNswi/RoqitC7Afcflhu3zBQ==", "cpu": [ "x64" ], @@ -2159,9 +2159,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", - "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", "license": "MIT", "dependencies": { "undici-types": "~8.9.0" @@ -3524,9 +3524,9 @@ "license": "MIT" }, "node_modules/dotenv": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.2.tgz", - "integrity": "sha512-wl0U3CxC0iobaryqEKpFJxzyp4AxQFYAKj/WWszXZr87vVHLi2yJv2syax5Vxt+u02xqNgE277rsc3lOKg9sPg==", + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.4.tgz", + "integrity": "sha512-jGt9uI5BIxpVO15kvy+iEFqNAwxYClKWnkptLjSNPnFLC003Imd0ofr3aEVsbpHdSBdSsbcApNIlz4nbyGMwdA==", "license": "BSD-2-Clause", "bin": { "dotenv": "dist/index.cjs" @@ -5371,9 +5371,9 @@ } }, "node_modules/oxlint": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.85.0.tgz", - "integrity": "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.86.0.tgz", + "integrity": "sha512-og0lhgvZfgGF//gOOmZXvtr+GmBbAGEnbEhv/QUg7UW2Wi4wHMnJbnMD+zuHgPdJxdklfgpPupdlAaAySyxrZg==", "dev": true, "license": "MIT", "bin": { @@ -5386,28 +5386,28 @@ "url": "https://github.com/sponsors/oxc-project" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.85.0", - "@oxlint/binding-android-arm64": "1.85.0", - "@oxlint/binding-darwin-arm64": "1.85.0", - "@oxlint/binding-darwin-x64": "1.85.0", - "@oxlint/binding-freebsd-x64": "1.85.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", - "@oxlint/binding-linux-arm-musleabihf": "1.85.0", - "@oxlint/binding-linux-arm64-gnu": "1.85.0", - "@oxlint/binding-linux-arm64-musl": "1.85.0", - "@oxlint/binding-linux-ppc64-gnu": "1.85.0", - "@oxlint/binding-linux-riscv64-gnu": "1.85.0", - "@oxlint/binding-linux-riscv64-musl": "1.85.0", - "@oxlint/binding-linux-s390x-gnu": "1.85.0", - "@oxlint/binding-linux-x64-gnu": "1.85.0", - "@oxlint/binding-linux-x64-musl": "1.85.0", - "@oxlint/binding-openharmony-arm64": "1.85.0", - "@oxlint/binding-win32-arm64-msvc": "1.85.0", - "@oxlint/binding-win32-ia32-msvc": "1.85.0", - "@oxlint/binding-win32-x64-msvc": "1.85.0" + "@oxlint/binding-android-arm-eabi": "1.86.0", + "@oxlint/binding-android-arm64": "1.86.0", + "@oxlint/binding-darwin-arm64": "1.86.0", + "@oxlint/binding-darwin-x64": "1.86.0", + "@oxlint/binding-freebsd-x64": "1.86.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.86.0", + "@oxlint/binding-linux-arm-musleabihf": "1.86.0", + "@oxlint/binding-linux-arm64-gnu": "1.86.0", + "@oxlint/binding-linux-arm64-musl": "1.86.0", + "@oxlint/binding-linux-ppc64-gnu": "1.86.0", + "@oxlint/binding-linux-riscv64-gnu": "1.86.0", + "@oxlint/binding-linux-riscv64-musl": "1.86.0", + "@oxlint/binding-linux-s390x-gnu": "1.86.0", + "@oxlint/binding-linux-x64-gnu": "1.86.0", + "@oxlint/binding-linux-x64-musl": "1.86.0", + "@oxlint/binding-openharmony-arm64": "1.86.0", + "@oxlint/binding-win32-arm64-msvc": "1.86.0", + "@oxlint/binding-win32-ia32-msvc": "1.86.0", + "@oxlint/binding-win32-x64-msvc": "1.86.0" }, "peerDependencies": { - "oxlint-tsgolint": ">=7.0.2001", + "oxlint-tsgolint": ">=7.0.2003", "vite-plus": "*" }, "peerDependenciesMeta": { diff --git a/backend/package.json b/backend/package.json index f1292be..137e373 100644 --- a/backend/package.json +++ b/backend/package.json @@ -34,7 +34,7 @@ "@worldcoin/idkit-core": "^4.3.0", "cookie-parser": "^1.4.7", "cors": "^2.8.6", - "dotenv": "^18.0.2", + "dotenv": "^18.0.4", "ethers": "^6.17.0", "express": "^5.2.1", "express-rate-limit": "^8.7.0" @@ -43,8 +43,8 @@ "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/node": "^26.6.2", - "oxlint": "^1.85.0", + "@types/node": "^26.6.3", + "oxlint": "^1.86.0", "tsx": "^4.23.15", "typescript": "^7.0.2" } diff --git a/contracts/package-lock.json b/contracts/package-lock.json index cbc62fc..a576305 100644 --- a/contracts/package-lock.json +++ b/contracts/package-lock.json @@ -11,19 +11,19 @@ "dependencies": { "@openzeppelin/contracts": "^5.6.1", "@zk-kit/lean-imt.sol": "^2.0.1", - "dotenv": "^18.0.2", + "dotenv": "^18.0.4", "poseidon-solidity": "^0.0.5" }, "devDependencies": { - "@nomicfoundation/hardhat-toolbox-mocha-ethers": "^3.0.7", + "@nomicfoundation/hardhat-toolbox-mocha-ethers": "^4.0.0", "@semaphore-protocol/group": "^4.14.3", "@semaphore-protocol/identity": "^4.14.3", "@types/mocha": "^10.0.10", - "@types/node": "^26.6.2", + "@types/node": "^26.6.3", "@zk-kit/lean-imt": "^2.2.5", - "hardhat": "^3.17.0", + "hardhat": "^3.18.0", "poseidon-lite": "^0.3.0", - "snarkjs": "^0.7.5", + "snarkjs": "^0.7.6", "typescript": "^7.0.2" }, "engines": { @@ -927,89 +927,6 @@ "dev": true, "license": "GPL-3.0" }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@noble/ciphers": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.2.1.tgz", @@ -1058,7 +975,6 @@ "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -1067,28 +983,28 @@ } }, "node_modules/@nomicfoundation/edr": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr/-/edr-0.20.0.tgz", - "integrity": "sha512-pjqWjQBWtNZRnDcs05YipQCVQehze3Ap5R/QfDHC8RBUU7ufYW1zluJLxuZZQjdO8jfCaRTkSNUvRWVTnq/EKw==", + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr/-/edr-0.22.0.tgz", + "integrity": "sha512-m/RS577AIDg1TMOwGmH8edeiwa6UE1RQnzniUHQJXVHOu40tMexmLNIqaaI0KL0EgoJtYS3/4kz3gzaRGKExyw==", "dev": true, "license": "MIT", "engines": { "node": ">= 22" }, "optionalDependencies": { - "@nomicfoundation/edr-darwin-arm64": "0.20.0", - "@nomicfoundation/edr-darwin-x64": "0.20.0", - "@nomicfoundation/edr-linux-arm64-gnu": "0.20.0", - "@nomicfoundation/edr-linux-arm64-musl": "0.20.0", - "@nomicfoundation/edr-linux-x64-gnu": "0.20.0", - "@nomicfoundation/edr-linux-x64-musl": "0.20.0", - "@nomicfoundation/edr-win32-x64-msvc": "0.20.0" + "@nomicfoundation/edr-darwin-arm64": "0.22.0", + "@nomicfoundation/edr-darwin-x64": "0.22.0", + "@nomicfoundation/edr-linux-arm64-gnu": "0.22.0", + "@nomicfoundation/edr-linux-arm64-musl": "0.22.0", + "@nomicfoundation/edr-linux-x64-gnu": "0.22.0", + "@nomicfoundation/edr-linux-x64-musl": "0.22.0", + "@nomicfoundation/edr-win32-x64-msvc": "0.22.0" } }, "node_modules/@nomicfoundation/edr-darwin-arm64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-darwin-arm64/-/edr-darwin-arm64-0.20.0.tgz", - "integrity": "sha512-RmET+vMxXzKdAnaySms85Ec9U3F/LxwCLMkFT+6NAWqeTMzvuTJYMnaD1e33PZ2C4v9C1UPOT+s+FbEpv4h3ZQ==", + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-darwin-arm64/-/edr-darwin-arm64-0.22.0.tgz", + "integrity": "sha512-1IdPHw4eMCii2giwPuYxyeIwYol5rBI6cVnPIlcXSiWg+jnjFE12RGfw18kzXu+nka2hK9E9IV3M7iZrDfdsbg==", "cpu": [ "arm64" ], @@ -1103,9 +1019,9 @@ } }, "node_modules/@nomicfoundation/edr-darwin-x64": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-darwin-x64/-/edr-darwin-x64-0.20.0.tgz", - "integrity": "sha512-2M5i8EpsXKRKdeVRHVfsjke/m9x4kG7+eK7II4V/Cjof9an76vpMNNvx/VoIkuk6Ttytm1xXUUv0OaVwnYV7rg==", + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-darwin-x64/-/edr-darwin-x64-0.22.0.tgz", + "integrity": "sha512-s2GsGmYQsvBNZ5TOohsS65wliUNL6MVlhDSKn/RF+hLd2GE5w9etwnfIsr9pa619/Zsdu9QxRpT+JjarhLWW8Q==", "cpu": [ "x64" ], @@ -1120,9 +1036,9 @@ } }, "node_modules/@nomicfoundation/edr-linux-arm64-gnu": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-arm64-gnu/-/edr-linux-arm64-gnu-0.20.0.tgz", - "integrity": "sha512-Wz9sBmQEcrM1D1IX68f4ktTsWJiN2FLcc1WFWifbLT06yaF/GEOn1Kg54VZ1IGoV2Fndm1pJEs/4SUQnJ4Wmdw==", + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-arm64-gnu/-/edr-linux-arm64-gnu-0.22.0.tgz", + "integrity": "sha512-xAzNojRkCJMdCpmJtdwqumMT7jExVlR2zBBAmL6Ibco63AZaTtxTtEI+LIMVbjJGgkmIgx1p/1KOPHaAMfNSVw==", "cpu": [ "arm64" ], @@ -1140,9 +1056,9 @@ } }, "node_modules/@nomicfoundation/edr-linux-arm64-musl": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-arm64-musl/-/edr-linux-arm64-musl-0.20.0.tgz", - "integrity": "sha512-J4KK55Kk0r+bXwO1SqxbMg3Nkhg7+7ezKgI/SPTjn2EALF8jGuMYvRt9oWe8lz49sraEex7k9GGqicSTe8w1gA==", + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-arm64-musl/-/edr-linux-arm64-musl-0.22.0.tgz", + "integrity": "sha512-exgirWkwQAkQdnjS5w1lwA6p0fG1Z7fce5OgWSL0YzxfI9yUkBIoC4XoyUCCqYhB1Mr/9KXmsI4JDPG2U/2H6g==", "cpu": [ "arm64" ], @@ -1160,9 +1076,9 @@ } }, "node_modules/@nomicfoundation/edr-linux-x64-gnu": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-x64-gnu/-/edr-linux-x64-gnu-0.20.0.tgz", - "integrity": "sha512-hlzg26kOHCH7Wg+MIie/maoF6qPr2imrnuQ+N4Spy5miRLgza8nip12GdMunwCvf2Zp1wn8bf2dcp7RGUX0jkw==", + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-x64-gnu/-/edr-linux-x64-gnu-0.22.0.tgz", + "integrity": "sha512-oYbYDXYIof+/ZKegmwxugGYhoGfnVFLrVqxmW0po9KETuUnNBg7hKn9SzlOLRAIQGoKUUUfiBjvZdp1gzHKMWQ==", "cpu": [ "x64" ], @@ -1180,9 +1096,9 @@ } }, "node_modules/@nomicfoundation/edr-linux-x64-musl": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-x64-musl/-/edr-linux-x64-musl-0.20.0.tgz", - "integrity": "sha512-n1wS0SoKuqvuUbL7HakmSWZx8aSBRuPW3wtC4ly6I0FCCB4xXNEf7bj1KLGNbayWL7ZG3BJIARHUMO8hy7efzQ==", + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-linux-x64-musl/-/edr-linux-x64-musl-0.22.0.tgz", + "integrity": "sha512-DbF/kG49FNfRO4x7qSYFZlIHg3txqq5j06VTRqRG3r+j/C4HXgHG3InlWr5bElKXt+7LGkX57FQAORmSU8/v+A==", "cpu": [ "x64" ], @@ -1200,9 +1116,9 @@ } }, "node_modules/@nomicfoundation/edr-win32-x64-msvc": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-win32-x64-msvc/-/edr-win32-x64-msvc-0.20.0.tgz", - "integrity": "sha512-+E2srJzdeZ2Ro0siU5NqbptzJVSj5Pk79uGj39o5MgJMn5mcPONs3E6w/1SBtB/fmFlD8PPxnL9hN+jlcv1Naw==", + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/edr-win32-x64-msvc/-/edr-win32-x64-msvc-0.22.0.tgz", + "integrity": "sha512-fKqT26zGwnISIYvJcY/yINfyaKMZKLxg2eNpLij752Hl75552ysOo8+SkADd/q5J9DXdFryNJ+CFOXCDKWJ/Ig==", "cpu": [ "x64" ], @@ -1217,13 +1133,13 @@ } }, "node_modules/@nomicfoundation/hardhat-errors": { - "version": "3.0.21", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-errors/-/hardhat-errors-3.0.21.tgz", - "integrity": "sha512-jXgqgxmI1D7vYcWRQnb6HsRDssxE87a4xJ48FQAdkZUPclB0aVISx6IoVG1Kttx2jJ7tNuo9GasJxpOv7stqyg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-errors/-/hardhat-errors-3.1.0.tgz", + "integrity": "sha512-5hv8MlD4+btIazB6wM3kWG7YdUYQwG36K3a4DGNn1UUNwiwb6oRKMiQuKKH8jek4Wu+EypjwKiCqeYTBOzIBnw==", "dev": true, "license": "MIT", "dependencies": { - "@nomicfoundation/hardhat-utils": "^4.1.5" + "@nomicfoundation/hardhat-utils": "^4.3.0" } }, "node_modules/@nomicfoundation/hardhat-ethers": { @@ -1324,22 +1240,22 @@ } }, "node_modules/@nomicfoundation/hardhat-mocha": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-mocha/-/hardhat-mocha-3.1.0.tgz", - "integrity": "sha512-bZgm1NWoVvwPdc2SQwjyj/HuFePG5LjS+zuLqqwwW8w5hq5+NIFZtgcOoTFNhShDFXeItLxOYiC2K2Pk/q2scw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-mocha/-/hardhat-mocha-4.0.0.tgz", + "integrity": "sha512-6DzaqKHcMjrtdNTljrW+annBn8UYtGNAoYwE4JvJJwyaL6mIc6go6uXWa5LqVs+eNyYx6ZK3dgJs5lAkSrFaTg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@nomicfoundation/hardhat-errors": "^3.0.15", - "@nomicfoundation/hardhat-utils": "^4.1.2", + "@nomicfoundation/hardhat-errors": "^3.1.0", + "@nomicfoundation/hardhat-utils": "^4.3.0", "@nomicfoundation/hardhat-zod-utils": "^3.0.5", "tsx": "^4.23.1", "zod": "^3.23.8" }, "peerDependencies": { "hardhat": "^3.12.0", - "mocha": "^11.0.0" + "mocha": "^12.0.0" } }, "node_modules/@nomicfoundation/hardhat-network-helpers": { @@ -1358,9 +1274,9 @@ } }, "node_modules/@nomicfoundation/hardhat-toolbox-mocha-ethers": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-toolbox-mocha-ethers/-/hardhat-toolbox-mocha-ethers-3.0.7.tgz", - "integrity": "sha512-7uF3QgU+o21AZjtZdp7YIS1daHj7fD0iSFkZwZE94Nxv54j5crFCNc7YPu1W+glBKmYjFKoRUqhyC30L9rNFrQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-toolbox-mocha-ethers/-/hardhat-toolbox-mocha-ethers-4.0.0.tgz", + "integrity": "sha512-MsAmVM/cQdKIUAePWvYNlc2h8R+fW1IhaUHHNVO4cWIj6bEQ8YR9/86c4LDGeW55A8ChZ90juC4S9TPOwBb48w==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1369,7 +1285,7 @@ "@nomicfoundation/hardhat-ignition": "^3.0.0", "@nomicfoundation/hardhat-ignition-ethers": "^3.0.0", "@nomicfoundation/hardhat-keystore": "^3.0.0", - "@nomicfoundation/hardhat-mocha": "^3.0.0", + "@nomicfoundation/hardhat-mocha": "^4.0.0", "@nomicfoundation/hardhat-network-helpers": "^3.0.0", "@nomicfoundation/hardhat-typechain": "^3.0.0", "@nomicfoundation/hardhat-verify": "^3.0.0", @@ -1377,7 +1293,7 @@ "chai": ">=5.1.2 <7", "ethers": "^6.14.0", "hardhat": "^3.8.0", - "mocha": "^11.0.0" + "mocha": "^12.0.0" } }, "node_modules/@nomicfoundation/hardhat-typechain": { @@ -1402,9 +1318,9 @@ } }, "node_modules/@nomicfoundation/hardhat-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-utils/-/hardhat-utils-4.2.0.tgz", - "integrity": "sha512-1WTx0WT6l1FcQ16vE2eFoiqP69HBGJtNWDfDdU19tsAlPbb4dGSdWEHLfSavlx6ssby0daP046mn+1nVBa21ZA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-utils/-/hardhat-utils-4.3.0.tgz", + "integrity": "sha512-QbthuXWPcEglEsSFFXnDBVFS0A+81YLboLLdNajDdzeGWimOU0mqaky/qpqUAWOzTn4bdNp5Z0ub3EEPR8oLzw==", "dev": true, "license": "MIT", "dependencies": { @@ -1684,18 +1600,6 @@ "integrity": "sha512-Ly6SlsVJ3mj+b18W3R8gNufB7dTICT105fJhodGAGgyC2oqnBAhqSiNDJ8V8DLY05cCz81GLI0CU5vNYA1EC/w==", "license": "MIT" }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@scure/base": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", @@ -1874,9 +1778,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", - "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", "dev": true, "license": "MIT", "dependencies": { @@ -2506,21 +2410,6 @@ "dev": true, "license": "MIT" }, - "node_modules/b4a": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", - "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2566,17 +2455,6 @@ "node": ">= 8.0.0" } }, - "node_modules/blake2b-wasm": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/blake2b-wasm/-/blake2b-wasm-2.4.0.tgz", - "integrity": "sha512-S1kwmW2ZhZFFFOghcx73+ZajEfKBqhP82JMssxtLVMxlaPea1p9uoLiUZ5WYyHn0KddwbLc+0vh4wR0KBNoT5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "b4a": "^1.0.1", - "nanoassert": "^2.0.0" - } - }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -2643,20 +2521,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cbor2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/cbor2/-/cbor2-2.3.0.tgz", @@ -2787,65 +2651,6 @@ "web-worker": "1.2.0" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3017,22 +2822,6 @@ "license": "MIT", "peer": true }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3052,20 +2841,6 @@ } } }, - "node_modules/decamelize": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3089,9 +2864,9 @@ } }, "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", "dev": true, "license": "BSD-3-Clause", "peer": true, @@ -3100,9 +2875,9 @@ } }, "node_modules/dotenv": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.2.tgz", - "integrity": "sha512-wl0U3CxC0iobaryqEKpFJxzyp4AxQFYAKj/WWszXZr87vVHLi2yJv2syax5Vxt+u02xqNgE277rsc3lOKg9sPg==", + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.4.tgz", + "integrity": "sha512-jGt9uI5BIxpVO15kvy+iEFqNAwxYClKWnkptLjSNPnFLC003Imd0ofr3aEVsbpHdSBdSsbcApNIlz4nbyGMwdA==", "license": "BSD-2-Clause", "bin": { "dotenv": "dist/index.cjs" @@ -3114,14 +2889,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -3163,14 +2930,6 @@ "license": "MIT", "peer": true }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -3243,31 +3002,6 @@ "@esbuild/win32-x64": "0.28.2" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/escodegen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", @@ -3496,53 +3230,20 @@ "node": ">=4.0.0" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", "dev": true, "license": "MIT", "peer": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "bin": { - "flat": "cli.js" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -3582,35 +3283,20 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "peer": true, - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "peer": true, "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3625,15 +3311,15 @@ "peer": true }, "node_modules/hardhat": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/hardhat/-/hardhat-3.17.0.tgz", - "integrity": "sha512-BJB0rQcur9gZfZRVfvfw69QS4IiOahzegC2V0GUdwKZA2ashqziv5aji/eOlYTqlmLqLD+Q6zj8lT+0wwGadCA==", + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/hardhat/-/hardhat-3.18.0.tgz", + "integrity": "sha512-+M7TxlsB09QBQvxJQJBJQQgyGb2nnZ6iBGdAZVrcOxXdQAkqvG+eHhHiA44xygeucUPdr98uKKMzIv76EzjhJQ==", "dev": true, "license": "MIT", "dependencies": { - "@nomicfoundation/edr": "0.20.0", - "@nomicfoundation/hardhat-errors": "^3.0.17", - "@nomicfoundation/hardhat-utils": "^4.2.0", + "@nomicfoundation/edr": "0.22.0", + "@nomicfoundation/hardhat-errors": "^3.1.0", + "@nomicfoundation/hardhat-utils": "^4.3.0", "@nomicfoundation/hardhat-vendored": "^3.0.4", "@nomicfoundation/hardhat-zod-utils": "^3.0.5", "@nomicfoundation/solidity-analyzer": "^0.1.1", @@ -3798,17 +3484,6 @@ "minimalistic-assert": "^1.0.1" } }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "he": "bin/he" - } - }, "node_modules/hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -3886,37 +3561,18 @@ "license": "ISC", "peer": true }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", "dev": true, "license": "MIT", "peer": true, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-safe-filename": { @@ -3946,31 +3602,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC", - "peer": true - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "peer": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -3994,12 +3625,13 @@ "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz", + "integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==", "dev": true, "funding": [ { @@ -4017,7 +3649,7 @@ "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/json-stream-stringify": { @@ -4086,23 +3718,6 @@ "node": ">=6" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -4127,24 +3742,6 @@ "license": "MIT", "peer": true }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/logplease": { "version": "1.2.15", "resolved": "https://registry.npmjs.org/logplease/-/logplease-1.2.15.tgz", @@ -4153,12 +3750,15 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "dev": true, - "license": "ISC", - "peer": true + "license": "BlueOak-1.0.0", + "peer": true, + "engines": { + "node": "20 || >=22" + } }, "node_modules/micro-eth-signer": { "version": "0.19.0", @@ -4261,22 +3861,47 @@ "peer": true }, "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "peer": true, "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.8" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimatch/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -4314,73 +3939,35 @@ } }, "node_modules/mocha": { - "version": "11.8.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.8.0.tgz", - "integrity": "sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==", + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-12.0.2.tgz", + "integrity": "sha512-SjAulGHxlMJLCD1bhvJBAiYwYxglydRzA9PDQ5MALq1oLRMMtIhxSQJ0olCFgA7c9YbTdxC0KOV0wl8JEDYVhA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "browser-stdout": "^1.3.1", - "chokidar": "^4.0.1", + "chokidar": "^5.0.0", "debug": "^4.3.5", - "diff": "^7.0.0", - "escape-string-regexp": "^4.0.0", - "find-up": "^5.0.0", - "glob": "^10.4.5", - "he": "^1.2.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "log-symbols": "^4.1.0", - "minimatch": "^9.0.5", + "diff": "^9.0.0", + "find-up-simple": "^1.0.1", + "glob": "^13.0.0", + "is-path-inside": "^4.0.0", + "is-unicode-supported": "^0.1.0", + "js-yaml": "^5.0.0", + "minimatch": "^10.2.2", "ms": "^2.1.3", "picocolors": "^1.1.1", - "serialize-javascript": "^6.0.2", - "strip-json-comments": "^3.1.1", + "serialize-javascript": "^7.1.1", + "strip-json-comments": "^5.0.3", "supports-color": "^8.1.1", - "workerpool": "^9.2.0", - "yargs": "^17.7.2", - "yargs-parser": "^21.1.1", - "yargs-unparser": "^2.0.0" + "workerpool": "^10.0.0" }, "bin": { - "_mocha": "bin/_mocha", "mocha": "bin/mocha.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/mocha/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/mocha/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/ms": { @@ -4391,13 +3978,6 @@ "license": "MIT", "peer": true }, - "node_modules/nanoassert": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/nanoassert/-/nanoassert-2.0.0.tgz", - "integrity": "sha512-7vO7n28+aYO4J+8w96AzhmU8G+Y/xpPDJz/se19ICsqj/momRbb9mh9ZUtkoJ5X3nTnPdhEJyc0qnM6yAsHBaA==", - "dev": true, - "license": "ISC" - }, "node_modules/ndjson": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ndjson/-/ndjson-2.0.0.tgz", @@ -4430,40 +4010,6 @@ "wrappy": "1" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-map": { "version": "7.0.7", "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.7.tgz", @@ -4477,25 +4023,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0", - "peer": true - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -4507,30 +4034,19 @@ "node": ">=0.10.0" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", "peer": true, "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4588,17 +4104,6 @@ "node": ">= 6" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -4640,17 +4145,6 @@ "node": ">=6" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve.exports": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", @@ -4704,53 +4198,14 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz", + "integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==", "dev": true, "license": "BSD-3-Clause", "peer": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "peer": true, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=20.0.0" } }, "node_modules/sisteransi": { @@ -4762,20 +4217,19 @@ "peer": true }, "node_modules/snarkjs": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/snarkjs/-/snarkjs-0.7.5.tgz", - "integrity": "sha512-h+3c4rXZKLhLuHk4LHydZCk/h5GcNvk5GjVKRRkHmfb6Ntf8gHOA9zea3g656iclRuhqQ3iKDWFgiD9ypLrKiA==", + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/snarkjs/-/snarkjs-0.7.6.tgz", + "integrity": "sha512-4uH1xA5JzVU5jaaWS2fXej3+RC6L5Erhr6INTJtUA27du4Elbh4VXCeeRjB4QiwL6N6y7SNKePw5prTxyEf4Zg==", "dev": true, "license": "GPL-3.0", "dependencies": { "@iden3/binfileutils": "0.0.12", + "@noble/hashes": "^1.7.1", "bfj": "^7.0.2", - "blake2b-wasm": "^2.4.0", "circom_runtime": "0.1.28", "ejs": "^3.1.6", "fastfile": "0.0.20", "ffjavascript": "0.3.1", - "js-sha3": "^0.8.0", "logplease": "^1.2.15", "r1csfile": "0.0.48" }, @@ -4882,81 +4336,6 @@ "license": "WTFPL OR MIT", "peer": true }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -4970,30 +4349,15 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", "dev": true, "license": "MIT", "peer": true, "engines": { - "node": ">=8" + "node": ">=14.16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -5315,23 +4679,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/wordwrapjs": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-4.0.1.tgz", @@ -5359,57 +4706,13 @@ } }, "node_modules/workerpool": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", - "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-10.0.3.tgz", + "integrity": "sha512-6z2Iis68Wqth93/G/wJP9u+R3O+d2XTlgWChGCwuT1qLbBsOYueGRZuJ++v3mtDP5KjYdy+WzvWC+VWETSVXJA==", "dev": true, "license": "Apache-2.0", "peer": true }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -5441,103 +4744,6 @@ } } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "17.7.3", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", - "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-unparser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/contracts/package.json b/contracts/package.json index 2a3e1c5..1ae1bb4 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -30,21 +30,21 @@ "license": "AGPL-3.0-only", "type": "module", "devDependencies": { - "@nomicfoundation/hardhat-toolbox-mocha-ethers": "^3.0.7", + "@nomicfoundation/hardhat-toolbox-mocha-ethers": "^4.0.0", "@semaphore-protocol/group": "^4.14.3", "@semaphore-protocol/identity": "^4.14.3", "@types/mocha": "^10.0.10", - "@types/node": "^26.6.2", + "@types/node": "^26.6.3", "@zk-kit/lean-imt": "^2.2.5", - "hardhat": "^3.17.0", + "hardhat": "^3.18.0", "poseidon-lite": "^0.3.0", - "snarkjs": "^0.7.5", + "snarkjs": "^0.7.6", "typescript": "^7.0.2" }, "dependencies": { "@openzeppelin/contracts": "^5.6.1", "@zk-kit/lean-imt.sol": "^2.0.1", - "dotenv": "^18.0.2", + "dotenv": "^18.0.4", "poseidon-solidity": "^0.0.5" } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b029bfd..8250e9d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,16 +16,16 @@ "@semaphore-protocol/group": "^4.14.3", "@semaphore-protocol/identity": "^4.14.3", "@walletconnect/ethereum-provider": "^2.25.0", - "@worldcoin/idkit": "^4.2.3", + "@worldcoin/idkit": "^4.3.0", "@worldcoin/idkit-core": "^4.3.0", "@zk-kit/lean-imt": "^2.2.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "country-flag-emoji-polyfill": "^0.1.10", "ethers": "^6.17.0", - "framer-motion": "^13.4.0", + "framer-motion": "^13.4.4", "i18next": "^26.4.2", - "lucide-react": "^1.47.0", + "lucide-react": "^1.48.0", "poseidon-lite": "^0.3.0", "qrcode.react": "^4.2.0", "react": "^19.3.0", @@ -33,7 +33,7 @@ "react-i18next": "^17.0.15", "react-router-dom": "^7.18.4", "recharts": "^3.10.1", - "snarkjs": "^0.7.5", + "snarkjs": "^0.7.6", "tailwind-merge": "^3.7.0" }, "devDependencies": { @@ -42,7 +42,7 @@ "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", - "@types/node": "^26.6.2", + "@types/node": "^26.6.3", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", @@ -50,13 +50,13 @@ "autoprefixer": "^10.6.1", "globals": "^17.12.0", "jsdom": "^30.1.1", - "oxlint": "^1.85.0", + "oxlint": "^1.86.0", "postcss": "^8.5.28", "tailwindcss": "^4.3.3", "typescript": "^7.0.2", - "vite": "^8.3.0", + "vite": "^8.3.1", "vite-plugin-node-polyfills": "^0.28.0", - "vitest": "^5.0.1" + "vitest": "^5.0.2" }, "engines": { "node": ">=24", @@ -1593,9 +1593,9 @@ "license": "Apache-2.0" }, "node_modules/@oxc-project/types": { - "version": "0.149.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", - "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", "dev": true, "license": "MIT", "funding": { @@ -1603,9 +1603,9 @@ } }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.85.0.tgz", - "integrity": "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.86.0.tgz", + "integrity": "sha512-63Ozq6yQn79B2UxjSZ9huJmI+3/ZPI7hxAe6TyFh9oQL447KnIMoffOdtog+h4KILbQr61ZRvFPW5gYbAUgpgQ==", "cpu": [ "arm" ], @@ -1620,9 +1620,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.85.0.tgz", - "integrity": "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.86.0.tgz", + "integrity": "sha512-JdLGp44phbf/0Du236Dt06yfK6Qw8xhTFeDvHJdjk167r59OfP5+BL31mgMYVnswS8e+j6tFuccSY0QDC/lgCw==", "cpu": [ "arm64" ], @@ -1637,9 +1637,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.85.0.tgz", - "integrity": "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.86.0.tgz", + "integrity": "sha512-h+vkOr4ik6KLFCXdtNVEj9xfnXfNUpPoF95QjORdhiSpLqQePzcN81kGHjAgsw1/G7WBWK5KsFKbI2UK8EyzMA==", "cpu": [ "arm64" ], @@ -1654,9 +1654,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.85.0.tgz", - "integrity": "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.86.0.tgz", + "integrity": "sha512-wbglRuH5eKsp68keTOOeKDHU1astWSRYFsh2bwKQTAPb44rD6kVgzYI1ZrAJRTc6/7PH55ZXFCQx0rQXrj0UdA==", "cpu": [ "x64" ], @@ -1671,9 +1671,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.85.0.tgz", - "integrity": "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.86.0.tgz", + "integrity": "sha512-yTQ7QSOlNfQsrfbeVweTiegQJLKPjUKgXp/JpGiFzza6Hp29S9LAoH77TM3L0RjK39iQRZB5lk5iq2t241Mfag==", "cpu": [ "x64" ], @@ -1688,9 +1688,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.85.0.tgz", - "integrity": "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.86.0.tgz", + "integrity": "sha512-QYJGy0E2Tv8GAayyBIeHFTwQb8u+zrvrOYrifuDGZDr9ad5XsDhiMScpZymhS9fwWjqrgG2ZUwT4vpY0ISqp4Q==", "cpu": [ "arm" ], @@ -1705,9 +1705,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.85.0.tgz", - "integrity": "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.86.0.tgz", + "integrity": "sha512-oQo83CafLT8p3qh0AGkN0tSUIh5Hm63mfDsoqUmBQUBnSZLNBAbyRaAftfGY5a0cKZ34dG6jAtMYgcY5kZqt5g==", "cpu": [ "arm" ], @@ -1722,9 +1722,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.85.0.tgz", - "integrity": "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.86.0.tgz", + "integrity": "sha512-EM6wy5c2UM12qPb7iDJDBAZvHyfEJGH6iosFgaFiaRZpirz99//EHcUyD2KAskz07x1TULuiAtsojf4nvWWksw==", "cpu": [ "arm64" ], @@ -1742,9 +1742,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.85.0.tgz", - "integrity": "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.86.0.tgz", + "integrity": "sha512-vCiUQb9ZNzZalxYRU4mlZwUqT5f+c7Sk9DwUOplWHtik3Dl4r3DVnUB5tBeRuTAUe6MUVCTylNW7TkkN8J1oTw==", "cpu": [ "arm64" ], @@ -1762,9 +1762,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.85.0.tgz", - "integrity": "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.86.0.tgz", + "integrity": "sha512-T0AzE89yjTYmOhB/hb6i+rWTpe2mYt+xiguBhs9DEPIJTZ1H8OYqsE4VxJHHw2Wlq8Mztwvp2P8NV87ZOuhPRw==", "cpu": [ "ppc64" ], @@ -1782,9 +1782,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.85.0.tgz", - "integrity": "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.86.0.tgz", + "integrity": "sha512-s49r0K2f5Oc7fJoMUO1WkdhYK5Ob79uYRwWQ/XNoGysaOq3RUGmGUbHSuXiyydTTA+P9xEdDEnnlZVXiaSJhlw==", "cpu": [ "riscv64" ], @@ -1802,9 +1802,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.85.0.tgz", - "integrity": "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.86.0.tgz", + "integrity": "sha512-JIa2tYR8ayb3nvcMSZzn4O/xTy13S7MHllu7cBD+B5qDQXXSRy/Vwk1aNpiTjZmBJGwKCHOy/NvnY5Xcwg27uQ==", "cpu": [ "riscv64" ], @@ -1822,9 +1822,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.85.0.tgz", - "integrity": "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.86.0.tgz", + "integrity": "sha512-XwXMyBsAuWZkhZU74UNcfQKr1CJJAKnS80dZzOnjl6A4fKcf91IjFgidegQugMHA5SN8ulwDXEDV1yPntjXgQQ==", "cpu": [ "s390x" ], @@ -1842,9 +1842,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.85.0.tgz", - "integrity": "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.86.0.tgz", + "integrity": "sha512-C1WjukSyMnr66b+w1/tV8RFVv6d9v0MzDf4p9IxVXknqgmTHBgZh1pccN1eHzFr0b9Tbb3OXoPsAAdAuHAQfeA==", "cpu": [ "x64" ], @@ -1862,9 +1862,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.85.0.tgz", - "integrity": "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.86.0.tgz", + "integrity": "sha512-ap6KLmvC38c6MdYzsIh25cXQupqYvjd37tMNftzrX1DCtkX1Gcf2B+S2B17dD4lKa5c5gJog7DQJyDo82PBKyw==", "cpu": [ "x64" ], @@ -1882,9 +1882,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.85.0.tgz", - "integrity": "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.86.0.tgz", + "integrity": "sha512-996Q9w/2GLwNyy5Djs39Jt09aFlr4b7TPhoj7dvo5FHmcyiF68nPz73HNc5UAYKIYeK+M9JzPQ4Wg1r1dXvP6A==", "cpu": [ "arm64" ], @@ -1899,9 +1899,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.85.0.tgz", - "integrity": "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.86.0.tgz", + "integrity": "sha512-9uTCHYTknNkug5+46ViSoUMuOuGWK61D87QnblU6CXaWKJC630Z7dbls61vdHxya/cR2ycQZ6Mm6ckgF6Cag5g==", "cpu": [ "arm64" ], @@ -1916,9 +1916,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.85.0.tgz", - "integrity": "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.86.0.tgz", + "integrity": "sha512-a47ga7EKVfMU8rO0i1m3mGwfNRj3FhQyt1d/ukO/E9u1HHF3KpR3tyb3VhuJBGEVPF8uR4exSyHUyx7xbE+uxw==", "cpu": [ "ia32" ], @@ -1933,9 +1933,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.85.0.tgz", - "integrity": "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.86.0.tgz", + "integrity": "sha512-az4dzifCRzES1crwu6pDFXzJTIanrdxXFL9TbiJWrx9LKcuacbR83aPvxDA1LKLNswi/RoqitC7Afcflhu3zBQ==", "cpu": [ "x64" ], @@ -3315,9 +3315,9 @@ } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", - "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", "cpu": [ "arm" ], @@ -3332,9 +3332,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", - "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", "cpu": [ "arm64" ], @@ -3349,9 +3349,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", - "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", "cpu": [ "arm64" ], @@ -3366,9 +3366,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", - "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", "cpu": [ "x64" ], @@ -3383,9 +3383,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", - "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", "cpu": [ "x64" ], @@ -3400,9 +3400,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", - "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", "cpu": [ "arm" ], @@ -3417,9 +3417,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", - "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", "cpu": [ "arm64" ], @@ -3437,9 +3437,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", - "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", "cpu": [ "arm64" ], @@ -3457,9 +3457,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", - "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", "cpu": [ "ppc64" ], @@ -3477,9 +3477,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", - "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", "cpu": [ "s390x" ], @@ -3497,9 +3497,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", - "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", "cpu": [ "x64" ], @@ -3517,9 +3517,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", - "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", "cpu": [ "x64" ], @@ -3537,9 +3537,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", - "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", "cpu": [ "arm64" ], @@ -3554,9 +3554,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", - "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", "cpu": [ "arm64" ], @@ -3571,9 +3571,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", - "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", "cpu": [ "x64" ], @@ -4385,9 +4385,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", - "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", "dev": true, "license": "MIT", "dependencies": { @@ -4777,14 +4777,14 @@ } }, "node_modules/@vitest/mocker": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", - "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz", + "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.1", + "@vitest/spy": "5.0.2", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, @@ -4815,9 +4815,9 @@ } }, "node_modules/@vitest/mocker/node_modules/magic-string": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", - "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", "dev": true, "license": "MIT", "dependencies": { @@ -4825,9 +4825,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", - "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", + "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", "dev": true, "license": "MIT", "funding": { @@ -5253,12 +5253,12 @@ } }, "node_modules/@worldcoin/idkit": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@worldcoin/idkit/-/idkit-4.2.3.tgz", - "integrity": "sha512-WklAH46OefIb6c873urjGpvxDKAs+FMGaHtKLmWExLAt/QMD/q5P3Uzv3A42pQrW8/1PRpLY4w+sei19gSlylA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@worldcoin/idkit/-/idkit-4.3.0.tgz", + "integrity": "sha512-ityJl5cmNMJ7tlaEMHlFrkUsKLhKVjq1Eiwhyc+npJUhiJBKUk2nkHKzv4oyajj+lpnz+ls0ax9cUDlLgWu6ow==", "license": "MIT", "dependencies": { - "@worldcoin/idkit-core": "4.2.4", + "@worldcoin/idkit-core": "4.3.0", "qrcode": "^1.5.4" }, "peerDependencies": { @@ -5313,28 +5313,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@worldcoin/idkit/node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@worldcoin/idkit/node_modules/@worldcoin/idkit-core": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@worldcoin/idkit-core/-/idkit-core-4.2.4.tgz", - "integrity": "sha512-R7nupFbXtk1h0ErnDDlarYlGqnFBLzy96xcMWOHqzkt7Wzs788cyxHZqgJHRsSnWx6LMToxp5kO3zr5N4rBatA==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "^1.7.2", - "@worldcoin/idkit-server": "1.1.1" - } - }, "node_modules/@worldcoin/idkit/node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -5771,20 +5749,6 @@ "axios": "0.x || 1.x" } }, - "node_modules/b4a": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", - "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, "node_modules/base-x": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", @@ -5863,16 +5827,6 @@ "url": "https://opencollective.com/bigjs" } }, - "node_modules/blake2b-wasm": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/blake2b-wasm/-/blake2b-wasm-2.4.0.tgz", - "integrity": "sha512-S1kwmW2ZhZFFFOghcx73+ZajEfKBqhP82JMssxtLVMxlaPea1p9uoLiUZ5WYyHn0KddwbLc+0vh4wR0KBNoT5w==", - "license": "MIT", - "dependencies": { - "b4a": "^1.0.1", - "nanoassert": "^2.0.0" - } - }, "node_modules/blakejs": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/blakejs/-/blakejs-1.2.1.tgz", @@ -7366,12 +7320,12 @@ } }, "node_modules/framer-motion": { - "version": "13.4.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.4.0.tgz", - "integrity": "sha512-HCpqKM9BTu6UYKtj0u6J1QNxojnnirNcghcSDZ+SkpiL3myxvtsgXPS3ZGEELC2eSma7YiA937rAEXtRyydSXQ==", + "version": "13.4.4", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.4.4.tgz", + "integrity": "sha512-lbsZO95NGbulo6apz70zEt6Vxf/anoRwQI2ECEc/EQYWO5CGbITtl7plDW8P/VXgXw6e8wrbhH4a9i+ygke0Mg==", "license": "MIT", "dependencies": { - "motion-dom": "^13.3.0", + "motion-dom": "^13.4.4", "motion-utils": "^13.3.0", "tslib": "^2.4.0" }, @@ -8026,12 +7980,6 @@ "integrity": "sha512-uwYQp+VJ38FVvtim6qNbit6e9uT6dwWQ4Y1+H9TxhW5hcHjpHwoxlR0nMpqUmIFOmu4VqMxwdJA88gIVuZJQ/g==", "license": "BSD-3-Clause" }, - "node_modules/js-sha3": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", - "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", - "license": "MIT" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8465,9 +8413,9 @@ } }, "node_modules/lucide-react": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz", - "integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==", + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.48.0.tgz", + "integrity": "sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -8603,9 +8551,9 @@ "license": "MIT" }, "node_modules/motion-dom": { - "version": "13.3.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.3.0.tgz", - "integrity": "sha512-AmAnB6pHdZ1vHGzWzuzteG6Q3j1lHKeX/lbST6jGlgm0cjvLUgaCk1xhOuPMOy5SgovN6wnnUdwFrkcsus7Ftg==", + "version": "13.4.4", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.4.4.tgz", + "integrity": "sha512-z2qN3RUABSci4G7cr5aHTFhqPNCWJsEMMfRlzxqtANQsSCIbVJmHvMV288m5x7doEQBTYbRTWbKdztmquCn4Sw==", "license": "MIT", "dependencies": { "motion-utils": "^13.3.0" @@ -8630,12 +8578,6 @@ "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", "license": "(Apache-2.0 AND MIT)" }, - "node_modules/nanoassert": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/nanoassert/-/nanoassert-2.0.0.tgz", - "integrity": "sha512-7vO7n28+aYO4J+8w96AzhmU8G+Y/xpPDJz/se19ICsqj/momRbb9mh9ZUtkoJ5X3nTnPdhEJyc0qnM6yAsHBaA==", - "license": "ISC" - }, "node_modules/nanoid": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", @@ -8945,9 +8887,9 @@ } }, "node_modules/oxlint": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.85.0.tgz", - "integrity": "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.86.0.tgz", + "integrity": "sha512-og0lhgvZfgGF//gOOmZXvtr+GmBbAGEnbEhv/QUg7UW2Wi4wHMnJbnMD+zuHgPdJxdklfgpPupdlAaAySyxrZg==", "dev": true, "license": "MIT", "bin": { @@ -8960,28 +8902,28 @@ "url": "https://github.com/sponsors/oxc-project" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.85.0", - "@oxlint/binding-android-arm64": "1.85.0", - "@oxlint/binding-darwin-arm64": "1.85.0", - "@oxlint/binding-darwin-x64": "1.85.0", - "@oxlint/binding-freebsd-x64": "1.85.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", - "@oxlint/binding-linux-arm-musleabihf": "1.85.0", - "@oxlint/binding-linux-arm64-gnu": "1.85.0", - "@oxlint/binding-linux-arm64-musl": "1.85.0", - "@oxlint/binding-linux-ppc64-gnu": "1.85.0", - "@oxlint/binding-linux-riscv64-gnu": "1.85.0", - "@oxlint/binding-linux-riscv64-musl": "1.85.0", - "@oxlint/binding-linux-s390x-gnu": "1.85.0", - "@oxlint/binding-linux-x64-gnu": "1.85.0", - "@oxlint/binding-linux-x64-musl": "1.85.0", - "@oxlint/binding-openharmony-arm64": "1.85.0", - "@oxlint/binding-win32-arm64-msvc": "1.85.0", - "@oxlint/binding-win32-ia32-msvc": "1.85.0", - "@oxlint/binding-win32-x64-msvc": "1.85.0" - }, - "peerDependencies": { - "oxlint-tsgolint": ">=7.0.2001", + "@oxlint/binding-android-arm-eabi": "1.86.0", + "@oxlint/binding-android-arm64": "1.86.0", + "@oxlint/binding-darwin-arm64": "1.86.0", + "@oxlint/binding-darwin-x64": "1.86.0", + "@oxlint/binding-freebsd-x64": "1.86.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.86.0", + "@oxlint/binding-linux-arm-musleabihf": "1.86.0", + "@oxlint/binding-linux-arm64-gnu": "1.86.0", + "@oxlint/binding-linux-arm64-musl": "1.86.0", + "@oxlint/binding-linux-ppc64-gnu": "1.86.0", + "@oxlint/binding-linux-riscv64-gnu": "1.86.0", + "@oxlint/binding-linux-riscv64-musl": "1.86.0", + "@oxlint/binding-linux-s390x-gnu": "1.86.0", + "@oxlint/binding-linux-x64-gnu": "1.86.0", + "@oxlint/binding-linux-x64-musl": "1.86.0", + "@oxlint/binding-openharmony-arm64": "1.86.0", + "@oxlint/binding-win32-arm64-msvc": "1.86.0", + "@oxlint/binding-win32-ia32-msvc": "1.86.0", + "@oxlint/binding-win32-x64-msvc": "1.86.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2003", "vite-plus": "*" }, "peerDependenciesMeta": { @@ -9914,13 +9856,13 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", - "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.149.0", + "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -9930,21 +9872,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.8", - "@rolldown/binding-android-arm64": "1.2.8", - "@rolldown/binding-darwin-arm64": "1.2.8", - "@rolldown/binding-darwin-x64": "1.2.8", - "@rolldown/binding-freebsd-x64": "1.2.8", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", - "@rolldown/binding-linux-arm64-gnu": "1.2.8", - "@rolldown/binding-linux-arm64-musl": "1.2.8", - "@rolldown/binding-linux-ppc64-gnu": "1.2.8", - "@rolldown/binding-linux-s390x-gnu": "1.2.8", - "@rolldown/binding-linux-x64-gnu": "1.2.8", - "@rolldown/binding-linux-x64-musl": "1.2.8", - "@rolldown/binding-openharmony-arm64": "1.2.8", - "@rolldown/binding-win32-arm64-msvc": "1.2.8", - "@rolldown/binding-win32-x64-msvc": "1.2.8" + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, "node_modules/safe-buffer": { @@ -10160,13 +10102,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/slow-redact": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/slow-redact/-/slow-redact-0.3.2.tgz", @@ -10174,19 +10109,18 @@ "license": "MIT" }, "node_modules/snarkjs": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/snarkjs/-/snarkjs-0.7.5.tgz", - "integrity": "sha512-h+3c4rXZKLhLuHk4LHydZCk/h5GcNvk5GjVKRRkHmfb6Ntf8gHOA9zea3g656iclRuhqQ3iKDWFgiD9ypLrKiA==", + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/snarkjs/-/snarkjs-0.7.6.tgz", + "integrity": "sha512-4uH1xA5JzVU5jaaWS2fXej3+RC6L5Erhr6INTJtUA27du4Elbh4VXCeeRjB4QiwL6N6y7SNKePw5prTxyEf4Zg==", "license": "GPL-3.0", "dependencies": { "@iden3/binfileutils": "0.0.12", + "@noble/hashes": "^1.7.1", "bfj": "^7.0.2", - "blake2b-wasm": "^2.4.0", "circom_runtime": "0.1.28", "ejs": "^3.1.6", "fastfile": "0.0.20", "ffjavascript": "0.3.1", - "js-sha3": "^0.8.0", "logplease": "^1.2.15", "r1csfile": "0.0.48" }, @@ -10204,6 +10138,18 @@ "ffjavascript": "^0.3.0" } }, + "node_modules/snarkjs/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/snarkjs/node_modules/ffjavascript": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/ffjavascript/-/ffjavascript-0.3.1.tgz", @@ -10276,13 +10222,6 @@ "node": ">= 10.x" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/static-eval": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz", @@ -11064,16 +11003,16 @@ } }, "node_modules/vite": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", - "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", - "rolldown": "~1.2.6", + "rolldown": "~1.2.9", "tinyglobby": "^0.2.17" }, "bin": { @@ -11432,14 +11371,14 @@ } }, "node_modules/vitest": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", - "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz", + "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.1", + "@vitest/mocker": "5.0.2", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", @@ -11447,10 +11386,10 @@ "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", - "tinybench": "6.1.4", - "tinyexec": "1.3.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17", - "why-is-node-running": "^2.3.0" + "why-is-node-running": "^3.2.1" }, "bin": { "vitest": "vitest.mjs" @@ -11465,12 +11404,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.1", - "@vitest/browser-preview": "5.0.1", + "@vitest/browser-playwright": "5.0.2", + "@vitest/browser-preview": "5.0.2", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.1", - "@vitest/coverage-v8": "5.0.1", - "@vitest/ui": "5.0.1", + "@vitest/coverage-istanbul": "5.0.2", + "@vitest/coverage-v8": "5.0.2", + "@vitest/ui": "5.0.2", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -11657,20 +11596,16 @@ } }, "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz", + "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/wrap-ansi": { diff --git a/frontend/package.json b/frontend/package.json index 5622a71..32100a0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,16 +28,16 @@ "@semaphore-protocol/group": "^4.14.3", "@semaphore-protocol/identity": "^4.14.3", "@walletconnect/ethereum-provider": "^2.25.0", - "@worldcoin/idkit": "^4.2.3", + "@worldcoin/idkit": "^4.3.0", "@worldcoin/idkit-core": "^4.3.0", "@zk-kit/lean-imt": "^2.2.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "country-flag-emoji-polyfill": "^0.1.10", "ethers": "^6.17.0", - "framer-motion": "^13.4.0", + "framer-motion": "^13.4.4", "i18next": "^26.4.2", - "lucide-react": "^1.47.0", + "lucide-react": "^1.48.0", "poseidon-lite": "^0.3.0", "qrcode.react": "^4.2.0", "react": "^19.3.0", @@ -45,7 +45,7 @@ "react-i18next": "^17.0.15", "react-router-dom": "^7.18.4", "recharts": "^3.10.1", - "snarkjs": "^0.7.5", + "snarkjs": "^0.7.6", "tailwind-merge": "^3.7.0" }, "devDependencies": { @@ -54,7 +54,7 @@ "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", - "@types/node": "^26.6.2", + "@types/node": "^26.6.3", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", @@ -62,12 +62,12 @@ "autoprefixer": "^10.6.1", "globals": "^17.12.0", "jsdom": "^30.1.1", - "oxlint": "^1.85.0", + "oxlint": "^1.86.0", "postcss": "^8.5.28", "tailwindcss": "^4.3.3", "typescript": "^7.0.2", - "vite": "^8.3.0", + "vite": "^8.3.1", "vite-plugin-node-polyfills": "^0.28.0", - "vitest": "^5.0.1" + "vitest": "^5.0.2" } } diff --git a/frontend/src/components/ui/DatePicker.tsx b/frontend/src/components/ui/DatePicker.tsx index d45a88f..c0cd568 100644 --- a/frontend/src/components/ui/DatePicker.tsx +++ b/frontend/src/components/ui/DatePicker.tsx @@ -108,9 +108,15 @@ export function DatePicker({ const maxDate = max ? parseValue(max) : null; const [viewDate, setViewDate] = React.useState(() => selected ?? minDate ?? new Date()); + // The day to mark as today, read when the popover opens rather than on every + // render: a clock read during render is a different value each time. + const [today, setToday] = React.useState(() => new Date()); // Re-sync the visible month to the current value each time the popover opens. const handleOpenChange = (next: boolean) => { - if (next) setViewDate(selected ?? minDate ?? new Date()); + if (next) { + setViewDate(selected ?? minDate ?? new Date()); + setToday(new Date()); + } setOpen(next); }; @@ -307,7 +313,7 @@ export function DatePicker({ {cells.map(({ date, outside }, i) => { const disabled = isDisabled(date); const isSelected = isSameDay(date, selected); - const isToday = isSameDay(date, new Date()); + const isToday = isSameDay(date, today); return (