diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 6ceaeb2..b168596 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -19,7 +19,8 @@ on: push: branches: [main] pull_request: - branches: [main] + # `dev` too: that is where work is reviewed before it reaches `main`. + branches: [main, dev] workflow_dispatch: permissions: @@ -44,6 +45,11 @@ jobs: cache-dependency-path: frontend/package-lock.json - run: npm ci working-directory: frontend + # oxlint rather than ESLint: typescript-eslint does not support the + # TypeScript 7 compiler this project builds with, and oxlint needs no + # compiler at all. Correctness rules only, including react-hooks. + - run: npm run lint + working-directory: frontend # `npm run build` is `tsc -b && vite build`, so this covers types as well # as the bundle. It is also what 4EVERLAND runs, which is the point: if # this passes and that fails, the difference is configuration, not code. @@ -63,6 +69,13 @@ jobs: # The file that keeps deep links working on IPFS. Cheap to lose in a # refactor of `public/`, and its absence only shows up as a 404 on a # reloaded route in production. + # Every screen, rendered in a real browser at three sizes, against the + # demo data with the API answering as a server with no session. Catches + # what unit tests cannot: a render loop, a route that crashes on load. + - run: npx playwright install --with-deps chromium + working-directory: frontend + - run: npm run test:e2e + working-directory: frontend - name: The SPA rewrite survived the build working-directory: frontend run: | @@ -82,6 +95,8 @@ jobs: cache-dependency-path: backend/package-lock.json - run: npm ci working-directory: backend + - run: npm run lint + working-directory: backend - run: npm run build working-directory: backend - run: npm test @@ -100,8 +115,76 @@ 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 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 + 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') }} + - 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 + 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: 30 + - 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 @@ -110,14 +193,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 @@ -156,8 +246,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. # @@ -175,6 +265,8 @@ jobs: cache-dependency-path: scripts-tally/package-lock.json - run: npm ci working-directory: scripts-tally + # 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/.gitignore b/.gitignore index c310ce9..232fd9f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,14 @@ contracts/coverage/ # Local deploy manifests are throwaway; amoy.json IS committed (frontend reads it) contracts/deployments/local.json +# ------------- Circuits ------------- +# Build outputs of circuits/scripts/build.mjs: the trusted setup, and the +# verifiers and proving files generated from it. Never committed, because they +# belong to ONE ceremony: a deployment generates its own and publishes them. +circuits/build/ +contracts/contracts/verifiers/ +frontend/public/circuits/ + # ------------- React Frontend ------------- frontend/build/ frontend/dist/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 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 27d7b69..f91f112 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,16 @@ > End-to-end verifiable, anonymous, coercion-resistant voting dApp on Polygon Amoy. -Votain is a Bachelor's thesis project (TFG) demonstrating how modern cryptographic primitives (Zero-Knowledge Proofs, Verifiable Credentials, Homomorphic Encryption and meta-transaction relaying) can be combined into a voting system where every voter can verify their ballot is counted, no one can be coerced, and no central authority can tamper with results. +Votain is a Bachelor's thesis project (TFG) demonstrating how modern cryptographic primitives (Zero-Knowledge Proofs, Verifiable Credentials, Homomorphic Encryption and meta-transaction relaying) can be combined into a voting system where every voter can verify their ballot is counted, a coerced ballot can always be overridden, and no organizer can publish a result the ballots do not support. ## Why Votain | Property | How it is achieved | |----------|--------------------| -| **End-to-end verifiable** | Every vote is a Paillier ciphertext stored on-chain, so anyone can re-run the homomorphic sum from the chain itself. The auditor CLI additionally pins a result JSON to IPFS and publishes its CID; 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 silently override a prior ballot. Only the highest-nonce vote counts | -| **Sybil resistant** | World ID v4 proof of personhood, bound to a per-election scope | +| **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** | 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. At most two ballots per voter within an hour (one straight away, then one an hour) bound 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 | | **Unlinkable on chain** | Every voter's call arrives from the same relay contract, so the sender address cannot tie an enrollment to a ballot | @@ -61,36 +61,107 @@ Votain is a Bachelor's thesis project (TFG) demonstrating how modern cryptograph ## Architecture overview +Who talks to whom. Voters and organizers only ever run the frontend; the +issuer vouches for people and relays their calls; the contracts hold the +ballots and decide what counts. + +```mermaid +flowchart TB + subgraph phone["Voter's phone"] + worldapp["World App
proof of personhood"] + selfapp["Self app
passport / ID chip over NFC"] + end + + subgraph browser["Browser: frontend on IPFS (4EVERLAND)"] + voter["Voter screens
identity from a 12-word phrase,
sealed under a passkey"] + organizer["Organizer screens
wallet-signed actions,
tally key from a signature"] + prover["snarkjs
ballot and tally proofs"] + end + + circuitfiles[("Proving files
wasm + zkey, VITE_CIRCUITS_URL")] + + subgraph tee["Issuer backend in a TEE (Phala, Intel TDX)"] + api["Express API
World ID and Self checks,
SD-JWT session, attestations"] + relayer["Relayer and registrar
one signer queue per key"] + end + + subgraph chain["Polygon Amoy"] + factory["ElectionFactory"] + election["ElectionV4
members tree, ballots tree,
ElGamal aggregate"] + paymaster["ElectionPaymaster
organizer gas, relay hub"] + registry["PlatformRegistry
platform members, sealed vault"] + verifiers["Ballot + tally
Groth16 verifiers"] + end + + cli["Tally CLI
tally, or --verify with no key"] + + worldapp -- "proof" --> api + selfapp -- "document proof" --> api + voter -- "session, enrolment, ballots" --> api + voter --> prover + organizer --> prover + prover -. "fetch once" .-> circuitfiles + api --> relayer + relayer -- "registerMember, vault writes" --> registry + relayer -- "relayEnroll / relayVote" --> paymaster + paymaster -- "enroll, castVote" --> election + organizer -- "createElection, reserve gas" --> factory + organizer -- "close early, publishResults" --> election + organizer -- "fund the gas tank" --> paymaster + factory -- "deploys" --> election + election -- "verifyBallot, verifyTally" --> verifiers + election -- "verifiedMembers" --> registry + voter -. "reads events" .-> election + cli -. "reads aggregate and ballots" .-> election + cli -- "publishResults" --> election ``` -User (recovery phrase + World ID) - ├── React 19 + Vite + Tailwind 4 ◄──── IPFS (4EVERLAND) - │ - ├── SD-JWT issuance ── Backend (Node + Express) ◄──── Phala TEE - │ Verifies World ID (personhood) and - │ Self proofs (age, nationality), issues VC - │ - └── relayed tx ─────── Polygon Amoy - ElectionFactory · ElectionV4 - ElectionPaymaster · PlatformRegistry - Semaphore V4 Verifier - │ - ▼ - Tally (Paillier homomorphic sum): in-app in the - browser, or the off-chain CLI (auditor path) - → Result JSON pinned on IPFS (Pinata, CLI) - → publishResults(cid, tally) on-chain + +A vote, end to end. Every ballot is a vote plus the cancellation of the voter's +previous one, so the aggregate the contract keeps always decrypts to each +voter's LAST vote, and nothing public says which ballots are re-votes. + +```mermaid +sequenceDiagram + autonumber + actor V as Voter + participant F as Frontend + participant B as Issuer backend + participant P as ElectionPaymaster + participant E as ElectionV4 + actor O as Organizer + + V->>F: sign in with World ID + F->>B: World ID proof + B-->>F: SD-JWT session cookie + V->>F: enrol + F->>B: commitment derived for THIS election + B->>P: relayEnrollPrivate, signed by the platform + P->>E: enrollPrivate, one per human + V->>F: choose an option + F->>F: encrypt vote + cancel previous ballot,
prove it (Groth16, in the browser) + F->>B: ballot + proof, no session attached + B->>P: relayVote + P->>E: castVote: verify, add to aggregate,
reimburse the relayer + Note over V,E: a re-vote is the same call and looks the same on chain + O->>F: close and tally + F->>F: decrypt the aggregate, prove the decryption + F->>E: publishResults(counts, proof) + E->>E: verify the tally proof, then publish + Note over F,E: anyone re-adds the ballots and re-checks
the proof: results screen or tally --verify ``` -Full diagram in [`docs/dev/architecture.md`](docs/dev/architecture.md). +The full design, with its threat model and known limits, is in +[`docs/dev/architecture.md`](docs/dev/architecture.md). ## Monorepo layout ``` 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 +175,30 @@ votain/ ## Tech stack -**Contracts**: Solidity 0.8.37, Hardhat 3, ethers v6, OpenZeppelin 5, [`@semaphore-protocol/contracts`](https://semaphore.pse.dev/) 4.x, ERC-2771 context. +**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 @@ -126,19 +207,21 @@ 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 ``` > **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. @@ -160,7 +243,36 @@ 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 + +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. +- **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. +- **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. +- **Nothing makes the organizer tally.** There is no publishing deadline, so an + organizer who never decrypts leaves the election without a result. + +Threshold decryption, an auto-tally, a tally deadline and taking the per-user +gas off the platform are designed but left for after the thesis: see [Future work](docs/dev/architecture.md#future-work). ## Documentation @@ -171,7 +283,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/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..9778d64 100644 --- a/backend/DEVELOPMENT.md +++ b/backend/DEVELOPMENT.md @@ -330,19 +330,28 @@ returning voter would be handed a brand new identity on every device. ## Issuer architecture -``` -User → POST /api/worldid/request { returnTo? } → Set-Cookie: voter_pending - └── signs the RP request and opens the bridge request HERE, so it - survives the phone discarding the tab - → World ID QR / deep link (verify in WLD app) - → 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; - 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 } - └── PlatformRegistry.registerMember(nullifier, commitment) [on-chain] +```mermaid +sequenceDiagram + autonumber + participant F as Frontend + participant B as Issuer + participant W as World App + participant R as PlatformRegistry + + F->>B: POST /api/worldid/request {returnTo?} + Note right of B: signs the RP request and opens the bridge
request HERE, so it survives the phone
discarding the tab + B-->>F: connector URI, Set-Cookie voter_pending + F->>W: QR code or deep link + W-->>B: proof, via World's bridge, which the issuer polls + F->>B: GET /api/worldid/request?wait=1 (held open, a reload may ask again) + B-->>F: the proof + F->>B: POST /api/verify-human {proof} + B->>B: verify under the server-fixed action + B-->>F: SD-JWT VC, sub = nullifier, no identity attributes,
Status List 2021 entry, httpOnly cookie, 7 days + F->>B: GET /api/identity/vault + B-->>F: the sealed identity, or none + F->>B: POST /api/identity/vault {credentialId, blob, commitment} + B->>R: registerMember(nullifier, commitment) ``` Source layout: `sd/issuer.ts` (shared SD-JWT instance + disclosure frame), @@ -357,7 +366,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 +395,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/backend/README.md b/backend/README.md index 4731a63..fde186a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -19,7 +19,58 @@ read from its own chip by the Self app on the voter's phone and never leaves the ```bash npm install cp .env.example .env # fill in ISSUER_PRIVATE_KEY, WORLD_ID_APP_ID, ... +npm test # 160 tests npm run dev # http://localhost:3000 ``` +## How it is put together + +Every route is under `/api`. What reaches the chain goes through one queue per +signing key (`chain/signer.ts`), so concurrent requests never race for a nonce. + +```mermaid +flowchart LR + browser["Frontend"] + worldid["World ID
proofs, bridge"] + self["Self
document proofs"] + + subgraph routes["src/routes"] + verify["verify.ts
/worldid/request, /verify-human,
/me, /logout"] + identity["identity.ts
/identity/vault, /identity/recover"] + credentials["credentials.ts
/present, revocation status list"] + eligibility["eligibility.ts, enrolment.ts
sessions, attestations,
/enrolment/voucher"] + relayr["relay.ts
/relay/enroll, /relay/vote"] + other["preferences.ts, organizerDomains.ts"] + end + + subgraph core["src"] + auth["auth/
World ID (server-fixed action),
session cookie, per-voter limits"] + sd["sd/, status/
SD-JWT issuer, StatusList2021"] + elig["eligibility/
policies, self.ts, attester"] + chainmods["chain/
registrar, relayer, signer queue,
deployments"] + end + + subgraph onchain["Polygon"] + registry["PlatformRegistry
members, vault, preferences"] + paymaster["ElectionPaymaster
relay hub"] + end + + browser --> routes + verify --> auth + auth --> worldid + verify --> sd + credentials --> sd + eligibility --> elig + elig --> self + identity --> chainmods + relayr --> chainmods + eligibility --> chainmods + other --> chainmods + chainmods -- "registrar key" --> registry + chainmods -- "relayer key, reimbursed" --> paymaster +``` + +`/relay/vote` carries no session: a ballot is authorised by its proof, and a +cookie on it would tie the ballot to the voter it is meant not to name. + See [`DEVELOPMENT.md`](DEVELOPMENT.md) for the full stack, endpoint reference, environment variables and known technical debt. See the [root README](../README.md) for project context. diff --git a/backend/package-lock.json b/backend/package-lock.json index c2eb986..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,9 +24,14 @@ "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/node": "^26.6.2", + "@types/node": "^26.6.3", + "oxlint": "^1.86.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.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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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", @@ -1807,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" @@ -3172,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" @@ -5018,6 +5370,55 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oxlint": { + "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": { + "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.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": { + "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..137e373 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" @@ -28,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" @@ -37,7 +43,8 @@ "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@types/node": "^26.6.2", + "@types/node": "^26.6.3", + "oxlint": "^1.86.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.test.ts b/backend/src/chain/relayer.test.ts new file mode 100644 index 0000000..5e32aac --- /dev/null +++ b/backend/src/chain/relayer.test.ts @@ -0,0 +1,68 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; + +import { isVoteCall } from './relayer.js'; + +/** + * The shape check `/relay/vote` runs before simulating anything. + * + * The endpoint is public by design (a session would tell this server who cast + * which ballot), so it is the one place a stranger's body reaches the relayer. + * Whether a ballot is VALID is the chain's call; what this has to hold is that + * nothing oversized or malformed gets as far as costing a simulation. + */ + +const points = (n: number) => Array.from({ length: 2 * n }, (_, i) => String(i + 1)); + +function call(overrides: Record = {}, ballot: Record = {}) { + return { + election: '0x0000000000000000000000000000000000000001', + ballot: { + votersRoot: '1', + ballotsRoot: '0', + epoch: '480000', + tag: '12345', + epochTag: '67890', + leaf: '42', + voteA: ['1', '2'], + voteB: points(5), + cancelA: ['3', '4'], + cancelB: points(5), + ...ballot, + }, + proof: { a: ['1', '2'], b: [['3', '4'], ['5', '6']], c: ['7', '8'] }, + ...overrides, + }; +} + +describe('a relayed ballot', () => { + test('passes when shaped like one', () => { + assert.equal(isVoteCall(call()), true); + assert.equal(isVoteCall(call({}, { voteB: points(51), cancelB: points(51) })), true); + }); + + test('is refused with anything missing', () => { + assert.equal(isVoteCall(undefined), false); + assert.equal(isVoteCall({}), false); + assert.equal(isVoteCall(call({ proof: undefined })), false); + assert.equal(isVoteCall(call({}, { tag: undefined })), false); + }); + + test('is refused with numbers that are not decimal strings of a field element', () => { + assert.equal(isVoteCall(call({}, { tag: 12345 })), false); + assert.equal(isVoteCall(call({}, { tag: '0x3039' })), false); + assert.equal(isVoteCall(call({}, { tag: '-1' })), false); + assert.equal(isVoteCall(call({}, { tag: '9'.repeat(79) })), false); + }); + + test('is refused when a point list is larger than the largest circuit', () => { + assert.equal(isVoteCall(call({}, { voteB: points(52) })), false); + assert.equal(isVoteCall(call({}, { cancelB: [] })), false); + assert.equal(isVoteCall(call({}, { voteA: ['1'] })), false); + }); + + test('is refused with a proof of the wrong shape', () => { + assert.equal(isVoteCall(call({ proof: { a: ['1', '2'], b: [['3', '4']], c: ['7', '8'] } })), false); + assert.equal(isVoteCall(call({ proof: { a: ['1'], b: [['3', '4'], ['5', '6']], c: ['7', '8'] } })), false); + }); +}); diff --git a/backend/src/chain/relayer.ts b/backend/src/chain/relayer.ts index 1008779..5692bb7 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 relayVote(address election, bytes voteCiphertext, uint256 nullifier, uint256 merkleRoot, uint256 merkleDepth, uint256[2] pA, uint256[2][2] pB, uint256[2] pC)', + 'function relayEnrollPrivate(address election, uint256 identityCommitment, uint256 humanTag, uint256 documentTag, uint256 deadline, bytes platformSignature, bytes eligibilitySignature)', + 'function relayVote(address election, (uint256 votersRoot, uint256 ballotsRoot, uint256 epoch, uint256 tag, uint256 epochTag, uint256 leaf, uint256[2] voteA, uint256[] voteB, uint256[2] cancelA, uint256[] cancelB) ballot, (uint256[2] a, uint256[2][2] b, uint256[2] c) proof)', // 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,12 @@ const PAYMASTER_ABI = [ 'error UnknownOrExpiredRoot()', 'error InvalidProof()', 'error WrongPhase()', + 'error TagAlreadyCast()', + 'error EpochAlreadyCast()', + 'error WrongEpoch()', + 'error TreeFull()', + 'error MissingDocumentTag()', + 'error UnexpectedDocumentTag()', 'function organizerOf(address election) view returns (address)', 'function gasBalance(address organizer) view returns (uint256)', ]; @@ -65,54 +71,108 @@ 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; error?: string; } +/** A ballot and its proof as the browser sends them: every number a decimal string. */ export interface VoteCall { election: string; - voteCiphertext: string; - nullifier: string; - merkleRoot: string; - merkleDepth: string; - pA: [string, string]; - pB: [[string, string], [string, string]]; - pC: [string, string]; + ballot: { + votersRoot: string; + ballotsRoot: string; + epoch: string; + tag: string; + epochTag: string; + leaf: string; + voteA: string[]; + voteB: string[]; + cancelA: string[]; + cancelB: string[]; + }; + proof: { a: string[]; b: string[][]; c: 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'); +/** + * Points in one of a ballot's B lists: two numbers per slot, and the largest + * circuit has 51 slots (50 options and the blank vote; ElectionV4.MAX_OPTIONS). + */ +const MAX_POINT_VALUES = 2 * 51; + +const isDecimal = (v: unknown): v is string => typeof v === 'string' && /^\d{1,78}$/.test(v); +const decimals = (v: unknown, length: number | [number, number]): boolean => { + if (!Array.isArray(v)) return false; + const [min, max] = typeof length === 'number' ? [length, length] : length; + return v.length >= min && v.length <= max && v.every(isDecimal); +}; + +/** + * Whether a body is shaped like a ballot and its proof, before anything is + * simulated. The chain is the judge of whether it is a VALID one; this only + * keeps an oversized or malformed body from reaching the simulation at all. + */ +export function isVoteCall(body: unknown): body is VoteCall { + const { election, ballot, proof } = (body ?? {}) as Partial; + if (typeof election !== 'string' || !ballot || !proof) return false; + const scalars = ['votersRoot', 'ballotsRoot', 'epoch', 'tag', 'epochTag', 'leaf'] as const; + return ( + scalars.every(key => isDecimal(ballot[key])) && + decimals(ballot.voteA, 2) && + decimals(ballot.cancelA, 2) && + decimals(ballot.voteB, [2, MAX_POINT_VALUES]) && + decimals(ballot.cancelB, [2, MAX_POINT_VALUES]) && + decimals(proof.a, 2) && + decimals(proof.c, 2) && + Array.isArray(proof.b) && + proof.b.length === 2 && + proof.b.every(pair => decimals(pair, 2)) + ); } -export async function relayEnroll(election: string, commitment: string): Promise { +type PaymasterMethod = 'relayEnroll' | 'relayEnrollAttested' | 'relayEnrollPrivate' | 'relayVote'; + +/** + * 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 +180,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 +201,45 @@ 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 { + const { ballot, proof } = call; + return relay('relayVote', call.election, () => [ + call.election, + { + votersRoot: BigInt(ballot.votersRoot), + ballotsRoot: BigInt(ballot.ballotsRoot), + epoch: BigInt(ballot.epoch), + tag: BigInt(ballot.tag), + epochTag: BigInt(ballot.epochTag), + leaf: BigInt(ballot.leaf), + voteA: ballot.voteA.map(BigInt), + voteB: ballot.voteB.map(BigInt), + cancelA: ballot.cancelA.map(BigInt), + cancelB: ballot.cancelB.map(BigInt), + }, + { a: proof.a.map(BigInt), b: proof.b.map(pair => pair.map(BigInt)), c: proof.c.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..0a46b5f 100644 --- a/backend/src/routes/relay.ts +++ b/backend/src/routes/relay.ts @@ -25,12 +25,13 @@ import { relayEnrollPrivate, relayVote, isRelayerConfigured, - type VoteCall, + isVoteCall, } from '../chain/relayer.js'; 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'; const router = Router(); @@ -96,11 +97,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 +115,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( @@ -131,15 +138,13 @@ router.post('/relay/vote', relayLimiter, async (req: Request, res: Response) => return res.status(503).json({ error: 'Relayer not configured' }); } - const body = req.body as Partial; - const missing = ( - ['election', 'voteCiphertext', 'nullifier', 'merkleRoot', 'merkleDepth', 'pA', 'pB', 'pC'] as const - ).filter(field => body[field] === undefined); - if (missing.length > 0) { - return res.status(400).json({ error: `missing fields: ${missing.join(', ')}` }); + // Refused here as well as on chain, so a malformed or oversized body never + // reaches the simulation, let alone the relayer's float. + if (!isVoteCall(req.body)) { + return res.status(400).json({ error: 'not a ballot: expected { election, ballot, proof } of decimal strings' }); } - const result = await relayVote(body as VoteCall); + const result = await relayVote(req.body); if (!result.relayed) return res.status(400).json({ error: result.error }); return res.status(200).json({ txHash: result.txHash }); }); 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/circuits/README.md b/circuits/README.md new file mode 100644 index 0000000..e7061f2 --- /dev/null +++ b/circuits/README.md @@ -0,0 +1,66 @@ +# circuits/ + +The two circom circuits behind every Votain ballot and result, their trusted +setup, and the Solidity verifiers they generate. + +- **`src/ballot.circom`**: a ballot is an exponential ElGamal encryption of one + option over Baby Jubjub, plus a re-randomised cancellation of the voter's + previous ballot. The proof says the voter is on the roll, the vote is exactly + one option, the cancellation undoes this voter's own previous ballot, and the + tag and the epoch tag are theirs, without saying who or which ballot. +- **`src/tally.circom`**: the published counts are the decryption of the + aggregate the election keeps, under its keys. In voiding mode it proves only + that fewer voters than the privacy quorum took part. + +```bash +npm install +npm test # compiles the smallest size, then 8 witness tests +npm run build # sizes 5 and 9: compile, trusted setup, verifiers +npm run build:all # adds 17, 33 and 51 slots (up to 50 options) +``` + +On Windows the WebAssembly compiler (circom2) cannot build these circuits: put +the native circom v2.2.3 on the PATH or point `CIRCOM` at it (see the root +README). + +## From source to deployment + +```mermaid +flowchart LR + subgraph src["src/"] + ballot["ballot.circom"] + tally["tally.circom"] + end + libs["circomlib, zk-kit
binary-merkle-root"] + ptau[("Phase 1
PTAU, a public ceremony,
or generated locally for dev")] + entropy["CEREMONY_ENTROPY
phase 2 secret, discarded after"] + + build["scripts/build.mjs
one compile + setup per size"] + + subgraph out["outputs"] + art["build/
*.wasm, *.zkey, *.vkey.json,
manifest.json"] + sol["contracts/contracts/verifiers/
BallotVerifierS*, TallyVerifierS*"] + pub["frontend/public/circuits/
what browsers prove with"] + end + + deploy["contracts: deploy.ts
refuses the dev ceremony off local"] + ipfs[("VITE_CIRCUITS_URL
published with the deployment")] + + ballot --> build + tally --> build + libs --> build + ptau --> build + entropy --> build + build --> art + build --> sol + build --> pub + sol --> deploy + art -- "manifest: sizes, hashes, ceremony" --> deploy + pub --> ipfs +``` + +With `CEREMONY_ENTROPY` unset the build uses a fixed, public entropy so every +machine produces the same development artefacts. Anyone can forge proofs +against those, which is why `deploy.ts` refuses them off the local chain before +sending a single transaction. [`docs/dev/deployment.md`](../docs/dev/deployment.md) +has the steps for a real deployment. diff --git a/circuits/package-lock.json b/circuits/package-lock.json new file mode 100644 index 0000000..f264e4a --- /dev/null +++ b/circuits/package-lock.json @@ -0,0 +1,1684 @@ +{ + "name": "votain-circuits", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "votain-circuits", + "version": "1.0.0", + "license": "AGPL-3.0-only", + "devDependencies": { + "@semaphore-protocol/identity": "^4.14.3", + "@zk-kit/binary-merkle-root.circom": "^2.0.0", + "@zk-kit/lean-imt": "^2.2.5", + "circom2": "^0.2.23", + "circomlib": "^2.0.5", + "poseidon-lite": "^0.3.0", + "snarkjs": "^0.7.6", + "tsx": "^4.23.15" + }, + "engines": { + "node": ">=24", + "npm": ">=11" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "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==", + "dev": true, + "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==", + "dev": true, + "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", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@semaphore-protocol/identity": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@semaphore-protocol/identity/-/identity-4.14.3.tgz", + "integrity": "sha512-YMSNBGMJ2H1GodTO/F7J3Tft/D+NxtCDpw4Q1SjqxKcgP2tKVmxpycCLcfJGISu9pwQ5CcRhy/xYWxlzgekYQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zk-kit/baby-jubjub": "1.0.3", + "@zk-kit/eddsa-poseidon": "1.0.4", + "@zk-kit/utils": "1.3.0", + "poseidon-lite": "0.3.0" + } + }, + "node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@wasmer/wasi": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@wasmer/wasi/-/wasi-0.12.0.tgz", + "integrity": "sha512-FJhLZKAfLWm/yjQI7eCRHNbA8ezmb7LSpUYFkHruZXs2mXk2+DaQtSElEtOoNrVQ4vApTyVaAd5/b7uEu8w6wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browser-process-hrtime": "^1.0.0", + "buffer-es6": "^4.9.3", + "path-browserify": "^1.0.0", + "randomfill": "^1.0.4" + } + }, + "node_modules/@zk-kit/baby-jubjub": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@zk-kit/baby-jubjub/-/baby-jubjub-1.0.3.tgz", + "integrity": "sha512-Wl+QfV6XGOMk1yU2JTqHXeKWfJVXp83is0+dtqfj9wx4wsAPpb+qzYvwAxW5PBx5/Nu71Bh7jp/5vM+6QgHSwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zk-kit/utils": "1.2.1" + } + }, + "node_modules/@zk-kit/baby-jubjub/node_modules/@zk-kit/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@zk-kit/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-H2nTsyWdicVOyvqC5AjgU7tsTgmR6PDrruFJNmlmdhKp7RxEia/E1B1swMZjaasYa2QMp4Zc6oB7cWchty7B2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3" + } + }, + "node_modules/@zk-kit/binary-merkle-root.circom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@zk-kit/binary-merkle-root.circom/-/binary-merkle-root.circom-2.0.0.tgz", + "integrity": "sha512-YaOmt1tq9mGwuQmk6ijlNbBRT71dxZciyuGOaNyL4eyosOeQbiM3u0WFDWbaHaKonpYupDn2Z5gUDHy0dtH0uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "circomlib": "^2.0.5" + } + }, + "node_modules/@zk-kit/eddsa-poseidon": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@zk-kit/eddsa-poseidon/-/eddsa-poseidon-1.0.4.tgz", + "integrity": "sha512-w0HrF5F+Kh+77+VK/p9qzB7wvitiZMAoWnqrmzPUM+MWlaNp0ipmtuO8KTLxnj8ACjt98GAK4voxl7XeWjC4XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zk-kit/baby-jubjub": "1.0.3", + "@zk-kit/utils": "1.2.1", + "buffer": "6.0.3", + "poseidon-lite": "0.3.0" + } + }, + "node_modules/@zk-kit/eddsa-poseidon/node_modules/@zk-kit/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@zk-kit/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-H2nTsyWdicVOyvqC5AjgU7tsTgmR6PDrruFJNmlmdhKp7RxEia/E1B1swMZjaasYa2QMp4Zc6oB7cWchty7B2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3" + } + }, + "node_modules/@zk-kit/lean-imt": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@zk-kit/lean-imt/-/lean-imt-2.2.5.tgz", + "integrity": "sha512-7pJIWiWp30wKz7OlbzV/+ATvHD+Emwy1aejU+9R4V+a1BkdY/yhGA/hFoVULxYyZ/RohiCfcJC/48cU2xI6Rtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zk-kit/utils": "1.4.1" + } + }, + "node_modules/@zk-kit/lean-imt/node_modules/@zk-kit/utils": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@zk-kit/utils/-/utils-1.4.1.tgz", + "integrity": "sha512-IoXzy3ElfkvRfC/s6uk3eFQO8uxkCdAOVxKlJlaEjM7pgckX5AK46Q+7n5yMQD/v/o6OL0mMtet065o5EpUocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "ethers": "6.13.5" + } + }, + "node_modules/@zk-kit/utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@zk-kit/utils/-/utils-1.3.0.tgz", + "integrity": "sha512-Gqsq5KLDf2WPuZSsNc2KZy25XeK5d5UV9hwLaPEM5wq+GD2qCi1ZBFzNs0OptiJ9GRGtP2J/xptrszWri+pRqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "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==", + "dev": true, + "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": ">= 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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browser-process-hrtime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", + "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-es6": { + "version": "4.9.3", + "resolved": "https://registry.npmjs.org/buffer-es6/-/buffer-es6-4.9.3.tgz", + "integrity": "sha512-Ibt+oXxhmeYJSsCkODPqNpPmyegefiD8rfutH1NYGhMZQhSp95Rz7haemgnJ6dxa6LT+JLLbtgOMORRluwKktw==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ffjavascript": "0.3.1" + }, + "bin": { + "calcwit": "calcwit.js" + } + }, + "node_modules/circom2": { + "version": "0.2.23", + "resolved": "https://registry.npmjs.org/circom2/-/circom2-0.2.23.tgz", + "integrity": "sha512-ykGgYwiPPqZAcDb/DA6EP38PjXlL+y8l2z63i9L3iohqeXtFj/Y0Ch+GNc5SZYfx310gPbnPcSQB4PQRuzfDcA==", + "dev": true, + "license": "GPL-3.0", + "dependencies": { + "@wasmer/wasi": "^0.12.0", + "is-typed-array": "^1.1.8", + "path-browserify": "^1.0.1" + }, + "bin": { + "circom2": "cli.js" + }, + "engines": { + "node": ">=15" + } + }, + "node_modules/circomlib": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/circomlib/-/circomlib-2.0.5.tgz", + "integrity": "sha512-O7NQ8OS+J4eshBuoy36z/TwQU0YHw8W3zxZcs4hVwpEll3e4hDm3mgkIPqItN8FDeLEKZFK3YeT/+k8TiLF3/A==", + "dev": true, + "license": "GPL-3.0" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ethers": { + "version": "6.13.5", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.13.5.tgz", + "integrity": "sha512-+knKNieu5EKRThQJWwqaJ10a6HE9sSehGeqWN65//wE7j47ZpFhKAnHB/JJFibwwg61I/koxaPsXbXpD/skNOQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/r1csfile": { + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/r1csfile/-/r1csfile-0.0.48.tgz", + "integrity": "sha512-kHRkKUJNaor31l05f2+RFzvcH5XSa7OfEfd/l4hzjte6NL6fjRkSMfZ4BjySW9wmfdwPOtq3mXurzPvPGEf5Tw==", + "dev": true, + "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==", + "dev": true, + "license": "GPL-3.0", + "dependencies": { + "wasmbuilder": "0.0.16", + "wasmcurves": "0.2.2", + "web-worker": "1.2.0" + } + }, + "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", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/snarkjs": { + "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", + "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/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/circuits/package.json b/circuits/package.json new file mode 100644 index 0000000..4e02d5b --- /dev/null +++ b/circuits/package.json @@ -0,0 +1,27 @@ +{ + "name": "votain-circuits", + "version": "1.0.0", + "private": true, + "description": "The Votain ballot and tally circuits, their trusted setup and the Solidity verifiers they produce", + "type": "module", + "license": "AGPL-3.0-only", + "engines": { + "node": ">=24", + "npm": ">=11" + }, + "scripts": { + "build": "node scripts/build.mjs", + "build:all": "node scripts/build.mjs --all", + "test": "node scripts/build.mjs --compile && node --import tsx --test test/*.test.ts" + }, + "devDependencies": { + "@semaphore-protocol/identity": "^4.14.3", + "@zk-kit/binary-merkle-root.circom": "^2.0.0", + "@zk-kit/lean-imt": "^2.2.5", + "circom2": "^0.2.23", + "circomlib": "^2.0.5", + "poseidon-lite": "^0.3.0", + "snarkjs": "^0.7.6", + "tsx": "^4.23.15" + } +} diff --git a/circuits/scripts/build.mjs b/circuits/scripts/build.mjs new file mode 100644 index 0000000..1f19cc2 --- /dev/null +++ b/circuits/scripts/build.mjs @@ -0,0 +1,257 @@ +/** + * Compiles the ballot circuit per size, runs its trusted setup and writes the + * Solidity verifier each size needs. + * + * SIZES. A ballot carries one ciphertext per option plus the blank vote, so the + * circuit is compiled for a fixed number of SLOTS and an election uses the + * smallest size that fits it (the unused slots are forced to zero). `build` + * produces the sizes every development and test run needs; `build:all` adds + * the larger ones, whose setup needs a bigger powers-of-tau file and takes far + * longer. + * + * THE CEREMONY, stated plainly. Groth16 needs a trusted setup: whoever knows + * the randomness used in it can forge proofs, which here means forging ballots. + * With CEREMONY_ENTROPY unset this script uses a FIXED, PUBLIC entropy so every + * machine builds byte-identical artefacts for development and CI. Those + * artefacts are INSECURE by construction and must never back a real election. + * A deployment sets CEREMONY_ENTROPY to a secret (better: runs a multi-party + * 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 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. + * + * Outputs: + * circuits/build/ballot_s.{wasm,zkey,vkey.json} the artefacts + * contracts/contracts/verifiers/BallotVerifierS.sol one verifier per size + * frontend/public/circuits/_s.{wasm,zkey,vkey.json} served to voters and auditors + * circuits/build/manifest.json sizes, hashes, entropy kind + */ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as snarkjs from "snarkjs"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const BUILD = join(ROOT, "build"); +const VERIFIERS = join(ROOT, "..", "contracts", "contracts", "verifiers"); +const PUBLIC = join(ROOT, "..", "frontend", "public", "circuits"); + +/** Slots per size: options plus the blank vote. The contract's ceiling is 50 options. */ +const DEFAULT_SIZES = [5, 9]; +const ALL_SIZES = [5, 9, 17, 33, 51]; +const MAX_DEPTH = 20; + +const DEV_ENTROPY = "votain-INSECURE-development-ceremony-do-not-use-in-production"; +const entropy = process.env.CEREMONY_ENTROPY || DEV_ENTROPY; +const insecure = entropy === DEV_ENTROPY; + +const sizes = process.argv.includes("--all") ? ALL_SIZES : DEFAULT_SIZES; +const FORCE = process.argv.includes("--force"); +const logger = { debug() {}, info() {}, log() {}, warn: console.warn, error: console.error }; + +for (const dir of [BUILD, VERIFIERS, PUBLIC]) mkdirSync(dir, { recursive: true }); +if (insecure) { + console.warn("WARNING: CEREMONY_ENTROPY is not set. Building with the public development"); + console.warn("entropy: anyone can forge proofs against these verifiers. Never deploy them."); +} + +const sha256 = file => createHash("sha256").update(readFileSync(file)).digest("hex"); + +const CIRCUITS = { + ballot: { + source: "ballot.circom", + main: slots => + `component main {public [votersRoot, ballotsRoot, scope, keysHash, slots, epoch, voteA, voteB, cancelA, cancelB]}` + + ` = Ballot(${slots}, ${MAX_DEPTH});`, + verifier: "BallotVerifier", + iface: "IBallotVerifier", + method: "verifyBallot", + }, + tally: { + source: "tally.circom", + main: slots => `component main {public [keysHash, slots, aggA, aggB, quorum, publish]} = Tally(${slots});`, + verifier: "TallyVerifier", + iface: "ITallyVerifier", + method: "verifyTally", + }, +}; + +/** + * 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}`; + const main = join(BUILD, `${name}.circom`); + writeFileSync(main, `pragma circom 2.1.5;\ninclude "../src/${circuit.source}";\n${circuit.main(slots)}\n`); + const r1cs = join(BUILD, `${name}.r1cs`); + if (!existsSync(r1cs) || FORCE) { + console.log(`compiling ${name}`); + const { command, prefix } = compiler(); + execFileSync( + 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" }, + ); + } + return { name, r1cs, wasm: join(BUILD, `${name}_js`, `${name}.wasm`) }; +} + +/** A phase-1 file of 2^power: the public one in PTAU, or generated locally and cached. */ +async function powersOfTau(power) { + if (process.env.PTAU) return process.env.PTAU; + const cachedFor = p => join(BUILD, `pot${p}_${insecure ? "dev" : "custom"}.ptau`); + // Any cached file at least this large will do: a bigger phase 1 serves a + // smaller circuit, and generating a second one would cost as long again. + for (let p = power; p <= 28; p++) if (existsSync(cachedFor(p))) return cachedFor(p); + const final = cachedFor(power); + console.log(`powers of tau 2^${power} (slow)`); + const p0 = join(BUILD, `pot${power}_0.ptau`); + const p1 = join(BUILD, `pot${power}_1.ptau`); + await snarkjs.powersOfTau.newAccumulator(await snarkjs.curves.getCurveFromName("bn128"), power, p0, logger); + await snarkjs.powersOfTau.contribute(p0, p1, "votain", entropy, logger); + await snarkjs.powersOfTau.preparePhase2(p1, final, logger); + rmSync(p0); + rmSync(p1); + return final; +} + +async function setup(kind, slots) { + const circuit = CIRCUITS[kind]; + const { name, r1cs, wasm } = compile(kind, slots); + const info = await snarkjs.r1cs.info(r1cs, logger); + // Groth16 needs 2^power >= constraints + public signals + 1. + const power = Math.max(12, Math.ceil(Math.log2(info.nConstraints + info.nPubInputs + info.nOutputs + 1))); + const ptau = await powersOfTau(power); + + const zkey0 = join(BUILD, `${name}_0.zkey`); + const zkey = join(BUILD, `${name}.zkey`); + const cached = !FORCE && existsSync(zkey) && statSync(zkey).mtimeMs > statSync(r1cs).mtimeMs; + if (cached) { + console.log(`setup ${name}: cached`); + } else { + console.log(`setup ${name}: ${info.nConstraints} constraints, ${info.nPubInputs + info.nOutputs} public signals`); + await snarkjs.zKey.newZKey(r1cs, ptau, zkey0, logger); + await snarkjs.zKey.contribute(zkey0, zkey, "votain", entropy, logger); + rmSync(zkey0); + } + const vkey = await snarkjs.zKey.exportVerificationKey(zkey, logger); + writeFileSync(join(BUILD, `${name}.vkey.json`), JSON.stringify(vkey, null, 2)); + + const templates = { groth16: readFileSync(join(ROOT, "node_modules", "snarkjs", "templates", "verifier_groth16.sol.ejs"), "utf8") }; + const generated = await snarkjs.zKey.exportSolidityVerifier(zkey, templates, logger); + const publicCount = info.nPubInputs + info.nOutputs; + writeFileSync(join(VERIFIERS, `${circuit.verifier}S${slots}.sol`), wrapVerifier(circuit, generated, slots, publicCount)); + + copyFileSync(wasm, join(PUBLIC, `${name}.wasm`)); + copyFileSync(zkey, join(PUBLIC, `${name}.zkey`)); + // Small, and what an auditor checks a published proof against. + copyFileSync(join(BUILD, `${name}.vkey.json`), join(PUBLIC, `${name}.vkey.json`)); + return { kind, slots, constraints: info.nConstraints, publicSignals: publicCount, wasm: sha256(wasm), zkey: sha256(zkey) }; +} + +/** + * Renames the generated verifier and gives it the interface elections call. + * + * The generated `verifyProof` takes a FIXED-size array, whose length differs + * per size, so an election could not call every size the same way. The wrapper + * takes a dynamic array, checks its length and forwards it. + */ +function wrapVerifier(circuit, source, slots, publicCount) { + const { verifier, iface, method } = circuit; + const renamed = source + .replace(/pragma solidity [^;]+;/, "pragma solidity ^0.8.37;") + .replace("contract Groth16Verifier {", `import {${iface}} from "../${iface}.sol";\n\n/// @notice GENERATED by circuits/scripts/build.mjs for ${slots} slots. Do not edit.\ncontract ${verifier}S${slots} is ${iface} {`); + const wrapper = ` + /// @inheritdoc ${iface} + function slots() external pure returns (uint256) { + return ${slots}; + } + + /// @inheritdoc ${iface} + function ${method}( + uint[2] calldata a, + uint[2][2] calldata b, + uint[2] calldata c, + uint[] calldata pub + ) external view returns (bool) { + if (pub.length != ${publicCount}) return false; + uint[${publicCount}] memory fixedPub; + for (uint i = 0; i < ${publicCount}; i++) fixedPub[i] = pub[i]; + return this.verifyProof(a, b, c, fixedPub); + } +} +`; + 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")) { + 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)); + built.push(await setup("tally", slots)); +} +writeFileSync( + join(BUILD, "manifest.json"), + JSON.stringify({ maxDepth: MAX_DEPTH, ceremony: insecure ? "INSECURE-development" : "custom", sizes: built }, null, 2) + "\n", +); +console.log("done:", built.map(b => `${b.kind}S${b.slots}=${b.constraints}`).join(", ")); +process.exit(0); diff --git a/circuits/src/ballot.circom b/circuits/src/ballot.circom new file mode 100644 index 0000000..63540ac --- /dev/null +++ b/circuits/src/ballot.circom @@ -0,0 +1,232 @@ +pragma circom 2.1.5; + +include "circomlib/circuits/babyjub.circom"; +include "circomlib/circuits/bitify.circom"; +include "circomlib/circuits/comparators.circom"; +include "circomlib/circuits/escalarmulany.circom"; +include "circomlib/circuits/escalarmulfix.circom"; +include "circomlib/circuits/poseidon.circom"; +include "@zk-kit/binary-merkle-root.circom/src/binary-merkle-root.circom"; + +/* + * A Votain ballot whose re-votes nobody can see. + * + * WHAT IT REPLACES. A ballot used to carry the voter's Semaphore nullifier and + * a nonce, both public. Only the last ballot per nullifier counted, which is + * the coercion defence, but anybody who learned a voter's nullifier could see + * THAT they had voted again. A coercer only needs that much. + * + * WHAT A BALLOT IS NOW. An exponential ElGamal encryption of a one-hot vote, + * over Baby Jubjub, with one tally key per option and one shared randomness: + * A = r·G, B_i = v_i·G + r·H_i + * plus a CANCELLATION of the voter's previous ballot, re-randomised: + * A' = -A_prev + s·G, B'_i = -B_prev_i + s·H_i + * (an encryption of zero when there is no previous ballot). Summing EVERY + * ballot, votes and cancellations alike, telescopes each voter's chain down to + * their last vote, so the tally needs no deduplication and nothing public says + * which ballots belong together. + * + * WHAT THE PROOF SAYS, without revealing which voter or which previous ballot: + * - the voter is in the election's roll (Semaphore identity, members tree); + * - the vote is exactly one of the election's options; + * - `tag` = Poseidon(TAG, secret, scope, k) for a private counter k, so each + * step of a voter's chain can be cast once and nothing links the steps; + * - for k > 0, the cancelled ballot is the one this voter cast at step k-1, + * proved by membership in the ballots tree rather than by pointing at it; + * - `epochTag` = Poseidon(EPOCH, secret, scope, epoch), so one voter can cast + * at most one ballot per epoch, which bounds how fast anyone can spend the + * organizer's gas, without linking a voter's ballots across epochs; + * - `leaf` is this ballot's entry in the ballots tree, for the next step. + * + * A first ballot and a re-vote are indistinguishable: both carry a vote and a + * cancellation, and only the voter knows whether the cancellation is of zero. + */ + +// Domain separators, so a tag can never collide with an epoch tag or a leaf. +function TAG_DOMAIN() { return 1953718116; } // "tagd" +function EPOCH_DOMAIN() { return 1701867371; } // "epok" + +// Order of the prime subgroup of Baby Jubjub. +function SUBGROUP_ORDER() { + return 2736030358979909402780800718157159386076813972158567259200215660948447373041; +} + +/// A scalar in [1, l): Semaphore's own range check, and non-zero, because the +/// variable-base multiplier is only guaranteed for a non-zero scalar. +template CheckScalar() { + signal input in; + component lt = LessThan(251); + lt.in <== [in, SUBGROUP_ORDER()]; + lt.out === 1; + component zero = IsZero(); + zero.in <== in; + zero.out === 0; +} + +/// scalar · P for an arbitrary point P (the identity included). +template MulAny() { + signal input scalar; + signal input p[2]; + signal output out[2]; + component bits = Num2Bits(253); + bits.in <== scalar; + component mul = EscalarMulAny(253); + mul.e <== bits.out; + mul.p <== p; + out <== mul.out; +} + +/// scalar · G for the Baby Jubjub base point Base8. +template MulBase() { + signal input scalar; + signal output out[2]; + var BASE8[2] = [ + 5299619240641551281634865583518297030282874472190772894086521144482721001553, + 16950150798460657717958625567821834550301663161624707787222815936182638968203 + ]; + component bits = Num2Bits(253); + bits.in <== scalar; + component mul = EscalarMulFix(253, BASE8); + mul.e <== bits.out; + out <== mul.out; +} + +/// Poseidon chain over a point list, seeded with `seed`. +template HashPoints(N) { + signal input seed; + signal input points[N][2]; + signal output out; + signal h[N + 1]; + h[0] <== seed; + for (var i = 0; i < N; i++) { + h[i + 1] <== Poseidon(3)([h[i], points[i][0], points[i][1]]); + } + out <== h[N]; +} + +/// The ballots-tree leaf of a vote: Poseidon chain over (tag, A, B_0..B_n). +template BallotLeaf(SLOTS) { + signal input tag; + signal input a[2]; + signal input b[SLOTS][2]; + signal output out; + signal head <== Poseidon(3)([tag, a[0], a[1]]); + out <== HashPoints(SLOTS)(head, b); +} + +template Ballot(SLOTS, MAX_DEPTH) { + // ── public ────────────────────────────────────────────────────────────── + signal input votersRoot; + signal input ballotsRoot; + signal input scope; + signal input keysHash; + signal input slots; + signal input epoch; + signal input voteA[2]; + signal input voteB[SLOTS][2]; + signal input cancelA[2]; + signal input cancelB[SLOTS][2]; + + signal output tag; + signal output epochTag; + signal output leaf; + + // ── private ───────────────────────────────────────────────────────────── + signal input secret; + signal input votersDepth, votersIndex, votersSiblings[MAX_DEPTH]; + signal input keys[SLOTS][2]; + signal input choice[SLOTS]; + signal input r; + signal input s; + signal input k; + signal input prevA[2]; + signal input prevB[SLOTS][2]; + signal input ballotsDepth, ballotsIndex, ballotsSiblings[MAX_DEPTH]; + + var G[2] = [ + 5299619240641551281634865583518297030282874472190772894086521144482721001553, + 16950150798460657717958625567821834550301663161624707787222815936182638968203 + ]; + + // 1. The voter is on the roll, exactly as Semaphore V4 proves it. + CheckScalar()(secret); + var Ax, Ay; + (Ax, Ay) = BabyPbk()(secret); + signal commitment <== Poseidon(2)([Ax, Ay]); + signal computedVotersRoot <== BinaryMerkleRoot(MAX_DEPTH)(commitment, votersDepth, votersIndex, votersSiblings); + computedVotersRoot === votersRoot; + + // 2. The keys are the election's. + signal computedKeysHash <== HashPoints(SLOTS)(0, keys); + computedKeysHash === keysHash; + + // 3. Exactly one option, and only among the options this election has. + component slotsRange = Num2Bits(8); + slotsRange.in <== slots; + signal inUse[SLOTS]; + var total = 0; + for (var i = 0; i < SLOTS; i++) { + choice[i] * (choice[i] - 1) === 0; + inUse[i] <== LessThan(8)([i, slots]); + choice[i] * (1 - inUse[i]) === 0; + total += choice[i]; + } + total === 1; + + // 4. The vote: A = r·G, B_i = v_i·G + r·H_i. + CheckScalar()(r); + signal rG[2] <== MulBase()(r); + voteA[0] === rG[0]; + voteA[1] === rG[1]; + signal rH[SLOTS][2]; + signal vGx[SLOTS]; + signal vGy[SLOTS]; + for (var i = 0; i < SLOTS; i++) { + rH[i] <== MulAny()(r, keys[i]); + // v·G for a bit v: G itself, or the identity (0, 1). + vGx[i] <== choice[i] * G[0]; + vGy[i] <== 1 + choice[i] * (G[1] - 1); + var bx, by; + (bx, by) = BabyAdd()(vGx[i], vGy[i], rH[i][0], rH[i][1]); + voteB[i][0] === bx; + voteB[i][1] === by; + } + + // 5. The tags. + tag <== Poseidon(4)([TAG_DOMAIN(), secret, scope, k]); + epochTag <== Poseidon(4)([EPOCH_DOMAIN(), secret, scope, epoch]); + leaf <== BallotLeaf(SLOTS)(tag, voteA, voteB); + + // 6. The previous ballot. At k = 0 there is none: it must be the identity + // everywhere and no tree membership is asked for. Otherwise it is the + // ballot this voter cast at step k-1, found in the ballots tree. + component bits = Num2Bits(32); + bits.in <== k; + signal isFirst <== IsZero()(k); + signal prevTag <== Poseidon(4)([TAG_DOMAIN(), secret, scope, k - 1]); + signal prevLeaf <== BallotLeaf(SLOTS)(prevTag, prevA, prevB); + signal computedBallotsRoot <== BinaryMerkleRoot(MAX_DEPTH)(prevLeaf, ballotsDepth, ballotsIndex, ballotsSiblings); + (computedBallotsRoot - ballotsRoot) * (1 - isFirst) === 0; + prevA[0] * isFirst === 0; + (prevA[1] - 1) * isFirst === 0; + for (var i = 0; i < SLOTS; i++) { + prevB[i][0] * isFirst === 0; + (prevB[i][1] - 1) * isFirst === 0; + } + + // 7. The cancellation: -prev, re-randomised so it points at nothing. + CheckScalar()(s); + signal sG[2] <== MulBase()(s); + var cax, cay; + (cax, cay) = BabyAdd()(-prevA[0], prevA[1], sG[0], sG[1]); + cancelA[0] === cax; + cancelA[1] === cay; + signal sH[SLOTS][2]; + for (var i = 0; i < SLOTS; i++) { + sH[i] <== MulAny()(s, keys[i]); + var cbx, cby; + (cbx, cby) = BabyAdd()(-prevB[i][0], prevB[i][1], sH[i][0], sH[i][1]); + cancelB[i][0] === cbx; + cancelB[i][1] === cby; + } +} diff --git a/circuits/src/tally.circom b/circuits/src/tally.circom new file mode 100644 index 0000000..dced8ad --- /dev/null +++ b/circuits/src/tally.circom @@ -0,0 +1,126 @@ +pragma circom 2.1.5; + +include "ballot.circom"; + +/* + * The decryption of a Votain tally, checked on chain. + * + * The election adds every ballot, votes and cancellations, into one aggregate + * as they arrive (ElectionV4 keeps it). Because each re-vote cancels the one + * before, that aggregate encrypts each voter's LAST vote, and decrypting it + * gives the counts. This proves the decryption: + * + * H_i = x_i·G the organizer holds the keys the ballots used + * B_i = count_i·G + x_i·A and the aggregate decrypts to these counts + * + * so a result reaches the chain only if it is what the ballots say. + * + * TWO MODES, for the privacy quorum. Publishing (`publish = 1`) reveals the + * counts. Voiding (`publish = 0`) reveals only how many voted, proves that is + * below the quorum, and outputs zero counts: an election too small to publish + * can end without its handful of votes ever being disclosed. + */ +template Tally(SLOTS) { + // ── public ────────────────────────────────────────────────────────────── + signal input keysHash; + signal input slots; + signal input aggA[2]; + signal input aggB[SLOTS][2]; + signal input quorum; + signal input publish; + + signal output counts[SLOTS]; + signal output voters; + + // ── private ───────────────────────────────────────────────────────────── + signal input secrets[SLOTS]; + signal input count[SLOTS]; + + publish * (1 - publish) === 0; + component slotsRange = Num2Bits(8); + slotsRange.in <== slots; + + signal keys[SLOTS][2]; + signal xG[SLOTS][2]; + signal xA[SLOTS][2]; + signal mG[SLOTS][2]; + signal inUse[SLOTS]; + signal countBits[SLOTS][32]; + var total = 0; + for (var i = 0; i < SLOTS; i++) { + inUse[i] <== LessThan(8)([i, slots]); + + // The key of a slot in use is x·G; an unused slot's key is the identity, + // exactly as the ballots hashed it. + CheckScalar()(secrets[i]); + xG[i] <== MulBase()(secrets[i]); + keys[i][0] <== inUse[i] * xG[i][0]; + keys[i][1] <== 1 + inUse[i] * (xG[i][1] - 1); + + // Counts are small, and zero where there is no option. + countBits[i] <== Num2Bits(32)(count[i]); + count[i] * (1 - inUse[i]) === 0; + total += count[i]; + + // count·G, built from bits so a zero count is the identity. + mG[i] <== SmallMulBase(32)(count[i]); + + // B = count·G + x·A. + xA[i] <== MulAny()(secrets[i], aggA); + var bx, by; + (bx, by) = BabyAdd()(mG[i][0], mG[i][1], xA[i][0], xA[i][1]); + (aggB[i][0] - bx) * inUse[i] === 0; + (aggB[i][1] - by) * inUse[i] === 0; + + counts[i] <== publish * count[i]; + } + + signal computedKeysHash <== HashPoints(SLOTS)(0, keys); + computedKeysHash === keysHash; + + voters <== total; + + // Voiding is only for an election below its quorum. + signal below <== LessThan(32)([total, quorum]); + (1 - publish) * (1 - below) === 0; +} + +/// k·G for a small k, by double-and-add over its bits: exact for k = 0. +template SmallMulBase(BITS) { + signal input k; + signal output out[2]; + var G[2] = [ + 5299619240641551281634865583518297030282874472190772894086521144482721001553, + 16950150798460657717958625567821834550301663161624707787222815936182638968203 + ]; + component bits = Num2Bits(BITS); + bits.in <== k; + // acc_{j+1} = acc_j + bit_j · (2^j·G), with 2^j·G precomputed as constants. + signal acc[BITS + 1][2]; + signal termX[BITS]; + signal termY[BITS]; + acc[0] <== [0, 1]; + var P[2] = G; + for (var j = 0; j < BITS; j++) { + termX[j] <== bits.out[j] * P[0]; + termY[j] <== 1 + bits.out[j] * (P[1] - 1); + var ax, ay; + (ax, ay) = BabyAdd()(acc[j][0], acc[j][1], termX[j], termY[j]); + acc[j + 1] <== [ax, ay]; + P = babyDouble(P); + } + out <== acc[BITS]; +} + +/// Doubling of a constant point, evaluated at compile time. +function babyDouble(p) { + var a = 168700; + var d = 168696; + var x = p[0]; + var y = p[1]; + var denX = 1 + d * x * x * y * y; + var denY = 1 - d * x * x * y * y; + var nx = (2 * x * y) / denX; + var ny = (y * y - a * x * x) / denY; + return [nx, ny]; +} diff --git a/circuits/test/circuits.test.ts b/circuits/test/circuits.test.ts new file mode 100644 index 0000000..544d240 --- /dev/null +++ b/circuits/test/circuits.test.ts @@ -0,0 +1,160 @@ +/** + * The ballot and tally circuits, driven with real witnesses. + * + * Witness generation evaluates every constraint, so a witness that computes is + * a ballot the verifier would accept and one that throws is one it would not. + * No proving key is needed, which keeps this fast enough to run on every build. + */ +import { test, describe, before } from "node:test"; +import assert from "node:assert/strict"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { existsSync } from "node:fs"; +import * as snarkjs from "snarkjs"; +import { poseidon2 } from "poseidon-lite/poseidon2"; +import { poseidon3 } from "poseidon-lite/poseidon3"; +import { poseidon4 } from "poseidon-lite/poseidon4"; +import { LeanIMT } from "@zk-kit/lean-imt"; +import { Identity } from "@semaphore-protocol/identity"; + +import { + aggregate, + ballotCircuitInputs, + decryptTally, + deriveTallyKeys, + tallyCircuitInputs, + type Ballot, + type CastBallot, + type Point, +} from "../../frontend/src/lib/ballotCrypto.ts"; + +const BUILD = join(dirname(fileURLToPath(import.meta.url)), "..", "build"); +const SLOTS = 5; +const BALLOT_WASM = join(BUILD, `ballot_s${SLOTS}_js`, `ballot_s${SLOTS}.wasm`); +const TALLY_WASM = join(BUILD, `tally_s${SLOTS}_js`, `tally_s${SLOTS}.wasm`); + +const p3 = (x: bigint[]) => poseidon3(x); +const p4 = (x: bigint[]) => poseidon4(x); + +async function witness(wasm: string, inputs: object): Promise { + const out = { type: "mem" } as { type: "mem"; data?: Uint8Array }; + await snarkjs.wtns.calculate(inputs as never, wasm, out); +} + +const accepts = (wasm: string, inputs: object) => assert.doesNotReject(witness(wasm, inputs)); +const refuses = (wasm: string, inputs: object) => assert.rejects(witness(wasm, inputs)); + +// 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[]; + let secrets: bigint[]; + const voters = [new Identity("alice"), new Identity("bob"), new Identity("carol")]; + const members = new LeanIMT((a, b) => poseidon2([a, b]), voters.map(v => v.commitment)); + const ballots = new LeanIMT((a, b) => poseidon2([a, b])); + const cast: CastBallot[] = []; + + before(async () => { + // Four options in use out of five slots: the fifth is padding. + ({ keys, secrets } = await deriveTallyKeys(new TextEncoder().encode("organizer-secret-32-bytes-long!!"), "0x1", 4)); + }); + + function build(voter: number, choice: number, k: bigint, previous: CastBallot | null, epochFor = epoch) { + const prevIndex = previous ? cast.indexOf(previous) : -1; + return ballotCircuitInputs({ + poseidon3: p3, + poseidon4: p4, + circuitSlots: SLOTS, + secret: voters[voter].secretScalar, + scope, + epoch: epochFor, + keys, + choice, + k, + voters: members.generateProof(voter), + ballotsRoot: ballots.size ? ballots.root : 0n, + previous: previous ? { a: previous.voteA, b: previous.voteB, path: ballots.generateProof(prevIndex) } : null, + }); + } + + function record(ballot: CastBallot) { + cast.push(ballot); + ballots.insert(ballot.leaf); + } + + test("takes a first ballot, and the tag and leaf it outputs are the client's", async () => { + const { inputs, ballot } = build(0, 1, 0n, null); + await accepts(BALLOT_WASM, inputs); + record(ballot); + }); + + test("takes a re-vote that cancels the same voter's previous ballot", async () => { + const first = build(1, 2, 0n, null); + await accepts(BALLOT_WASM, first.inputs); + record(first.ballot); + const second = build(1, 0, 1n, first.ballot, epoch + 1n); + await accepts(BALLOT_WASM, second.inputs); + record(second.ballot); + }); + + test("refuses to cancel a ballot that is not the voter's own", async () => { + // Carol claims step 1 and points at Alice's ballot: her own step-0 tag + // does not match the leaf, so the tree membership fails. + const { inputs } = build(2, 0, 1n, cast[0]); + await refuses(BALLOT_WASM, inputs); + }); + + test("refuses a vote for two options at once", async () => { + const { inputs } = build(2, 0, 0n, null); + await refuses(BALLOT_WASM, { ...inputs, choice: ["1", "1", "0", "0", "0"] }); + }); + + test("refuses a vote for a slot the election does not have", async () => { + const { inputs } = build(2, 0, 0n, null); + // Slot 4 is padding in a four-slot election. + await refuses(BALLOT_WASM, { ...inputs, choice: ["0", "0", "0", "0", "1"] }); + }); + + test("refuses a ballot encrypted to keys other than the election's", async () => { + const other = await deriveTallyKeys(new TextEncoder().encode("another-organizer-secret-32bytes"), "0x1", 4); + const honest = build(2, 0, 0n, null); + const forged = ballotCircuitInputs({ + poseidon3: p3, poseidon4: p4, circuitSlots: SLOTS, secret: voters[2].secretScalar, scope, epoch, + keys: other.keys, choice: 0, k: 0n, voters: members.generateProof(2), ballotsRoot: ballots.root, previous: null, + }); + await refuses(BALLOT_WASM, { ...forged.inputs, keysHash: honest.inputs.keysHash }); + }); + + test("refuses a non-member", async () => { + const outsider = new Identity("mallory"); + const { inputs } = build(2, 0, 0n, null); + await refuses(BALLOT_WASM, { ...inputs, secret: outsider.secretScalar.toString() }); + }); + + test("tallies the last vote of each voter, proved against the aggregate", async () => { + const third = build(2, 3, 0n, null); + await accepts(BALLOT_WASM, third.inputs); + record(third.ballot); + + const onChain: Ballot[] = cast.map(b => ({ ...b, voteB: b.voteB, cancelB: b.cancelB })); + const total = aggregate(onChain, SLOTS); + const counts = decryptTally(secrets, { a: total.a, b: total.b.slice(0, 4) }, cast.length); + // Alice 1; Bob 2 then 0; Carol 3. + assert.deepEqual(counts, [1n, 1n, 0n, 1n]); + + const base = { poseidon3: p3, circuitSlots: SLOTS, keys, secrets, aggregate: total }; + await accepts(TALLY_WASM, tallyCircuitInputs({ ...base, counts, quorum: 2n, publish: true })); + // A count moved from one option to another. + await refuses(TALLY_WASM, tallyCircuitInputs({ ...base, counts: [2n, 0n, 0n, 1n], quorum: 2n, publish: true })); + // Voiding is only for an election below its quorum. + await refuses(TALLY_WASM, tallyCircuitInputs({ ...base, counts, quorum: 3n, publish: false })); + await accepts(TALLY_WASM, tallyCircuitInputs({ ...base, counts, quorum: 4n, publish: false })); + }); +}); 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..e641a92 100644 --- a/contracts/DEVELOPMENT.md +++ b/contracts/DEVELOPMENT.md @@ -15,15 +15,19 @@ | 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. | -| 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. | +| ElectionV4.sol | Single election. Members tree (LeanIMT/PoseidonT3), `enroll` gated to PlatformRegistry members plus an optional attribute policy (`enrollAttested`), `enrollPrivate` with a `documentTag` on gated elections. `castVote(BallotInput, Proof)`: an ElGamal vote and a cancellation of the voter's previous ballot, proved by the ballot circuit; checks the members and ballots roots, the ballot's `tag` (once) and `epochTag` (one per voter per `EPOCH_LENGTH`), files the ballot's leaf in the ballots tree and adds it to the on-chain aggregate. `publishResults(cid, counts, Proof)` verifies the tally circuit's proof against that aggregate; `voidBelowQuorum(voters, Proof)` ends an election below its quorum without revealing counts; `markVoided` is refused on a non-cancellable election with as many ballots as its quorum. The tag scope derives from the contract's own address. `VotingType` + `thresholdValue`, lifecycle (cancel / open and close early / void / publish). No meta-transaction forwarder. | +| ElectionFactory.sol | Creates elections through `ElectionDeployer` from a `Config` struct, choosing the smallest verifier pair (`verifiersFor`) that holds the options plus the blank vote; routes the deposit to the paymaster; enumerable `getElections(offset, limit)`. | +| ElectionDeployer.sol | Holds ElectionV4's creation code, which no longer fits inside the factory under the 24576-byte limit. Stateless and permissionless: only the factory can register what it deploys with the paymaster. | +| 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. Keeps no per-voter state: the election's epoch tags bound the rate. `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. | +| BabyJubJub.sol | Point addition in extended twisted Edwards coordinates, for the aggregate. | +| IBallotVerifier.sol, ITallyVerifier.sol | What an election calls: `slots()` and a verify taking the public signals as a dynamic array. | +| verifiers/*.sol | GENERATED by `circuits/scripts/build.mjs` (`BallotVerifierS`, `TallyVerifierS`), never committed. Build the circuits before compiling. | +| mocks/MockVerifier.sol | `MockBallotVerifier` / `MockTallyVerifier`: accept any proof with the right number of signals, unit tests and circuit-less local runs only. | -**External library**: `PoseidonT3` (poseidon-solidity) is linked into ElectionV4/Factory. The -deploy script deploys it deterministically (CREATE2) so it lands at the same address on every chain. +**External libraries**: `PoseidonT3` (the trees) and `PoseidonT4` (the keys hash) from +poseidon-solidity are linked into ElectionV4, and so into `ElectionDeployer`. The deploy script +deploys them deterministically (CREATE2) so they land at the same address on every chain. ### Why enrollment dedupes by human, not by commitment @@ -144,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 @@ -157,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 @@ -284,8 +288,8 @@ 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 +ALLOW_INSECURE_CEREMONY=1 # only for a throwaway testnet: deploy verifiers from the public + # development ceremony, which deploy.ts otherwise refuses off-chain-local ``` `https://rpc-amoy.polygon.technology` is dead (no DNS record since 2026). Working free @@ -294,14 +298,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/README.md b/contracts/README.md index 5481e3b..fc16ef5 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -1,13 +1,56 @@ # contracts/ -Solidity 0.8.37 smart contracts for Votain: Semaphore V4, a gas tank and relay hub that reimburses whoever submits a voter's transaction, and a coercion-resistant `ElectionV4` with a per-nullifier nonce. +Solidity 0.8.37 smart contracts for Votain: elections whose ballots are exponential ElGamal ciphertexts proved by Groth16 circuits, an aggregate each election keeps itself, results published only with a proof that they decrypt it, and a gas tank and relay hub that reimburses whoever submits a voter's transaction. Voters are Semaphore V4 identities in a per-election tree; re-votes are hidden (see the root README). NOT ERC-4337. A per-voter smart account published the link between an enrolment and the ballot that followed it, and hosted paymasters cannot fund gas per organizer. `ElectionPaymaster` says so in its own header. ```bash npm install -npx hardhat test # 185 passing +npx hardhat test # 222 passing, the E2E suite with real Groth16 proofs npx hardhat compile ``` +The E2E suite needs the circuits built first (`cd ../circuits && npm run build`). + +## How the contracts fit together + +```mermaid +flowchart TB + organizer["Organizer wallet"] + relayer["Issuer relayer / any sender"] + registrar["Issuer registrar (owner)"] + + factory["ElectionFactory
validates config, picks the smallest
verifier pair that fits"] + deployer["ElectionDeployer
holds ElectionV4's creation code
(too big for the factory)"] + election["ElectionV4
phases, members tree, ballots tree,
ElGamal aggregate, tags, quorum"] + paymaster["ElectionPaymaster
organizer balances, per-election
reserves, relayEnroll*/relayVote"] + registry["PlatformRegistry
platform members, status list,
sealed vault, preferences"] + domains["OrganizerDomains
self-service domain claims"] + + subgraph verifiers["verifiers/ (generated by circuits/)"] + bv["BallotVerifierS5, S9, ..."] + tv["TallyVerifierS5, S9, ..."] + end + + subgraph libs["libraries"] + bjj["BabyJubJub.sol
extended-coordinate adds"] + pos["PoseidonT3 / T4, LeanIMT"] + end + + organizer -- "createElection" --> factory + factory -- "deploy" --> deployer + deployer -- "new" --> election + factory -- "reserveFromBalanceFor" --> paymaster + organizer -- "deposit, reserve, withdraw" --> paymaster + organizer -- "close early, void, publishResults" --> election + relayer -- "relayEnroll*, relayVote" --> paymaster + paymaster -- "enroll*, castVote, then reimburse" --> election + registrar -- "registerMember, vault" --> registry + election -- "verifiedMembers" --> registry + election -- "verifyBallot" --> bv + election -- "verifyTally" --> tv + election --> bjj + election --> pos +``` + See [`DEVELOPMENT.md`](DEVELOPMENT.md) for the full stack, contract reference, environment variables and known technical debt. See the [root README](../README.md) for project context. diff --git a/contracts/contracts/BabyJubJub.sol b/contracts/contracts/BabyJubJub.sol new file mode 100644 index 0000000..52dbfba --- /dev/null +++ b/contracts/contracts/BabyJubJub.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.37; + +/// @title BabyJubJub +/// @notice Point addition on Baby Jubjub, the curve the ballot circuit encrypts +/// on, so an election can keep the running sum of every ballot itself. +/// @dev Extended twisted Edwards coordinates (X:Y:Z:T) with x = X/Z, y = Y/Z and +/// xy = T/Z, and the complete add-2008-hwcd formula (Baby Jubjub's d is not a +/// square). An addition costs a handful of mulmods and no inversion; the one +/// inversion a point needs is paid when it is read back as (x, y). +library BabyJubJub { + /// @dev The BN254 scalar field, which is Baby Jubjub's base field. + uint256 internal constant Q = + 21888242871839275222246405745257275088548364400416034343698204186575808495617; + uint256 internal constant A = 168700; + uint256 internal constant D = 168696; + + error NotOnCurve(); + + /// @notice The identity, (0, 1), in extended coordinates. + function identity() internal pure returns (uint256[4] memory p) { + p[1] = 1; + p[2] = 1; + } + + /// @notice An affine point in extended coordinates. Reverts off the curve. + function fromAffine(uint256 x, uint256 y) internal pure returns (uint256[4] memory p) { + if (!isOnCurve(x, y)) revert NotOnCurve(); + p[0] = x; + p[1] = y; + p[2] = 1; + p[3] = mulmod(x, y, Q); + } + + function isOnCurve(uint256 x, uint256 y) internal pure returns (bool) { + if (x >= Q || y >= Q) return false; + uint256 xx = mulmod(x, x, Q); + uint256 yy = mulmod(y, y, Q); + uint256 lhs = addmod(mulmod(A, xx, Q), yy, Q); + uint256 rhs = addmod(1, mulmod(D, mulmod(xx, yy, Q), Q), Q); + return lhs == rhs; + } + + /// @notice p + q, both in extended coordinates. + function add(uint256[4] memory p, uint256[4] memory q) internal pure returns (uint256[4] memory r) { + uint256 a = mulmod(p[0], q[0], Q); + uint256 b = mulmod(p[1], q[1], Q); + uint256 c = mulmod(D, mulmod(p[3], q[3], Q), Q); + uint256 d = mulmod(p[2], q[2], Q); + // e = (X1 + Y1)(X2 + Y2) - a - b + uint256 e = addmod( + mulmod(addmod(p[0], p[1], Q), addmod(q[0], q[1], Q), Q), + Q - addmod(a, b, Q), + Q + ); + uint256 f = addmod(d, Q - c, Q); + uint256 g = addmod(d, c, Q); + uint256 h = addmod(b, Q - mulmod(A, a, Q), Q); + r[0] = mulmod(e, f, Q); + r[1] = mulmod(g, h, Q); + r[2] = mulmod(f, g, Q); + r[3] = mulmod(e, h, Q); + } + + /// @notice The affine (x, y) of an extended point. + function toAffine(uint256[4] memory p) internal view returns (uint256 x, uint256 y) { + uint256 zInv = _inverse(p[2]); + x = mulmod(p[0], zInv, Q); + y = mulmod(p[1], zInv, Q); + } + + /// @dev Fermat inversion through the modexp precompile. + function _inverse(uint256 value) private view returns (uint256 result) { + bytes memory input = abi.encode(uint256(32), uint256(32), uint256(32), value, Q - 2, Q); + (bool ok, bytes memory output) = address(0x05).staticcall(input); + require(ok && output.length == 32, "modexp failed"); + result = abi.decode(output, (uint256)); + } +} diff --git a/contracts/contracts/ElectionDeployer.sol b/contracts/contracts/ElectionDeployer.sol new file mode 100644 index 0000000..5770f30 --- /dev/null +++ b/contracts/contracts/ElectionDeployer.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.37; + +import {ElectionV4} from "./ElectionV4.sol"; + +/// @title ElectionDeployer +/// @notice Holds ElectionV4's creation code so ElectionFactory does not have to. +/// +/// WHY IT EXISTS. A contract that says `new ElectionV4(...)` carries the whole +/// creation code of ElectionV4 inside its own, and once ballots were verified +/// and aggregated on chain that no longer fit under the 24576-byte limit next to +/// the factory's own logic. Splitting it off is the one change that leaves both +/// contracts exactly as they were. +/// +/// Permissionless and stateless on purpose. Deploying an ElectionV4 was never +/// privileged, anyone can do it from a wallet, and it confers nothing: an +/// election exists for the platform only once the paymaster has registered it, +/// which only ElectionFactory can do. +contract ElectionDeployer { + function deploy( + address ballotVerifier, + address tallyVerifier, + address registry, + address platformAttester, + address organizer, + ElectionV4.Config calldata cfg + ) external returns (address) { + return address(new ElectionV4(ballotVerifier, tallyVerifier, registry, platformAttester, organizer, cfg)); + } +} diff --git a/contracts/contracts/ElectionFactory.sol b/contracts/contracts/ElectionFactory.sol index c593eca..13171f8 100644 --- a/contracts/contracts/ElectionFactory.sol +++ b/contracts/contracts/ElectionFactory.sol @@ -2,15 +2,27 @@ pragma solidity ^0.8.37; import {ElectionV4} from "./ElectionV4.sol"; +import {ElectionDeployer} from "./ElectionDeployer.sol"; import {ElectionPaymaster} from "./ElectionPaymaster.sol"; +import {IBallotVerifier} from "./IBallotVerifier.sol"; +import {ITallyVerifier} from "./ITallyVerifier.sol"; /// @title ElectionFactory /// @notice Deploys ElectionV4 instances and forwards the organizer's MATIC deposit /// 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; + /// @notice Holds ElectionV4's creation code, which no longer fits in here. + ElectionDeployer public immutable deployer; + /** + * @notice The circuits' verifiers, one pair per size. An election uses the + * smallest size that holds its options and the blank vote. + * + * Fixed at deployment and ordered by size. An organizer never names a + * verifier: one who could would choose what counts as a valid ballot. + */ + address[] internal _ballotVerifiers; + address[] internal _tallyVerifiers; address public immutable registry; /** * @notice The key every election deployed here trusts for private enrolment. @@ -32,29 +44,40 @@ contract ElectionFactory { address indexed electionAddress, address indexed organizer, string name, - ElectionV4.VotingType votingType, - uint256 scope + ElectionV4.VotingType votingType ); error ZeroAddress(); + /// @dev The verifier lists are empty, of different lengths, or not in size order. + error BadVerifiers(); + /// @dev No circuit is large enough for this many options. + error TooManyOptions(); constructor( address _paymaster, - address _forwarder, - address _verifier, + address _deployer, + address[] memory ballotVerifiers_, + address[] memory tallyVerifiers_, address _registry, address _platformAttester ) { - if ( - _paymaster == address(0) || - _forwarder == address(0) || - _verifier == address(0) || - _registry == address(0) - ) revert ZeroAddress(); + if (_paymaster == address(0) || _deployer == address(0) || _registry == address(0)) { + revert ZeroAddress(); + } + if (ballotVerifiers_.length == 0 || ballotVerifiers_.length != tallyVerifiers_.length) { + revert BadVerifiers(); + } + uint256 previous = 0; + for (uint256 i = 0; i < ballotVerifiers_.length; i++) { + uint256 size = IBallotVerifier(ballotVerifiers_[i]).slots(); + if (size <= previous || ITallyVerifier(tallyVerifiers_[i]).slots() != size) revert BadVerifiers(); + previous = size; + } paymaster = ElectionPaymaster(payable(_paymaster)); - forwarder = _forwarder; - verifier = _verifier; + deployer = ElectionDeployer(_deployer); + _ballotVerifiers = ballotVerifiers_; + _tallyVerifiers = tallyVerifiers_; registry = _registry; platformAttester = _platformAttester; } @@ -73,13 +96,9 @@ contract ElectionFactory { ElectionV4.Config calldata cfg, uint256 fromBalance ) external payable returns (address) { - ElectionV4 newElection = new ElectionV4( - forwarder, - verifier, - registry, - platformAttester, - msg.sender, - cfg + (address ballotVerifier, address tallyVerifier) = verifiersFor(cfg.numOptions); + ElectionV4 newElection = ElectionV4( + deployer.deploy(ballotVerifier, tallyVerifier, registry, platformAttester, msg.sender, cfg) ); elections.push(address(newElection)); @@ -108,10 +127,28 @@ contract ElectionFactory { paymaster.reserveFromBalanceFor(address(newElection), msg.sender, fromBalance); } - emit ElectionCreated(address(newElection), msg.sender, cfg.name, cfg.votingType, cfg.scope); + emit ElectionCreated(address(newElection), msg.sender, cfg.name, cfg.votingType); return address(newElection); } + /// @notice The smallest verifier pair that holds `numOptions` and the blank vote. + function verifiersFor(uint256 numOptions) public view returns (address ballot, address tallyV) { + for (uint256 i = 0; i < _ballotVerifiers.length; i++) { + if (IBallotVerifier(_ballotVerifiers[i]).slots() > numOptions) { + return (_ballotVerifiers[i], _tallyVerifiers[i]); + } + } + revert TooManyOptions(); + } + + function ballotVerifiers() external view returns (address[] memory) { + return _ballotVerifiers; + } + + function tallyVerifiers() external view returns (address[] memory) { + return _tallyVerifiers; + } + function electionsCount() external view returns (uint256) { return elections.length; } @@ -121,8 +158,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..591903a 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 @@ -13,9 +14,9 @@ import {ElectionV4} from "./ElectionV4.sol"; /// /// 1. **Anonymity.** With account abstraction each voter gets their own smart /// account, and that address is the public `sender` of both their `enroll` -/// and their `castVote`. Anyone could link commitment to nullifier and, -/// through PlatformRegistry, back to the human, which defeats the Semaphore -/// proof entirely. Routing every voter through this one contract makes the +/// and their `castVote`. Anyone could link a voter's ballots to each other +/// and their enrolment, and through PlatformRegistry back to the human, +/// which defeats the zero-knowledge proof entirely. Routing every voter through this one contract makes the /// caller identical for all of them, so the transport layer leaks nothing. /// 2. **Multi-tenant sponsorship.** Hosted paymasters fund gas per project, /// billed to the project owner. There is no path for a third party to fund @@ -24,10 +25,10 @@ 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 + /// byte, because `relayVote` carries an encrypted ballot and a Groth16 /// proof while `relayEnroll` carries 52 bytes: one flat constant for both /// would badly overcharge the cheap call and undercharge the expensive one. uint256 public constant DEFAULT_BASE_OVERHEAD = 32_000; @@ -46,17 +47,31 @@ 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); - /// 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). + uint256 private constant PRIVATE_ENROLL_CALLDATA_HEAD = 4 + 32 + (32 * 4) + (32 * 2) + (32 * 2); + /// relayVote head: selector + address + ballot offset + the proof (eight + /// words, encoded in place), then the ballot's own head (six uint256, two + /// points, two array offsets) and the two arrays' length words. The arrays' + /// contents are added at call time from their lengths. + uint256 private constant VOTE_CALLDATA_HEAD = 4 + 32 + 32 + (32 * 8) + (32 * 12) + (32 * 2); + + /** + * @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; + /// A ballot on the largest circuit (51 slots) costs about 4.5M gas; see below. + uint256 public constant MAX_RELAY_GAS_CEILING = 6_000_000; + + + /// @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 @@ -94,8 +109,6 @@ contract ElectionPaymaster { 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 +125,9 @@ contract ElectionPaymaster { uint256 maxRelayGas ); - error NotOwner(); - error NotPendingOwner(); error NotFactory(); + error FactoryAlreadySet(); + error RelayParamsOutOfBounds(); error InsufficientBalance(); error WithdrawFailed(); error ReimbursementFailed(); @@ -124,14 +137,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,22 +147,35 @@ 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. maxGasPrice = 50 gwei; baseOverheadGas = DEFAULT_BASE_OVERHEAD; calldataGasPerByte = DEFAULT_CALLDATA_GAS; - maxRelayGas = 2_000_000; // a ballot costs ~400k; leaves ample headroom + // Measured by the E2E suite: a relayed ballot costs ~1.0M gas at 5 slots and + // ~1.3M at 9, about 77k per further slot, so ~4.5M at the largest size. The + // charge is the gas actually used; this only caps it, and a cap below the + // real cost would leave the relayer paying the difference. + maxRelayGas = 5_000_000; } // ──────────────────────────────────────────────── // 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 +186,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 +253,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 +283,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 +312,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 +341,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 +424,7 @@ contract ElectionPaymaster { address election, uint256 identityCommitment, uint256 humanTag, + uint256 documentTag, uint256 deadline, bytes calldata platformSignature, bytes calldata eligibilitySignature @@ -405,6 +435,7 @@ contract ElectionPaymaster { ElectionV4(election).enrollPrivate( identityCommitment, humanTag, + documentTag, deadline, platformSignature, eligibilitySignature @@ -419,36 +450,29 @@ contract ElectionPaymaster { _reimburse(election, organizer, startGas, billable); } - /// @notice Relay a voter's ballot, reimbursed from the organizer's tank. - /// @dev Deliberately permissionless. The zero-knowledge proof is the - /// authorisation, so requiring a whitelisted relayer would only add a - /// censorship point without adding safety. Spam is bounded because an - /// invalid proof reverts and the sender eats their own gas. + /** + * @notice Relay a voter's ballot, reimbursed from the organizer's tank. + * @dev Deliberately permissionless. The zero-knowledge proof is the + * authorisation, so requiring a whitelisted relayer would only add a + * censorship point without adding safety. Spam is bounded twice: an + * invalid proof reverts and the sender eats their own gas, and a valid one + * spends the voter's single ballot for the epoch (`ElectionV4.EPOCH_LENGTH`), + * so one voter can cost a tank at most one ballot per epoch. That used to + * be a cooldown kept here per nullifier, which was a public link between a + * voter's ballots; the election's epoch tags bound the same rate without it. + */ function relayVote( address election, - bytes calldata voteCiphertext, - uint256 nullifier, - uint256 merkleRoot, - uint256 merkleDepth, - uint256[2] calldata pA, - uint256[2][2] calldata pB, - uint256[2] calldata pC + ElectionV4.BallotInput calldata ballot, + ElectionV4.Proof calldata proof ) external nonReentrant { uint256 startGas = gasleft(); address organizer = _organizerOrRevert(election); - ElectionV4(election).castVote( - voteCiphertext, - nullifier, - merkleRoot, - merkleDepth, - pA, - pB, - pC - ); + ElectionV4(election).castVote(ballot, proof); - // Derived from the ciphertext length, never from msg.data.length. - uint256 billable = VOTE_CALLDATA_HEAD + ((voteCiphertext.length + 31) / 32) * 32; + // Derived from the arrays' lengths, never from msg.data.length. + uint256 billable = VOTE_CALLDATA_HEAD + 32 * (ballot.voteB.length + ballot.cancelB.length); _reimburse(election, organizer, startGas, billable); } @@ -521,24 +545,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..89cdf87 100644 --- a/contracts/contracts/ElectionV4.sol +++ b/contracts/contracts/ElectionV4.sol @@ -2,9 +2,11 @@ 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"; +import {PoseidonT4} from "poseidon-solidity/PoseidonT4.sol"; +import {BabyJubJub} from "./BabyJubJub.sol"; +import {IBallotVerifier} from "./IBallotVerifier.sol"; +import {ITallyVerifier} from "./ITallyVerifier.sol"; interface IPlatformRegistry { function verifiedMembers(uint256 identityCommitment) external view returns (bool); @@ -16,10 +18,22 @@ interface IPlatformRegistry { /// @title ElectionV4 /// @notice A single anonymous, coercion-resistant election. Members enroll their /// Semaphore identity commitment into an on-chain Lean Incremental Merkle Tree and -/// 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 { +/// later cast ElGamal-encrypted ballots (Baby Jubjub, one key per option) with a +/// zero-knowledge proof that the ballot is well formed and cast by a member. +/// +/// Re-voting is the coercion defence, and nobody can see it happen. Every ballot +/// carries a vote AND an encrypted cancellation of the voter's previous ballot +/// (of nothing, on a first ballot), so first votes and re-votes look alike, and +/// each ballot is filed under a tag that links it to no other. The contract adds +/// every ballot into one running aggregate, in which each voter's chain +/// telescopes to their last vote, and a result is published only with a proof +/// that it is the decryption of that aggregate (see circuits/src). +/// +/// 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; // ──────────────────────────────────────────────── @@ -65,8 +79,9 @@ contract ElectionV4 is ERC2771Context { uint256 enrollEnd; uint256 voteStart; uint256 voteEnd; - uint256 scope; // Semaphore V4 scope (external nullifier) - string paillierPublicKey; // JSON {"n": "0x…", "g": "0x…"} voters encrypt with + /// One tally key per slot in use (options, then the blank vote), as + /// affine Baby Jubjub points flattened to [x0, y0, x1, y1, ...]. + uint256[] tallyKeys; string metadataJson; // description, candidates, organizer name, tags (IPFS on mainnet) address eligibilityAttester; // address(0) = open election, no attribute policy bytes32 eligibilityPolicyHash; // keccak256 of the policy declared in metadataJson @@ -80,29 +95,58 @@ contract ElectionV4 is ERC2771Context { // Constants / immutables // ──────────────────────────────────────────────── - /// @dev Old merkle roots stay valid this long after being superseded, so proofs - /// generated just before another member enrolls do not become unusable. + /// @dev Old merkle roots stay valid this long after being SUPERSEDED, so a proof + /// generated just before another member enrolls, or another ballot lands, + /// does not become unusable. Measured from when a root stopped being current, + /// as Semaphore does: measured from when it was created, a root that had been + /// current for longer than this expired the moment it was replaced, and a + /// voter in a quiet election lost their proof to whoever voted first. uint256 public constant MERKLE_ROOT_VALIDITY = 1 hours; - uint256 public constant MIN_TREE_DEPTH = 1; - uint256 public constant MAX_TREE_DEPTH = 32; + /// @dev The depth the circuits were compiled for: both trees hold at most + /// 2^20 leaves, which bounds an election to that many members and ballots. + uint256 public constant MAX_TREE_DEPTH = 20; /** - * @dev Ceiling on ballot options, and it is the ENCODING that sets it. - * - * A ballot for option i is the Paillier plaintext B^i with B = 10^12, so a - * tally packs one counter per option into a single plaintext, base B. That - * plaintext must stay below the 2048-bit modulus, which is about 616 - * decimal digits: 12 digits per counter leaves room for 51 of them, and the - * blank vote takes one. Above that the top counter wraps and the decrypted - * tally is silently wrong, so the bound belongs here, where an election is - * refused before anyone votes in it, rather than in the client that happens - * to do the arithmetic. + * @notice One ballot per voter per epoch. + * + * Ballots are relayed at the organizer's expense, so something has to bound + * how fast one voter can spend it. The old bound was a cooldown per + * nullifier, which is exactly the public link between a voter's ballots + * this design removes. An epoch tag, derived from the voter's secret and + * the epoch number, is different in every epoch: it limits the rate without + * saying which ballots share an author. + * + * A proof may name the current epoch or the one before (see `castVote`), + * so the bound is two ballots in any window of this length, not one. + */ + uint256 public constant EPOCH_LENGTH = 1 hours; + + /** + * @dev Ceiling on ballot options, set by the largest circuit built: 50 + * options and the blank vote. Each slot costs the ballot proof two public + * signals and the aggregate one point, so the bound also keeps a ballot's + * gas within reach of any block. */ uint256 public constant MAX_OPTIONS = 50; address public immutable organizer; - ISemaphoreVerifier public immutable verifier; + IBallotVerifier public immutable ballotVerifier; + ITallyVerifier public immutable tallyVerifier; + /// @notice Slots the circuits were compiled for: at least numOptions + 1, + /// the rest padded with the identity point. + uint256 public immutable circuitSlots; + /// @notice Poseidon chain over the tally keys padded to `circuitSlots`, + /// exactly as the circuits hash them. Computed here, never supplied. + uint256 public immutable keysHash; + /** + * @notice The scope every tag of this election is derived under. + * + * Derived from this contract's own address and chain, never chosen: two + * elections sharing a scope would give a voter who holds one secret in both + * the same tags in both, and tags are what must not link anything. + */ + uint256 public immutable scope; IPlatformRegistry public immutable registry; /** @@ -181,7 +225,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)" ); /** @@ -211,8 +255,6 @@ contract ElectionV4 is ERC2771Context { uint256 public enrollEnd; uint256 public voteStart; uint256 public voteEnd; - uint256 public scope; - string public paillierPublicKey; string public metadataJson; // ──────────────────────────────────────────────── @@ -226,7 +268,7 @@ contract ElectionV4 is ERC2771Context { * * WHAT IT IS FOR. `closeEnrollmentEarly` and `closeVotingEarly` are ordinary * conveniences until you notice what the organizer can see while using - * them: `memberCount` and `distinctVoters` are public and rise in real time. + * them: `memberCount` and `voteCount` are public and rise in real time. * So an organizer can watch the electorate assemble and cut enrolment off at * the moment the roll suits them, or watch turnout and end the vote at the * moment the result does. Neither leaves a trace that says what it was for, @@ -292,11 +334,31 @@ contract ElectionV4 is ERC2771Context { LeanIMTData internal membersTree; - /// @dev merkle root => timestamp of its creation (0 = never existed) + /// @dev members root => when it stopped being the current root (0 = still current, or never existed) mapping(uint256 => uint256) public rootTimestamps; - /// @dev nullifier => next expected nonce (number of votes cast by that member) - mapping(uint256 => uint256) public nullifierNonces; + /// @dev Flattened affine tally keys, one point per slot in use. + uint256[] internal _tallyKeys; + + /// @dev Every ballot's leaf, so a voter can prove which ballot they cancel + /// without pointing at it. + LeanIMTData internal ballotsTree; + + /// @dev ballots root => when it stopped being the current root (0 = still current, or never existed). + /// The empty tree's root, zero, is recorded like any other, so a first ballot + /// proved before any other landed stays valid through the window. + mapping(uint256 => uint256) public ballotRootTimestamps; + + /// @dev Tags already cast. A tag is one step of one voter's chain. + mapping(uint256 => bool) public usedTags; + + /// @dev Epoch tags already cast: one ballot per voter per epoch. + mapping(uint256 => bool) public usedEpochTags; + + /// @dev The running sum of every ballot, votes and cancellations, in + /// extended coordinates: A, then one B per slot in use. + uint256[4] internal _aggA; + uint256[4][] internal _aggB; /** * @dev Personhood nullifier => already used in THIS election. @@ -319,18 +381,17 @@ contract ElectionV4 is ERC2771Context { /// identity cannot buy a second leaf, and therefore a second ballot. mapping(uint256 => bool) public enrolledHumans; - /// @dev Total VoteCast events emitted (re-votes included). + /// @dev Ballots cast, first votes and re-votes alike: nothing tells them apart. uint256 public voteCount; /** - * @dev How many distinct nullifiers have voted at least once. + * @notice How many voters the published (or voided) result rests on. * - * Not the same number as `voteCount`: re-voting is the coercion defence, so - * one person can appear in that total many times while the tally counts - * them once. This is the figure a published result has to agree with, and - * the figure the privacy quorum is measured against. + * Unknowable until the tally: each voter's chain telescopes to one vote in + * the aggregate, so this is the sum of the decrypted counts, proved with + * them. Zero until a result is published or voided below the quorum. */ - uint256 public distinctVoters; + uint256 public voters; string public resultsCid; uint256[] internal _tally; @@ -342,13 +403,29 @@ contract ElectionV4 is ERC2771Context { // ──────────────────────────────────────────────── event MemberEnrolled(uint256 indexed identityCommitment, uint256 index, uint256 merkleTreeRoot); - event VoteCast(uint256 indexed nullifier, bytes voteCiphertext, uint256 nonce, uint256 timestamp); + /** + * @notice One ballot, with everything anyone needs to recompute the + * aggregate and the ballots tree from events alone. Points are affine and + * flattened; each B list has one point per slot in use. + */ + event BallotCast( + uint256 indexed tag, + uint256 index, + uint256 leaf, + uint256[2] voteA, + uint256[] voteB, + uint256[2] cancelA, + uint256[] cancelB, + uint256 timestamp + ); event EnrollmentOpenedEarly(uint256 newEnrollStart); event VotingOpenedEarly(uint256 newVoteStart); event EnrollmentClosedEarly(uint256 newEnrollEnd, uint256 newVoteStart); event VotingClosedEarly(uint256 newVoteEnd); event ElectionCancelled(address indexed by); event ElectionVoided(address indexed by); + /// @notice Voided with a proof that fewer than the quorum voted. The counts stay secret. + event VoidedBelowQuorum(uint256 voters); event ResultsPublished(string ipfsCid, uint256[] tally, Outcome outcome, uint256 winnerIndex); /** @@ -376,7 +453,16 @@ contract ElectionV4 is ERC2771Context { error NotPlatformVerified(); error AlreadyEnrolled(); error UnknownOrExpiredRoot(); - error InvalidTreeDepth(); + /// @dev A tree reached the 2^20 leaves the circuits can prove membership in. + error TreeFull(); + /// @dev This step of some voter's chain was already cast. + error TagAlreadyCast(); + /// @dev This voter already cast a ballot in this epoch. + error EpochAlreadyCast(); + /// @dev The proof was built for an epoch that is neither this one nor the last. + error WrongEpoch(); + /// @dev The verifiers disagree with each other or with the options. + error VerifierMismatch(); error InvalidProof(); error InvalidTally(); error WrongPhase(); @@ -398,13 +484,19 @@ contract ElectionV4 is ERC2771Context { error PersonhoodNullifierUsed(); error TooManyOptions(); error PrivacyQuorumNotMet(); + /// @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(); // ──────────────────────────────────────────────── // Modifiers // ──────────────────────────────────────────────── modifier onlyOrganizer() { - if (_msgSender() != organizer) revert NotOrganizer(); + if (msg.sender != organizer) revert NotOrganizer(); _; } @@ -420,15 +512,16 @@ contract ElectionV4 is ERC2771Context { // ──────────────────────────────────────────────── constructor( - address trustedForwarder, - address _verifier, + address _ballotVerifier, + address _tallyVerifier, address _registry, address _platformAttester, address _organizer, Config memory cfg - ) ERC2771Context(trustedForwarder) { + ) { if ( - _verifier == address(0) || + _ballotVerifier == address(0) || + _tallyVerifier == address(0) || _registry == address(0) || _organizer == address(0) || cfg.numOptions == 0 || @@ -452,6 +545,9 @@ contract ElectionV4 is ERC2771Context { revert InvalidConfig(); } + // The tally circuit compares the voter count with the quorum over 32 bits. + if (cfg.privacyQuorum >= 1 << 32) revert InvalidConfig(); + // An attester without a policy hash would gate enrollment on rules // nobody can read, and a policy hash without an attester would publish // rules nothing enforces. Both are misconfigurations that would only @@ -475,7 +571,27 @@ contract ElectionV4 is ERC2771Context { revert InvalidConfig(); } - verifier = ISemaphoreVerifier(_verifier); + // The two circuits must be the same size, and big enough for every + // option and the blank vote. A mismatch would deploy an election nobody + // can vote in or nobody can count. + uint256 slotCount = IBallotVerifier(_ballotVerifier).slots(); + if (slotCount != ITallyVerifier(_tallyVerifier).slots() || slotCount < cfg.numOptions + 1) { + revert VerifierMismatch(); + } + if (cfg.tallyKeys.length != 2 * (cfg.numOptions + 1)) revert InvalidConfig(); + + ballotVerifier = IBallotVerifier(_ballotVerifier); + tallyVerifier = ITallyVerifier(_tallyVerifier); + circuitSlots = slotCount; + keysHash = _hashKeys(cfg.tallyKeys, slotCount); + _tallyKeys = cfg.tallyKeys; + + // The aggregate starts at the identity: the sum of no ballots. + _aggA = BabyJubJub.identity(); + for (uint256 i = 0; i <= cfg.numOptions; i++) { + _aggB.push(BabyJubJub.identity()); + } + registry = IPlatformRegistry(_registry); platformAttester = _platformAttester; organizer = _organizer; @@ -491,8 +607,7 @@ contract ElectionV4 is ERC2771Context { enrollEnd = cfg.enrollEnd; voteStart = cfg.voteStart; voteEnd = cfg.voteEnd; - scope = cfg.scope; - paillierPublicKey = cfg.paillierPublicKey; + scope = uint256(keccak256(abi.encode(block.chainid, address(this)))) >> 8; metadataJson = cfg.metadataJson; eligibilityAttester = cfg.eligibilityAttester; eligibilityPolicyHash = cfg.eligibilityPolicyHash; @@ -507,6 +622,27 @@ contract ElectionV4 is ERC2771Context { _cachedDomainSeparator = _buildDomainSeparator(); } + /** + * @dev The circuits' hash of the keys: a Poseidon chain over every point, + * padded to the circuit's size with the identity. Each key is checked to be + * on the curve and not the identity, which would encrypt its option in the + * clear. Whether the organizer knows the keys' secrets is not checked here: + * the tally proof demonstrates it, and an organizer who does not has only + * made an election they cannot count. + */ + function _hashKeys(uint256[] memory keys, uint256 slotCount) private pure returns (uint256 h) { + for (uint256 i = 0; i < slotCount; i++) { + uint256 x = 0; + uint256 y = 1; + if (2 * i < keys.length) { + x = keys[2 * i]; + y = keys[2 * i + 1]; + if (!BabyJubJub.isOnCurve(x, y) || x == 0) revert InvalidConfig(); + } + h = PoseidonT4.hash([h, x, y]); + } + } + /// @dev Rebuilt when the chain id moved under us, so attestations signed for /// this election cannot be replayed on a fork of it. function _domainSeparator() internal view returns (bytes32) { @@ -565,6 +701,35 @@ contract ElectionV4 is ERC2771Context { return _tally; } + /// @notice The tally keys voters encrypt with, flattened affine points. + function tallyKeys() external view returns (uint256[] memory) { + return _tallyKeys; + } + + function ballotsRoot() external view returns (uint256) { + return ballotsTree._root(); + } + + function ballotsDepth() external view returns (uint256) { + return ballotsTree.depth; + } + + function currentEpoch() public view returns (uint256) { + return block.timestamp / EPOCH_LENGTH; + } + + /** + * @notice The running sum of every ballot, as affine points: A, then one B + * per slot in use. What the tally proof decrypts. + */ + function aggregate() public view returns (uint256[2] memory a, uint256[] memory b) { + (a[0], a[1]) = BabyJubJub.toAffine(_aggA); + b = new uint256[](2 * _aggB.length); + for (uint256 i = 0; i < _aggB.length; i++) { + (b[2 * i], b[2 * i + 1]) = BabyJubJub.toAffine(_aggB[i]); + } + } + // ──────────────────────────────────────────────── // Enrollment // ──────────────────────────────────────────────── @@ -650,6 +815,7 @@ contract ElectionV4 is ERC2771Context { function enrollPrivate( uint256 identityCommitment, uint256 humanTag, + uint256 documentTag, uint256 deadline, bytes calldata platformSignature, bytes calldata eligibilitySignature @@ -658,7 +824,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 +849,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 +859,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)); } @@ -736,10 +920,11 @@ contract ElectionV4 is ERC2771Context { _requireEnrollmentOpen(); if (membersTree._has(identityCommitment)) revert AlreadyEnrolled(); if (enrolledHumans[humanKey]) revert AlreadyEnrolled(); + if (membersTree.size >= 1 << MAX_TREE_DEPTH) revert TreeFull(); uint256 index = membersTree.size; + rootTimestamps[membersTree._root()] = block.timestamp; uint256 newRoot = membersTree._insert(identityCommitment); - rootTimestamps[newRoot] = block.timestamp; enrolledHumans[humanKey] = true; emit MemberEnrolled(identityCommitment, index, newRoot); @@ -749,56 +934,157 @@ contract ElectionV4 is ERC2771Context { // Voting // ──────────────────────────────────────────────── - /// @notice Cast (or re-cast) an encrypted vote with a Semaphore membership proof. - /// @dev The ZK message binds the ciphertext to the voter's current nonce, so a - /// coerced vote can always be silently replaced by a later one. - /// @param voteCiphertext Paillier ciphertext of the encoded ballot (arbitrary length). - function castVote( - bytes calldata voteCiphertext, - uint256 nullifier, - uint256 merkleRoot, - uint256 merkleDepth, - uint256[2] calldata _pA, - uint256[2][2] calldata _pB, - uint256[2] calldata _pC - ) external notDecided { - if (block.timestamp < voteStart || block.timestamp > voteEnd) revert VotingNotOpen(); - 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) { - revert UnknownOrExpiredRoot(); - } + /// @notice The public half of a ballot. Points are affine; each B list is + /// flattened and holds one point per circuit slot. + struct BallotInput { + uint256 votersRoot; + uint256 ballotsRoot; + uint256 epoch; + uint256 tag; + uint256 epochTag; + uint256 leaf; + uint256[2] voteA; + uint256[] voteB; + uint256[2] cancelA; + uint256[] cancelB; + } + + /// @notice A Groth16 proof. + struct Proof { + uint256[2] a; + uint256[2][2] b; + uint256[2] c; + } + + /** + * @notice Cast a ballot: a first vote or a re-vote, and nobody can tell which. + * + * The proof says the voter is on the roll, the vote is exactly one option, + * the cancellation undoes this voter's previous ballot (or nothing), and + * the tags are this voter's for this step and this epoch. Everything else + * follows: a tag cast twice would fork a chain, so it is refused; an epoch + * tag cast twice would let one voter spend the organizer's gas without + * bound, so it is refused too. + * + * @dev Callable by anyone. The proof is the authorisation, so the voter can + * pay their own gas or hand the ballot to the relay. + */ + function castVote(BallotInput calldata ballot, Proof calldata proof) external notDecided { + // `voteEnd` is exclusive, matching `phase()`: the second it reads + // TALLYING, no ballot lands. + if (block.timestamp < voteStart || block.timestamp >= voteEnd) revert VotingNotOpen(); + if (ballot.voteB.length != 2 * circuitSlots || ballot.cancelB.length != 2 * circuitSlots) { + revert InvalidProof(); } - uint256 currentNonce = nullifierNonces[nullifier]; + // The epoch the proof was built for: this one, or the one that just + // ended, so a proof made at the boundary is not wasted. + uint256 epochNow = currentEpoch(); + if (ballot.epoch != epochNow && ballot.epoch + 1 != epochNow) revert WrongEpoch(); + + _requireKnownRoot(ballot.votersRoot, membersTree._root(), rootTimestamps); + _requireKnownRoot(ballot.ballotsRoot, ballotsTree._root(), ballotRootTimestamps); - // The ZK message is the ciphertext+nonce digest. The prover passes the same - // value as the Semaphore `message` input when generating the proof. - uint256 message = uint256(keccak256(abi.encodePacked(voteCiphertext, currentNonce))); + if (usedTags[ballot.tag]) revert TagAlreadyCast(); + if (usedEpochTags[ballot.epochTag]) revert EpochAlreadyCast(); - // Semaphore V4 public signals: [merkleTreeRoot, nullifier, hash(message), hash(scope)] - // (hash-to-field exactly as Semaphore.sol does it). - uint256[4] memory pubSignals = [merkleRoot, nullifier, _hashToField(message), _hashToField(scope)]; - if (!verifier.verifyProof(_pA, _pB, _pC, pubSignals, merkleDepth)) revert InvalidProof(); + if (!ballotVerifier.verifyBallot(proof.a, proof.b, proof.c, _ballotSignals(ballot))) { + revert InvalidProof(); + } - emit VoteCast(nullifier, voteCiphertext, currentNonce, block.timestamp); + usedTags[ballot.tag] = true; + usedEpochTags[ballot.epochTag] = true; - // Nonce zero is this nullifier's first ballot, so this is where a - // PERSON joins the count. Every later ballot from them replaces it. - if (currentNonce == 0) distinctVoters += 1; + if (ballotsTree.size >= 1 << MAX_TREE_DEPTH) revert TreeFull(); + uint256 index = ballotsTree.size; + ballotRootTimestamps[ballotsTree._root()] = block.timestamp; + ballotsTree._insert(ballot.leaf); - nullifierNonces[nullifier] = currentNonce + 1; + _accumulate(ballot); voteCount += 1; + + emit BallotCast( + ballot.tag, + index, + ballot.leaf, + ballot.voteA, + ballot.voteB, + ballot.cancelA, + ballot.cancelB, + block.timestamp + ); + } + + /// @dev A proof must be built against the current root, or one superseded + /// less than MERKLE_ROOT_VALIDITY ago. + function _requireKnownRoot( + uint256 root, + uint256 current, + mapping(uint256 => uint256) storage supersededAt + ) private view { + if (root == current) return; + uint256 since = supersededAt[root]; + if (since == 0 || block.timestamp > since + MERKLE_ROOT_VALIDITY) { + revert UnknownOrExpiredRoot(); + } + } + + /// @dev The ballot circuit's public signals, in its order: outputs first + /// (tag, epochTag, leaf), then the public inputs as declared. + function _ballotSignals(BallotInput calldata ballot) private view returns (uint256[] memory pub) { + uint256 n = circuitSlots; + pub = new uint256[](13 + 4 * n); + pub[0] = ballot.tag; + pub[1] = ballot.epochTag; + pub[2] = ballot.leaf; + pub[3] = ballot.votersRoot; + pub[4] = ballot.ballotsRoot; + pub[5] = scope; + pub[6] = keysHash; + pub[7] = numOptions + 1; + pub[8] = ballot.epoch; + pub[9] = ballot.voteA[0]; + pub[10] = ballot.voteA[1]; + uint256 pos = 11; + for (uint256 i = 0; i < 2 * n; i++) pub[pos + i] = ballot.voteB[i]; + pos += 2 * n; + pub[pos] = ballot.cancelA[0]; + pub[pos + 1] = ballot.cancelA[1]; + pos += 2; + for (uint256 i = 0; i < 2 * n; i++) pub[pos + i] = ballot.cancelB[i]; + } + + /** + * @dev Adds the ballot, vote and cancellation, into the aggregate. The + * points are on the curve because the proof computed them; only the slots + * in use are kept, the rest being the identity by construction. + */ + function _accumulate(BallotInput calldata ballot) private { + _aggA = BabyJubJub.add( + _aggA, + BabyJubJub.add( + _lift(ballot.voteA[0], ballot.voteA[1]), + _lift(ballot.cancelA[0], ballot.cancelA[1]) + ) + ); + uint256 used = _aggB.length; + for (uint256 i = 0; i < used; i++) { + _aggB[i] = BabyJubJub.add( + _aggB[i], + BabyJubJub.add( + _lift(ballot.voteB[2 * i], ballot.voteB[2 * i + 1]), + _lift(ballot.cancelB[2 * i], ballot.cancelB[2 * i + 1]) + ) + ); + } } - /// @dev Semaphore's hash-to-field: keccak256 truncated to fit the SNARK scalar field. - /// Mirrors `Semaphore.sol#_hash` so proofs generated with the official JS libraries verify. - function _hashToField(uint256 value) private pure returns (uint256) { - return uint256(keccak256(abi.encodePacked(value))) >> 8; + /// @dev An affine point the proof vouches for, in extended coordinates. + function _lift(uint256 x, uint256 y) private pure returns (uint256[4] memory p) { + p[0] = x; + p[1] = y; + p[2] = 1; + p[3] = mulmod(x, y, BabyJubJub.Q); } // ──────────────────────────────────────────────── @@ -808,9 +1094,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 +1162,86 @@ 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. + * + * Open without a proof when fewer ballots were cast than the privacy quorum + * asks for voters: each voter casts at least one, so no publishable result + * can exist. Otherwise 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. Such an election + * that turns out to be below its quorum proves it with `voidBelowQuorum`. + */ function markVoided() external onlyOrganizer notDecided { - if (block.timestamp <= voteEnd) revert VotingNotEnded(); + if (block.timestamp < voteEnd) revert VotingNotEnded(); + if (!cancellable && voteCount >= 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 Void the election with a proof that fewer voters than the privacy + * quorum took part. Terminal. The proof reveals how many voted and nothing + * about how: an election too small to publish ends without its handful of + * ballots ever being opened. + * + * @dev Callable by anyone holding the proof; only the organizer can make + * one, since it needs the tally keys' secrets. + */ + function voidBelowQuorum(uint256 votersBelow, Proof calldata proof) external notDecided { + if (block.timestamp < voteEnd) revert VotingNotEnded(); + uint256[] memory zeros = new uint256[](circuitSlots); + if (!tallyVerifier.verifyTally(proof.a, proof.b, proof.c, _tallySignals(zeros, votersBelow, false))) { + revert InvalidProof(); + } + voters = votersBelow; + voided = true; + emit ElectionVoided(msg.sender); + emit VoidedBelowQuorum(votersBelow); + } + + /** + * @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 proof The tally circuit's proof that these counts are the + * decryption of `aggregate()` under the election's keys. + * + * CHECKED HERE, not by the reader. The aggregate encrypts each voter's + * last vote, so its decryption is the result, and the proof makes that a + * condition of publishing rather than something every reader must redo. + * The counts also say how many voted, which is what the privacy quorum is + * measured against. + */ function publishResults( string calldata ipfsCid, - uint256[] calldata tallyResults + uint256[] calldata tallyResults, + Proof calldata proof ) external onlyOrganizer notDecided { - if (block.timestamp <= voteEnd) revert VotingNotEnded(); + if (block.timestamp < voteEnd) revert VotingNotEnded(); if (tallyResults.length != numOptions + 1) revert InvalidTally(); - // Below the quorum there is no publishable result, only `markVoided`. + uint256 counted = 0; + uint256[] memory counts = new uint256[](circuitSlots); + for (uint256 i = 0; i < tallyResults.length; i++) { + counts[i] = tallyResults[i]; + counted += tallyResults[i]; + } + + // Below the quorum there is no publishable result, only voiding. // 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(); + if (counted < 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. + if (!tallyVerifier.verifyTally(proof.a, proof.b, proof.c, _tallySignals(counts, counted, true))) { + revert InvalidProof(); + } (Outcome computed, uint256 winIdx) = _computeOutcome(tallyResults); @@ -934,11 +1249,45 @@ contract ElectionV4 is ERC2771Context { _tally = tallyResults; outcome = computed; winnerIndex = winIdx; + voters = counted; resultsPublished = true; emit ResultsPublished(ipfsCid, tallyResults, computed, winIdx); } + /** + * @dev The tally circuit's public signals, in its order: outputs (counts, + * voters), then keysHash, slots, the aggregate padded to the circuit's + * size with the identity, the quorum and the mode. + */ + function _tallySignals( + uint256[] memory counts, + uint256 voterCount, + bool publish + ) private view returns (uint256[] memory pub) { + uint256 n = circuitSlots; + (uint256[2] memory a, uint256[] memory b) = aggregate(); + pub = new uint256[](3 * n + 7); + for (uint256 i = 0; i < n; i++) pub[i] = counts[i]; + pub[n] = voterCount; + pub[n + 1] = keysHash; + pub[n + 2] = numOptions + 1; + pub[n + 3] = a[0]; + pub[n + 4] = a[1]; + uint256 pos = n + 5; + for (uint256 i = 0; i < n; i++) { + if (2 * i < b.length) { + pub[pos + 2 * i] = b[2 * i]; + pub[pos + 2 * i + 1] = b[2 * i + 1]; + } else { + pub[pos + 2 * i + 1] = 1; // the identity, (0, 1) + } + } + pos += 2 * n; + pub[pos] = privacyQuorum; + pub[pos + 1] = publish ? 1 : 0; + } + // ──────────────────────────────────────────────── // Internal // ──────────────────────────────────────────────── diff --git a/contracts/contracts/IBallotVerifier.sol b/contracts/contracts/IBallotVerifier.sol new file mode 100644 index 0000000..e977ae2 --- /dev/null +++ b/contracts/contracts/IBallotVerifier.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.37; + +/// @title IBallotVerifier +/// @notice The Groth16 verifier of the ballot circuit for one number of slots +/// (options plus the blank vote). Generated per size by circuits/scripts/build.mjs. +interface IBallotVerifier { + /// @notice How many slots the circuit this verifier checks was compiled for. + function slots() external view returns (uint256); + + /// @notice Verifies one ballot proof against its public signals, in circuit order: + /// tag, epochTag, leaf, votersRoot, ballotsRoot, scope, keysHash, slots, epoch, + /// voteA, voteB, cancelA, cancelB. + function verifyBallot( + uint256[2] calldata a, + uint256[2][2] calldata b, + uint256[2] calldata c, + uint256[] calldata pub + ) external view returns (bool); +} diff --git a/contracts/contracts/ITallyVerifier.sol b/contracts/contracts/ITallyVerifier.sol new file mode 100644 index 0000000..fc0b792 --- /dev/null +++ b/contracts/contracts/ITallyVerifier.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.37; + +/// @title ITallyVerifier +/// @notice The Groth16 verifier of the tally circuit for one number of slots. +/// Generated per size by circuits/scripts/build.mjs. +interface ITallyVerifier { + /// @notice How many slots the circuit this verifier checks was compiled for. + function slots() external view returns (uint256); + + /// @notice Verifies a tally proof against its public signals, in circuit order: + /// counts[slots], voters, keysHash, slots, aggA, aggB, quorum, publish. + function verifyTally( + uint256[2] calldata a, + uint256[2][2] calldata b, + uint256[2] calldata c, + uint256[] calldata pub + ) external view returns (bool); +} 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/MockVerifier.sol b/contracts/contracts/mocks/MockVerifier.sol index dc39366..0f56dcc 100644 --- a/contracts/contracts/mocks/MockVerifier.sol +++ b/contracts/contracts/mocks/MockVerifier.sol @@ -1,19 +1,54 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.37; -import "@semaphore-protocol/contracts/interfaces/ISemaphoreVerifier.sol"; - -contract MockVerifier is ISemaphoreVerifier { - /// @dev Simulates the verification of a ZK Groth16 proof as in Semaphore V4 - function verifyProof( - uint256[2] calldata /* _pA */, - uint256[2][2] calldata /* _pB */, - uint256[2] calldata /* _pC */, - uint256[4] calldata /* _pubSignals */, - uint256 /* merkleTreeDepth */ - ) external pure override returns (bool) { - // In production, real cryptographic math on the elliptic curve would be done here. - // As a mock, we simply assume any well-formed proof is valid. - return true; +import {IBallotVerifier} from "../IBallotVerifier.sol"; +import {ITallyVerifier} from "../ITallyVerifier.sol"; + +/// @notice TEST AND LOCAL ONLY. Stands in for the generated ballot verifier of +/// one size: it checks the number of public signals, as the real one does, and +/// accepts any proof unless told to refuse. Lets the contract tests exercise +/// everything around a proof without proving; the E2E suite proves for real. +contract MockBallotVerifier is IBallotVerifier { + uint256 public immutable slots; + bool public accept = true; + + constructor(uint256 slots_) { + slots = slots_; + } + + function setAccept(bool accept_) external { + accept = accept_; + } + + function verifyBallot( + uint256[2] calldata, + uint256[2][2] calldata, + uint256[2] calldata, + uint256[] calldata pub + ) external view returns (bool) { + return accept && pub.length == 13 + 4 * slots; + } +} + +/// @notice TEST AND LOCAL ONLY. The tally counterpart of MockBallotVerifier. +contract MockTallyVerifier is ITallyVerifier { + uint256 public immutable slots; + bool public accept = true; + + constructor(uint256 slots_) { + slots = slots_; + } + + function setAccept(bool accept_) external { + accept = accept_; + } + + function verifyTally( + uint256[2] calldata, + uint256[2][2] calldata, + uint256[2] calldata, + uint256[] calldata pub + ) external view returns (bool) { + return accept && pub.length == 3 * slots + 7; } } diff --git a/contracts/contracts/vendor/SemaphoreVerifierVendor.sol b/contracts/contracts/vendor/SemaphoreVerifierVendor.sol deleted file mode 100644 index a158a90..0000000 --- a/contracts/contracts/vendor/SemaphoreVerifierVendor.sol +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -pragma solidity ^0.8.37; - -import {SemaphoreVerifier} from "@semaphore-protocol/contracts/base/SemaphoreVerifier.sol"; - -/// @title SemaphoreVerifierV4 -/// @notice Thin wrapper over the official Semaphore V4 Groth16 verifier so Hardhat -/// produces a deployable artifact. This is the production verifier used on Amoy; -/// MockVerifier is for unit tests only. -contract SemaphoreVerifierV4 is SemaphoreVerifier {} diff --git a/contracts/package-lock.json b/contracts/package-lock.json index 61d8d44..a576305 100644 --- a/contracts/package-lock.json +++ b/contracts/package-lock.json @@ -10,19 +10,25 @@ "license": "AGPL-3.0-only", "dependencies": { "@openzeppelin/contracts": "^5.6.1", - "@semaphore-protocol/contracts": "^4.14.3", - "dotenv": "^18.0.2" + "@zk-kit/lean-imt.sol": "^2.0.1", + "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", - "@semaphore-protocol/proof": "^4.14.3", "@types/mocha": "^10.0.10", - "@types/node": "^26.6.2", - "hardhat": "^3.17.0", - "paillier-bigint": "^3.4.3", + "@types/node": "^26.6.3", + "@zk-kit/lean-imt": "^2.2.5", + "hardhat": "^3.18.0", + "poseidon-lite": "^0.3.0", + "snarkjs": "^0.7.6", "typescript": "^7.0.2" + }, + "engines": { + "node": ">=24", + "npm": ">=11" } }, "node_modules/@adraffy/ens-normalize": { @@ -30,17 +36,8 @@ "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", "dev": true, - "license": "MIT" - }, - "node_modules/@commander-js/extra-typings": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-12.1.0.tgz", - "integrity": "sha512-wf/lwQvWAA0goIghcb91dQYpkLBcyhOhQNqG/VgWhnKzgt+UOMvra7EX/2fv70arm5RW+PUHoQHHDa6/p77Eqg==", - "dev": true, "license": "MIT", - "peerDependencies": { - "commander": "~12.1.0" - } + "peer": true }, "node_modules/@cto.af/wtf8": { "version": "0.0.5", @@ -930,208 +927,6 @@ "dev": true, "license": "GPL-3.0" }, - "node_modules/@iden3/binfileutils": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@iden3/binfileutils/-/binfileutils-0.0.11.tgz", - "integrity": "sha512-LylnJoZ0CTdgErnKY8OxohvW4K+p6UHD3sxt+3P9AmMyBQjYR4IpoqoYZZ+9aMj89cmCQ21UvdhndAx04er3NA==", - "dev": true, - "license": "GPL-3.0", - "dependencies": { - "fastfile": "0.0.20", - "ffjavascript": "^0.2.48" - } - }, - "node_modules/@inquirer/core": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-9.2.1.tgz", - "integrity": "sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/figures": "^1.0.6", - "@inquirer/type": "^2.0.0", - "@types/mute-stream": "^0.0.4", - "@types/node": "^22.5.5", - "@types/wrap-ansi": "^3.0.0", - "ansi-escapes": "^4.3.2", - "cli-width": "^4.1.0", - "mute-stream": "^1.0.0", - "signal-exit": "^4.1.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/core/node_modules/@inquirer/type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-2.0.0.tgz", - "integrity": "sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==", - "dev": true, - "license": "MIT", - "dependencies": { - "mute-stream": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/core/node_modules/@types/node": { - "version": "22.20.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", - "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@inquirer/core/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/input": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-2.3.0.tgz", - "integrity": "sha512-XfnpCStx2xgh1LIRqPXrTNEEByqQWoxsWYzNRSEUxJ5c6EQlhMogJ3vHKu8aXuTacebtaZzMAHwEL0kAflKOBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/type": "^1.5.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/select": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-2.5.0.tgz", - "integrity": "sha512-YmDobTItPP3WcEI86GvPo+T2sRHkxxOq/kXmsBjHS5BVXUgvgZ5AfJjkvQvZr03T81NnI3KrrRuMzeuYUQRFOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/figures": "^1.0.5", - "@inquirer/type": "^1.5.3", - "ansi-escapes": "^4.3.2", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/type": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.5.tgz", - "integrity": "sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mute-stream": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "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", @@ -1180,7 +975,6 @@ "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -1189,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" ], @@ -1225,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" ], @@ -1242,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" ], @@ -1262,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" ], @@ -1282,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" ], @@ -1302,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" ], @@ -1322,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" ], @@ -1339,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": { @@ -1446,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": { @@ -1480,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": { @@ -1491,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", @@ -1499,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": { @@ -1524,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": { @@ -1806,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", @@ -1888,15 +1670,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@semaphore-protocol/contracts": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@semaphore-protocol/contracts/-/contracts-4.14.3.tgz", - "integrity": "sha512-N6UdFINfvDbpsIRSGuvOXZbKczDTrJATQQN5ToFI+oAVAO8s9TuBrFxDIsATnGnP8q/Dz/j7ZBEE6ypAU5Zebw==", - "license": "MIT", - "dependencies": { - "@zk-kit/lean-imt.sol": "2.0.1" - } - }, "node_modules/@semaphore-protocol/group": { "version": "4.14.3", "resolved": "https://registry.npmjs.org/@semaphore-protocol/group/-/group-4.14.3.tgz", @@ -1922,135 +1695,6 @@ "poseidon-lite": "0.3.0" } }, - "node_modules/@semaphore-protocol/proof": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@semaphore-protocol/proof/-/proof-4.14.3.tgz", - "integrity": "sha512-Zu7vIA161+jieo1DzhKoGB/UgTnG6cGMJtAN1/O6o4yKXKBkq7ly7JC3RZ0SbayfdN4TpTJyfO27i1USQ51eyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@semaphore-protocol/utils": "4.14.3", - "@zk-kit/artifacts": "2.0.1", - "@zk-kit/utils": "1.3.0", - "ethers": "6.13.4", - "snarkjs": "0.7.5" - }, - "peerDependencies": { - "@semaphore-protocol/group": "4.14.3", - "@semaphore-protocol/identity": "4.14.3" - } - }, - "node_modules/@semaphore-protocol/proof/node_modules/@adraffy/ens-normalize": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", - "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@semaphore-protocol/proof/node_modules/@noble/curves": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", - "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.3.2" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@semaphore-protocol/proof/node_modules/@noble/hashes": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", - "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@semaphore-protocol/proof/node_modules/@types/node": { - "version": "22.7.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", - "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.19.2" - } - }, - "node_modules/@semaphore-protocol/proof/node_modules/ethers": { - "version": "6.13.4", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.13.4.tgz", - "integrity": "sha512-21YtnZVg4/zKkCQPjrDj38B1r4nQvTZLopUGMLQ1ePU2zV/joCfDC3t3iKQjWRzjjjbzR+mdAIoikeBRNkdllA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/ethers-io/" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@adraffy/ens-normalize": "1.10.1", - "@noble/curves": "1.2.0", - "@noble/hashes": "1.3.2", - "@types/node": "22.7.5", - "aes-js": "4.0.0-beta.5", - "tslib": "2.7.0", - "ws": "8.17.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@semaphore-protocol/proof/node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@semaphore-protocol/proof/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/@semaphore-protocol/utils": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@semaphore-protocol/utils/-/utils-4.14.3.tgz", - "integrity": "sha512-22oW6hyw2y1m5JKFM4rop7b342M+PV/VC4dmaByXZ3nTGWwDe4SDcgujycIoR2EQq3eVc8C0rfeMeUhzQHidxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ethers": "^6.13.4" - } - }, "node_modules/@sentry/core": { "version": "9.47.1", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.47.1.tgz", @@ -2133,20 +1777,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mute-stream": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", - "integrity": "sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "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": { @@ -2161,21 +1795,6 @@ "license": "MIT", "peer": true }, - "node_modules/@types/snarkjs": { - "version": "0.7.9", - "resolved": "https://registry.npmjs.org/@types/snarkjs/-/snarkjs-0.7.9.tgz", - "integrity": "sha512-pb4Bq3GI2YQOQOG0dR/YuQs/mqcuL6k/vnz68LIPtpA2frrUL3twf69a3AUK9eUmNNeW0RIKkq6scDlC75Is+g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/wrap-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", - "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==", - "dev": true, - "license": "MIT" - }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -2516,24 +2135,6 @@ "node": ">=16.20.0" } }, - "node_modules/@zk-kit/artifacts": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@zk-kit/artifacts/-/artifacts-2.0.1.tgz", - "integrity": "sha512-RvEOFkU5LAKqEre/o94pRcjYMyqHxs6TpLDE6wLLsfUWgcxabaWsZ7/UqLikR/ueQjspMDGuJYi3n9FlK/K9Kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commander-js/extra-typings": "^12.1.0", - "@inquirer/input": "^2.1.8", - "@inquirer/select": "^2.3.4", - "circomkit": "^0.2.1", - "ora": "^5.4.1", - "snarkjs": "^0.7.4" - }, - "bin": { - "snarkli": "dist/cli/index.js" - } - }, "node_modules/@zk-kit/baby-jubjub": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@zk-kit/baby-jubjub/-/baby-jubjub-1.0.3.tgz", @@ -2745,22 +2346,6 @@ "node": ">=6" } }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2777,6 +2362,7 @@ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2824,37 +2410,6 @@ "dev": true, "license": "MIT" }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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", @@ -2900,64 +2455,6 @@ "node": ">= 8.0.0" } }, - "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "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", @@ -3024,70 +2521,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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", @@ -3133,6 +2566,7 @@ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -3150,6 +2584,7 @@ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -3175,13 +2610,6 @@ "dev": true, "license": "MIT" }, - "node_modules/child_process": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz", - "integrity": "sha512-Wmza/JzL0SiWz7kl6MhIKT5ceIlnFPJX+lwUGj7Clhy5MMldsSoJR0+uvRzOS5Kv45Mq7t1PoE8TsOA9bzvb6g==", - "dev": true, - "license": "ISC" - }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -3223,396 +2651,142 @@ "web-worker": "1.2.0" } }, - "node_modules/circom_tester": { - "version": "0.0.19", - "resolved": "https://registry.npmjs.org/circom_tester/-/circom_tester-0.0.19.tgz", - "integrity": "sha512-SNHaBsGxcBH6XsVWfsRbRPA7NF8m8AMKJI9dtJJCFGUtOTT2+zsoIqAwi50z6XCnO4TtjyXq7AeXa1PLHqT0tw==", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "license": "GPL-3.0", + "license": "MIT", + "peer": true, "dependencies": { - "chai": "^4.3.6", - "child_process": "^1.0.2", - "ffjavascript": "^0.2.56", - "fnv-plus": "^1.3.1", - "r1csfile": "^0.0.41", - "snarkjs": "0.5.0", - "tmp-promise": "^3.0.3", - "util": "^0.12.4" + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" } }, - "node_modules/circom_tester/node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT", - "engines": { - "node": "*" - } + "peer": true }, - "node_modules/circom_tester/node_modules/chai": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", - "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "node_modules/command-line-args": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", + "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.1.0" + "array-back": "^3.1.0", + "find-replace": "^3.0.0", + "lodash.camelcase": "^4.3.0", + "typical": "^4.0.0" }, "engines": { - "node": ">=4" + "node": ">=4.0.0" } }, - "node_modules/circom_tester/node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "node_modules/command-line-usage": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-6.1.3.tgz", + "integrity": "sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "get-func-name": "^2.0.2" + "array-back": "^4.0.2", + "chalk": "^2.4.2", + "table-layout": "^1.0.2", + "typical": "^5.2.0" }, "engines": { - "node": "*" - } - }, - "node_modules/circom_tester/node_modules/circom_runtime": { - "version": "0.1.21", - "resolved": "https://registry.npmjs.org/circom_runtime/-/circom_runtime-0.1.21.tgz", - "integrity": "sha512-qTkud630B/GK8y76hnOaaS1aNuF6prfV0dTrkeRsiJKnlP1ryQbP2FWLgDOPqn6aKyaPlam+Z+DTbBhkEzh8dA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "ffjavascript": "0.2.56" - }, - "bin": { - "calcwit": "calcwit.js" + "node": ">=8.0.0" } }, - "node_modules/circom_tester/node_modules/deep-eql": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", - "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "node_modules/command-line-usage/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "type-detect": "^4.0.0" + "color-convert": "^1.9.0" }, "engines": { - "node": ">=6" + "node": ">=4" } }, - "node_modules/circom_tester/node_modules/ffjavascript": { - "version": "0.2.56", - "resolved": "https://registry.npmjs.org/ffjavascript/-/ffjavascript-0.2.56.tgz", - "integrity": "sha512-em6G5Lrj7ucIqj4TYEgyoHs/j99Urwwqa4+YxEVY2hggnpRimVj+noX5pZQTxI1pvtiekZI4rG65JBf0xraXrg==", + "node_modules/command-line-usage/node_modules/array-back": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-4.0.2.tgz", + "integrity": "sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==", "dev": true, - "license": "GPL-3.0", - "dependencies": { - "wasmbuilder": "0.0.16", - "wasmcurves": "0.2.0", - "web-worker": "^1.2.0" + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" } }, - "node_modules/circom_tester/node_modules/snarkjs": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/snarkjs/-/snarkjs-0.5.0.tgz", - "integrity": "sha512-KWz8mZ2Y+6wvn6GGkQo6/ZlKwETdAGohd40Lzpwp5TUZCn6N6O4Az1SuX1rw/qREGL6Im+ycb19suCFE8/xaKA==", + "node_modules/command-line-usage/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, - "license": "GPL-3.0", + "license": "MIT", + "peer": true, "dependencies": { - "@iden3/binfileutils": "0.0.11", - "bfj": "^7.0.2", - "blake2b-wasm": "^2.4.0", - "circom_runtime": "0.1.21", - "ejs": "^3.1.6", - "fastfile": "0.0.20", - "ffjavascript": "0.2.56", - "js-sha3": "^0.8.0", - "logplease": "^1.2.15", - "r1csfile": "0.0.41" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" }, - "bin": { - "snarkjs": "build/cli.cjs" + "engines": { + "node": ">=4" } }, - "node_modules/circom_tester/node_modules/wasmcurves": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/wasmcurves/-/wasmcurves-0.2.0.tgz", - "integrity": "sha512-3e2rbxdujOwaod657gxgmdhZNn+i1qKdHO3Y/bK+8E7bV8ttV/fu5FO4/WLBACF375cK0QDLOP+65Na63qYuWA==", + "node_modules/command-line-usage/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, - "license": "GPL-3.0", + "license": "MIT", + "peer": true, "dependencies": { - "wasmbuilder": "0.0.16" + "color-name": "1.1.3" } }, - "node_modules/circomkit": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/circomkit/-/circomkit-0.2.1.tgz", - "integrity": "sha512-7O8QsOLUq2QvwGMimvWxwdg7OgV33OT7ZBND+81dv3JrVp8ove93yV16jF3TW6XBncSY92/Aka8F4CAi/H9VQw==", + "node_modules/command-line-usage/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true, "license": "MIT", - "dependencies": { - "circom_tester": "^0.0.19", - "commander": "^12.0.0", - "loglevel": "^1.8.1" - }, - "bin": { - "circomkit": "dist/cli.js" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "@types/snarkjs": "^0.7.x", - "snarkjs": "^0.7.x" - } + "peer": true }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "node_modules/command-line-usage/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, + "peer": true, "engines": { - "node": ">=8" + "node": ">=0.8.0" } }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "node_modules/command-line-usage/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "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/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/command-line-args": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", - "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "array-back": "^3.1.0", - "find-replace": "^3.0.0", - "lodash.camelcase": "^4.3.0", - "typical": "^4.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/command-line-usage": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-6.1.3.tgz", - "integrity": "sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "array-back": "^4.0.2", - "chalk": "^2.4.2", - "table-layout": "^1.0.2", - "typical": "^5.2.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/command-line-usage/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/command-line-usage/node_modules/array-back": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/array-back/-/array-back-4.0.2.tgz", - "integrity": "sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/command-line-usage/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/command-line-usage/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/command-line-usage/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/command-line-usage/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/command-line-usage/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" + "node": ">=4" } }, "node_modules/command-line-usage/node_modules/supports-color": { @@ -3640,16 +2814,6 @@ "node": ">=8" } }, - "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3658,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", @@ -3693,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", @@ -3729,41 +2863,10 @@ "node": ">=4.0.0" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "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, @@ -3772,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" @@ -3786,29 +2889,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "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", @@ -3850,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", @@ -3888,39 +2960,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -3963,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", @@ -4101,6 +3115,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@adraffy/ens-normalize": "1.11.1", "@noble/curves": "1.2.0", @@ -4120,6 +3135,7 @@ "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@noble/hashes": "1.3.2" }, @@ -4133,6 +3149,7 @@ "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 16" }, @@ -4146,6 +3163,7 @@ "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.19.2" } @@ -4155,7 +3173,8 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fast-equals": { "version": "6.0.3", @@ -4174,18 +3193,6 @@ "dev": true, "license": "GPL-3.0" }, - "node_modules/ffjavascript": { - "version": "0.2.63", - "resolved": "https://registry.npmjs.org/ffjavascript/-/ffjavascript-0.2.63.tgz", - "integrity": "sha512-dBgdsfGks58b66JnUZeZpGxdMIDQ4QsD3VYlRJyFVrKQHb2kJy4R2gufx5oetrTxXPT+aEjg0dOvOLg1N0on4A==", - "dev": true, - "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", @@ -4204,93 +3211,37 @@ "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/find-replace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", - "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "array-back": "^3.0.1" - }, - "engines": { - "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==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "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" + }, + "engines": { + "node": ">=10" } }, - "node_modules/fnv-plus": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/fnv-plus/-/fnv-plus-1.3.1.tgz", - "integrity": "sha512-Gz1EvfOneuFfk4yG458dJ3TLJ7gV19q3OM/vVvvHf7eT02Hm1DleB4edsia6ahbKgAYxO9gvyQ1ioWZR+a00Yw==", - "dev": true, - "license": "MIT" - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "node_modules/find-replace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", + "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "is-callable": "^1.2.7" + "array-back": "^3.0.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=4.0.0" } }, - "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==", + "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": "ISC", + "license": "MIT", "peer": true, - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, "engines": { - "node": ">=14" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/fs-extra": { @@ -4332,120 +3283,23 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "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/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "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" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", "engines": { - "node": ">= 0.4" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/graceful-fs": { @@ -4457,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", @@ -4613,52 +3467,11 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", @@ -4671,30 +3484,6 @@ "minimalistic-assert": "^1.0.1" } }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "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", @@ -4769,117 +3558,21 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true, - "license": "ISC" - }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } + "license": "ISC", + "peer": true }, "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_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-safe-filename": { @@ -4895,28 +3588,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -4924,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", @@ -4972,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": [ { @@ -4995,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": { @@ -5064,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", @@ -5100,41 +3737,10 @@ "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "dev": true, - "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", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/loglevel": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", - "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6.0" - }, - "funding": { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/loglevel" - } + "peer": true }, "node_modules/logplease": { "version": "1.2.15", @@ -5143,32 +3749,15 @@ "dev": true, "license": "MIT" }, - "node_modules/loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.1" - } - }, "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==", - "dev": true, - "license": "ISC", - "peer": true - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", + "peer": true, "engines": { - "node": ">= 0.4" + "node": "20 || >=22" } }, "node_modules/micro-eth-signer": { @@ -5255,16 +3844,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -5282,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", @@ -5335,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": { @@ -5412,23 +3978,6 @@ "license": "MIT", "peer": true }, - "node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "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", @@ -5461,80 +4010,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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", @@ -5548,38 +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/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==", - "dev": true, - "license": "MIT", - "dependencies": { - "bigint-crypto-utils": "^3.3.0" - }, - "engines": { - "node": ">=12" - } - }, - "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", @@ -5591,45 +4034,24 @@ "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" } }, - "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5650,16 +4072,6 @@ "integrity": "sha512-NzrvSwHzvZgT4hvg2GyGqeR+UOU/eLSEt4wAoXEua+VaR7NTKKwx1X9bPlh1VMBEVEno+IWvkRBbidFGzTeAqQ==", "license": "MIT" }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/prettier": { "version": "2.8.8", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", @@ -5692,58 +4104,13 @@ "node": ">= 6" } }, - "node_modules/r1csfile": { - "version": "0.0.41", - "resolved": "https://registry.npmjs.org/r1csfile/-/r1csfile-0.0.41.tgz", - "integrity": "sha512-Q1WDF3u1vYeAwjHo4YuddkA8Aq0TulbKjmGm99+Atn13Lf5fTsMZBnBV9T741w8iSyPFG6Uh6sapQby77sREqA==", - "dev": true, - "license": "GPL-3.0", - "dependencies": { - "@iden3/bigarray": "0.0.2", - "@iden3/binfileutils": "0.0.11", - "fastfile": "0.0.20", - "ffjavascript": "0.2.56" - } - }, - "node_modules/r1csfile/node_modules/ffjavascript": { - "version": "0.2.56", - "resolved": "https://registry.npmjs.org/ffjavascript/-/ffjavascript-0.2.56.tgz", - "integrity": "sha512-em6G5Lrj7ucIqj4TYEgyoHs/j99Urwwqa4+YxEVY2hggnpRimVj+noX5pZQTxI1pvtiekZI4rG65JBf0xraXrg==", - "dev": true, - "license": "GPL-3.0", - "dependencies": { - "wasmbuilder": "0.0.16", - "wasmcurves": "0.2.0", - "web-worker": "^1.2.0" - } - }, - "node_modules/r1csfile/node_modules/wasmcurves": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/wasmcurves/-/wasmcurves-0.2.0.tgz", - "integrity": "sha512-3e2rbxdujOwaod657gxgmdhZNn+i1qKdHO3Y/bK+8E7bV8ttV/fu5FO4/WLBACF375cK0QDLOP+65Na63qYuWA==", - "dev": true, - "license": "GPL-3.0", - "dependencies": { - "wasmbuilder": "0.0.16" - } - }, - "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", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -5778,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", @@ -5799,27 +4155,6 @@ "node": ">=10" } }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -5846,25 +4181,8 @@ "url": "https://feross.org/support" } ], - "license": "MIT" - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "peer": true }, "node_modules/semver": { "version": "7.8.5", @@ -5872,78 +4190,22 @@ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "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==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "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" + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=8" + "node": ">=10" } }, - "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==", + "node_modules/serialize-javascript": { + "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": "MIT", + "license": "BSD-3-Clause", "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", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=20.0.0" } }, "node_modules/sisteransi": { @@ -5955,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" }, @@ -6062,6 +4323,7 @@ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -6074,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", @@ -6162,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" @@ -6258,26 +4430,6 @@ "readable-stream": "3" } }, - "node_modules/tmp": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", - "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, - "node_modules/tmp-promise": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", - "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tmp": "^0.2.0" - } - }, "node_modules/tryer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", @@ -6339,29 +4491,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typechain": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/typechain/-/typechain-8.3.2.tgz", @@ -6518,26 +4647,13 @@ "node": ">= 4.0.0" } }, - "node_modules/util": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/wasmbuilder": { "version": "0.0.16", @@ -6556,16 +4672,6 @@ "wasmbuilder": "0.0.16" } }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, "node_modules/web-worker": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.2.0.tgz", @@ -6573,45 +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/which-typed-array": { - "version": "1.1.22", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", - "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/wordwrapjs": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-4.0.1.tgz", @@ -6639,94 +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": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "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/wrap-ansi/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" - }, - "node_modules/wrap-ansi/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", - "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", @@ -6741,6 +4727,7 @@ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -6757,116 +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/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "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 9d02869..1ae1bb4 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", @@ -26,19 +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", - "@semaphore-protocol/proof": "^4.14.3", "@types/mocha": "^10.0.10", - "@types/node": "^26.6.2", - "hardhat": "^3.17.0", - "paillier-bigint": "^3.4.3", + "@types/node": "^26.6.3", + "@zk-kit/lean-imt": "^2.2.5", + "hardhat": "^3.18.0", + "poseidon-lite": "^0.3.0", + "snarkjs": "^0.7.6", "typescript": "^7.0.2" }, "dependencies": { "@openzeppelin/contracts": "^5.6.1", - "@semaphore-protocol/contracts": "^4.14.3", - "dotenv": "^18.0.2" + "@zk-kit/lean-imt.sol": "^2.0.1", + "dotenv": "^18.0.4", + "poseidon-solidity": "^0.0.5" } } diff --git a/contracts/scripts/deploy.ts b/contracts/scripts/deploy.ts index 135b9d3..3afd477 100644 --- a/contracts/scripts/deploy.ts +++ b/contracts/scripts/deploy.ts @@ -7,26 +7,29 @@ import poseidon from "poseidon-solidity"; const conn = await network.getOrCreate(); const { ethers } = conn; -/// Ensures PoseidonT3 (external library required by LeanIMT) exists on-chain. -/// On live networks it uses the package's deterministic CREATE2 deployment so the -/// library lands at the same canonical address on every chain. On local networks -/// it deploys directly. -async function ensurePoseidonT3(deployer: any): Promise { +type PoseidonName = "PoseidonT3" | "PoseidonT4"; + +/// Ensures a Poseidon library exists on-chain: T3 for LeanIMT, T4 for the keys +/// hash ElectionV4 computes. On live networks it uses the package's +/// deterministic CREATE2 deployment so the library lands at the same canonical +/// address on every chain. On local networks it deploys directly. +async function ensurePoseidon(deployer: any, name: PoseidonName): Promise { const provider = ethers.provider; - const { PoseidonT3, proxy } = poseidon; + const library = poseidon[name]; + const { proxy } = poseidon; const chainId = (await provider.getNetwork()).chainId; const isLocal = chainId === 31337n; if (isLocal) { - const tx = await deployer.sendTransaction({ data: PoseidonT3.bytecode }); + const tx = await deployer.sendTransaction({ data: library.bytecode }); const receipt = await tx.wait(); return receipt.contractAddress; } - if ((await provider.getCode(PoseidonT3.address)) !== "0x") { - console.log("PoseidonT3 already deployed at:", PoseidonT3.address); - return PoseidonT3.address; + if ((await provider.getCode(library.address)) !== "0x") { + console.log(`${name} already deployed at:`, library.address); + return library.address; } // Fund and publish the keyless CREATE2 proxy if this chain doesn't have it yet @@ -36,9 +39,81 @@ async function ensurePoseidonT3(deployer: any): Promise { console.log("CREATE2 proxy deployed at:", proxy.address); } - await (await deployer.sendTransaction({ to: proxy.address, data: PoseidonT3.data })).wait(); - console.log("PoseidonT3 deployed at:", PoseidonT3.address); - return PoseidonT3.address; + await (await deployer.sendTransaction({ to: proxy.address, data: library.data })).wait(); + console.log(`${name} deployed at:`, library.address); + return library.address; +} + +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: CircuitManifest | null = null; + try { + manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); + } catch { + manifest = null; + } + + if (!manifest) { + if (!isLocal) throw new Error(`No circuits built (${manifestPath}): run \`npm run build\` in circuits/ first.`); + return null; + } + + if (manifest.ceremony !== "custom" && !isLocal && process.env.ALLOW_INSECURE_CEREMONY !== "1") { + throw new Error( + "The circuits were built with the public DEVELOPMENT ceremony: anyone can forge ballots against " + + "these verifiers. Rebuild with CEREMONY_ENTROPY set (and PTAU pointing at a public phase 1), or " + + "set ALLOW_INSECURE_CEREMONY=1 for a throwaway testnet deployment.", + ); + } + return manifest; +} + +/** + * The circuits' verifiers, one ballot/tally pair per size, deployed in size + * order as the factory requires. + * + * The real ones are generated by `circuits/scripts/build.mjs`, whose manifest + * says which sizes exist and which ceremony produced them. A development + * ceremony uses public entropy, so anyone can forge proofs against it: it is + * refused off the local chain unless ALLOW_INSECURE_CEREMONY=1 says a throwaway + * testnet deployment is genuinely intended. Without a build at all, the local + * chain gets mock verifiers, which accept any proof. + */ +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[] = []; + const tally: string[] = []; + for (const size of sizes) { + for (const [kind, out] of [["Ballot", ballot], ["Tally", tally]] as const) { + const verifier = await (await ethers.getContractFactory(`${kind}VerifierS${size}`)).deploy(); + await verifier.waitForDeployment(); + out.push(await verifier.getAddress()); + console.log(`${kind}VerifierS${size}:`, await verifier.getAddress()); + } + } + return { ballot, tally, kind: manifest.ceremony }; } const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; @@ -77,41 +152,9 @@ async function main() { console.log(`Deploying to ${networkName} (chainId ${chainId}) with account: ${deployer.address}`); - const poseidonAddress = await ensurePoseidonT3(deployer); - - const Registry = await ethers.getContractFactory("PlatformRegistry"); - const registry = await Registry.deploy(); - await registry.waitForDeployment(); - console.log("PlatformRegistry:", await registry.getAddress()); - - // Self-service and owned by nobody: a domain claim proves nothing on its own, - // since a reader resolves the TXT record and checks it names the organizer. - const Domains = await ethers.getContractFactory("OrganizerDomains"); - const domains = await Domains.deploy(); - await domains.waitForDeployment(); - console.log("OrganizerDomains:", await domains.getAddress()); - - const Paymaster = await ethers.getContractFactory("ElectionPaymaster"); - const paymaster = await Paymaster.deploy(); - await paymaster.waitForDeployment(); - console.log("ElectionPaymaster:", await paymaster.getAddress()); - - // Official Semaphore V4 Groth16 verifier in production; mock only for local runs. - const verifierName = - isLocal && process.env.USE_REAL_VERIFIER !== "true" ? "MockVerifier" : "SemaphoreVerifierV4"; - const Verifier = await ethers.getContractFactory(verifierName); - const verifier = await Verifier.deploy(); - 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"); - } - + // 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 = @@ -136,17 +179,48 @@ async function main() { throw new Error(complaint + " Set ALLOW_PUBLIC_ENROLMENT=1 if that is genuinely intended."); } console.warn(complaint); - } else { + } + + const libraries = { + PoseidonT3: await ensurePoseidon(deployer, "PoseidonT3"), + PoseidonT4: await ensurePoseidon(deployer, "PoseidonT4"), + }; + + const Registry = await ethers.getContractFactory("PlatformRegistry"); + const registry = await Registry.deploy(); + await registry.waitForDeployment(); + console.log("PlatformRegistry:", await registry.getAddress()); + + // Self-service and owned by nobody: a domain claim proves nothing on its own, + // since a reader resolves the TXT record and checks it names the organizer. + const Domains = await ethers.getContractFactory("OrganizerDomains"); + const domains = await Domains.deploy(); + await domains.waitForDeployment(); + console.log("OrganizerDomains:", await domains.getAddress()); + + const Paymaster = await ethers.getContractFactory("ElectionPaymaster"); + const paymaster = await Paymaster.deploy(); + await paymaster.waitForDeployment(); + console.log("ElectionPaymaster:", await paymaster.getAddress()); + + const verifiers = await deployVerifiers(circuits); + + // Holds ElectionV4's creation code, which no longer fits inside the factory. + const Deployer = await ethers.getContractFactory("ElectionDeployer", { libraries }); + const electionDeployer = await Deployer.deploy(); + await electionDeployer.waitForDeployment(); + console.log("ElectionDeployer:", await electionDeployer.getAddress()); + + if (platformAttester !== ZERO_ADDRESS) { console.log("Platform attester (private enrolment):", platformAttester); } - const Factory = await ethers.getContractFactory("ElectionFactory", { - libraries: { PoseidonT3: poseidonAddress }, - }); + const Factory = await ethers.getContractFactory("ElectionFactory"); const factory = await Factory.deploy( await paymaster.getAddress(), - forwarder, - await verifier.getAddress(), + await electionDeployer.getAddress(), + verifiers.ballot, + verifiers.tally, await registry.getAddress(), platformAttester, ); @@ -173,11 +247,14 @@ async function main() { PlatformRegistry: await registry.getAddress(), OrganizerDomains: await domains.getAddress(), ElectionPaymaster: await paymaster.getAddress(), - [verifierName]: await verifier.getAddress(), + ElectionDeployer: await electionDeployer.getAddress(), ElectionFactory: await factory.getAddress(), - PoseidonT3: poseidonAddress, + ...libraries, }, - config: { trustedForwarder: forwarder }, + // Which ceremony the verifiers come from: "custom", "INSECURE-development" or "mock". + ceremony: verifiers.kind, + ballotVerifiers: verifiers.ballot, + tallyVerifiers: verifiers.tally, }; const manifest = JSON.stringify(deployment, null, 2) + "\n"; diff --git a/contracts/scripts/e2e-eligibility.ts b/contracts/scripts/e2e-eligibility.ts index 54ede7b..13d412f 100644 --- a/contracts/scripts/e2e-eligibility.ts +++ b/contracts/scripts/e2e-eligibility.ts @@ -22,6 +22,8 @@ import { network } from "hardhat"; import fs from "node:fs"; import path from "node:path"; +import { BASE, multiply } from "../../frontend/src/lib/ballotCrypto.js"; + const { ethers } = await network.getOrCreate(); const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; @@ -113,8 +115,8 @@ async function main() { enrollEnd: now + 3600, voteStart: now + 3600, voteEnd: now + 7200, - scope: BigInt(ethers.hexlify(ethers.randomBytes(31))), - paillierPublicKey: '{"n":"0x1234","g":"0x1235"}', + // Valid points nobody holds the secrets of: this script never counts a vote. + tallyKeys: [2n, 3n, 4n].flatMap(x => [...multiply(BASE, x)]), metadataJson: JSON.stringify(metadata), eligibilityAttester: attester.address, eligibilityPolicyHash: policyHash, @@ -295,7 +297,6 @@ async function main() { const openCfg = { ...cfg, name: "Open E2E Election", - scope: BigInt(ethers.hexlify(ethers.randomBytes(31))), metadataJson: JSON.stringify({ ...metadata, eligibility: undefined }), eligibilityAttester: ZERO_ADDRESS, eligibilityPolicyHash: ethers.ZeroHash, diff --git a/contracts/scripts/estimate-deploy-cost.ts b/contracts/scripts/estimate-deploy-cost.ts index dd49af2..5fb4930 100644 --- a/contracts/scripts/estimate-deploy-cost.ts +++ b/contracts/scripts/estimate-deploy-cost.ts @@ -14,6 +14,8 @@ import { network } from "hardhat"; import poseidon from "poseidon-solidity"; +import { BASE, multiply } from "../../frontend/src/lib/ballotCrypto.js"; + const DEFAULT_RPC = "https://polygon-amoy-bor-rpc.publicnode.com"; const FALLBACK_GAS_PRICE_GWEI = 30n; @@ -102,61 +104,66 @@ async function main(): Promise { return receipt.gasUsed; }; - // 1. PoseidonT3 external library (LeanIMT dependency). - const poseidonGas = await gasOf(deployer.sendTransaction({ data: poseidon.PoseidonT3.bytecode })); - const poseidonAddress = (await ethers.provider.getTransactionReceipt( - (await ethers.provider.getBlock("latest"))!.transactions.at(-1)!, - ))!.contractAddress!; - steps.push({ - label: "PoseidonT3 library", - gas: poseidonGas, - conditional: chain.poseidonDeployed ? "already on chain, SKIPPED" : undefined, - }); + // 1. Poseidon libraries: T3 for the trees, T4 for the election's keys hash. + const libraries: Record = {}; + for (const name of ["PoseidonT3", "PoseidonT4"] as const) { + const tx = await deployer.sendTransaction({ data: poseidon[name].bytecode }); + const receipt = (await tx.wait())!; + libraries[name] = receipt.contractAddress!; + steps.push({ + label: `${name} library`, + gas: receipt.gasUsed, + conditional: chain.poseidonDeployed ? "already on chain, SKIPPED" : undefined, + }); + } - // 2. PlatformRegistry. - const Registry = await ethers.getContractFactory("PlatformRegistry"); - const registry = await Registry.deploy(); - await registry.waitForDeployment(); - steps.push({ - label: "PlatformRegistry", - gas: (await ethers.provider.getTransactionReceipt(registry.deploymentTransaction()!.hash))!.gasUsed, - }); + const deployed = async (label: string, contract: { waitForDeployment: () => Promise; deploymentTransaction: () => { hash: string } | null }) => { + await contract.waitForDeployment(); + steps.push({ label, gas: (await ethers.provider.getTransactionReceipt(contract.deploymentTransaction()!.hash))!.gasUsed }); + return contract; + }; - // 3. ElectionPaymaster. - const Paymaster = await ethers.getContractFactory("ElectionPaymaster"); - const paymaster = await Paymaster.deploy(); - await paymaster.waitForDeployment(); - steps.push({ - label: "ElectionPaymaster", - gas: (await ethers.provider.getTransactionReceipt(paymaster.deploymentTransaction()!.hash))!.gasUsed, - }); + // 2-3. PlatformRegistry and ElectionPaymaster. + const registry = (await deployed("PlatformRegistry", await (await ethers.getContractFactory("PlatformRegistry")).deploy())) as any; + const paymaster = (await deployed("ElectionPaymaster", await (await ethers.getContractFactory("ElectionPaymaster")).deploy())) as any; - // 4. Official Semaphore Groth16 verifier (production path). - const Verifier = await ethers.getContractFactory("SemaphoreVerifierV4"); - const verifier = await Verifier.deploy(); - await verifier.waitForDeployment(); - steps.push({ - label: "SemaphoreVerifierV4", - gas: (await ethers.provider.getTransactionReceipt(verifier.deploymentTransaction()!.hash))!.gasUsed, - }); + // 4. The circuits' verifiers, one ballot/tally pair per size. The generated + // ones when circuits/ has been built (their size is what matters here), the + // mocks otherwise, which UNDERSTATE this line and say so. + const ballotVerifiers: string[] = []; + const tallyVerifiers: string[] = []; + for (const size of [5, 9]) { + for (const [kind, out] of [["Ballot", ballotVerifiers], ["Tally", tallyVerifiers]] as const) { + let factory; + let label = `${kind}VerifierS${size}`; + try { + factory = await ethers.getContractFactory(label); + } catch { + factory = await ethers.getContractFactory(`Mock${kind}Verifier`); + label += " (MOCK: build circuits/ for the real size)"; + } + const verifier = (await deployed(label, await (label.includes("MOCK") ? factory.deploy(size) : factory.deploy()))) as any; + out.push(await verifier.getAddress()); + } + } - // 5. ElectionFactory (embeds the ElectionV4 creation bytecode). - const Factory = await ethers.getContractFactory("ElectionFactory", { - libraries: { PoseidonT3: poseidonAddress }, - }); - 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. - deployer.address, - ); - await factory.waitForDeployment(); - steps.push({ - label: "ElectionFactory", - gas: (await ethers.provider.getTransactionReceipt(factory.deploymentTransaction()!.hash))!.gasUsed, - }); + // 5. ElectionDeployer (holds the ElectionV4 creation code) and ElectionFactory. + const electionDeployer = (await deployed( + "ElectionDeployer", + await (await ethers.getContractFactory("ElectionDeployer", { libraries })).deploy(), + )) as any; + const factory = (await deployed( + "ElectionFactory", + await (await ethers.getContractFactory("ElectionFactory")).deploy( + await paymaster.getAddress(), + await electionDeployer.getAddress(), + ballotVerifiers, + tallyVerifiers, + await registry.getAddress(), + // The platform attester, which costs the same to store whoever it is. + deployer.address, + ), + )) as any; // 6. Paymaster wiring: only the factory may bind an election to a gas tank. steps.push({ @@ -175,14 +182,12 @@ async function main(): Promise { enrollEnd: now + 100000, voteStart: now + 100000, voteEnd: now + 200000, - scope: 42n, - paillierPublicKey: '{"n":"0x' + "ab".repeat(256) + '","g":"0x' + "cd".repeat(256) + '"}', + // Four valid keys (three options and the blank vote): x·G for small x. + tallyKeys: [2n, 3n, 4n, 5n].flatMap(x => [...multiply(BASE, x)]), metadataJson: JSON.stringify({ description: "x".repeat(400), candidates: ["A", "B", "C"] }), eligibilityAttester: "0x0000000000000000000000000000000000000000", eligibilityPolicyHash: "0x" + "00".repeat(32), personhood: 0, - // Both were missing and the script could not have run: `privacyQuorum` has - // been in the config for a while, `fixedSchedule` since today. privacyQuorum: 0n, fixedSchedule: false, cancellable: true, @@ -277,19 +282,12 @@ async function main(): Promise { console.log("═".repeat(78)); console.log(""); - const optimized = process.argv.includes("production"); console.log(" Notes:"); - if (optimized) { - console.log(" - Compiled with the `production` profile (optimizer on), same as `deploy:amoy`."); - } else { - console.log(" - Compiled with the `default` profile (no optimizer). Add"); - console.log(" `--build-profile production` to match what `npm run deploy:amoy` does;"); - console.log(" it cuts the deploy by ~17% and each election by ~29%."); - } + console.log(" - Both build profiles run the optimizer, so this matches `npm run deploy:amoy`."); console.log(" - Polygon enforces a 30 gwei minimum priority fee; the base fee is ~0 on Amoy,"); console.log(" so 30 gwei is effectively the floor and the price rarely moves."); - console.log(" - castVote is not measured here: it needs a real Groth16 proof. Budget roughly"); - console.log(" 350k-450k gas per vote, paid by the paymaster gas tank."); + console.log(" - castVote is not measured here: it needs a real Groth16 proof. The E2E suite"); + console.log(" measures it and prints the figure; it is paid by the paymaster gas tank."); console.log(""); } diff --git a/contracts/scripts/lib/prover.ts b/contracts/scripts/lib/prover.ts new file mode 100644 index 0000000..eaedca5 --- /dev/null +++ b/contracts/scripts/lib/prover.ts @@ -0,0 +1,161 @@ +/** + * Proves ballots and tallies from Node, exactly as the browser does. + * + * The E2E suite and the demo seed both need real proofs against real + * verifiers, and they should build them the way a voter's browser and an + * organizer's browser do, or they prove nothing about those. So the inputs + * come from the same `ballotCrypto.ts` the frontend uses; only the plumbing + * differs: events via ethers, artefacts from disk. + * + * The artefacts are `circuits/build`, produced by `npm run build` in circuits/. + */ +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { LeanIMT } from "@zk-kit/lean-imt"; +import { poseidon2, poseidon3, poseidon4 } from "poseidon-lite"; +import * as snarkjs from "snarkjs"; + +import { + ballotCalldata, + ballotCircuitInputs, + ballotTag, + decryptTally, + solidityProof, + tallyCircuitInputs, + unflattenPoints, + type Point, + type SolidityProof, +} from "../../../frontend/src/lib/ballotCrypto.js"; + +export const CIRCUITS_BUILD = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "circuits", "build"); + +/** Whether the circuits have been built for this size, so real proofs can be made. */ +export function circuitsBuilt(slots: number): boolean { + return ["ballot", "tally"].every(kind => existsSync(join(CIRCUITS_BUILD, `${kind}_s${slots}.zkey`))); +} + +function artefacts(kind: "ballot" | "tally", slots: number) { + const name = `${kind}_s${slots}`; + return { wasm: join(CIRCUITS_BUILD, `${name}_js`, `${name}.wasm`), zkey: join(CIRCUITS_BUILD, `${name}.zkey`) }; +} + +/** A voter as the prover needs them: the Semaphore secret and its commitment. */ +export interface Voter { + secretScalar: bigint; + commitment: bigint; +} + +interface BallotEvent { + tag: bigint; + index: number; + leaf: bigint; + voteA: Point; + voteB: Point[]; +} + +async function readBallots(election: any): Promise { + const slots = Number(await election.numOptions()) + 1; + const events = await election.queryFilter(election.filters.BallotCast()); + return events.map((e: { args: Record }) => ({ + tag: e.args.tag as bigint, + index: Number(e.args.index), + leaf: e.args.leaf as bigint, + voteA: [e.args.voteA[0], e.args.voteA[1]] as Point, + voteB: unflattenPoints([...e.args.voteB]).slice(0, slots), + })); +} + +async function readMembers(election: any): Promise { + const events = await election.queryFilter(election.filters.MemberEnrolled()); + return events.map((e: { args: { identityCommitment: bigint } }) => e.args.identityCommitment); +} + +const hash2 = (a: bigint, b: bigint) => poseidon2([a, b]); + +/** + * The voter's next ballot for `choice`, proved: their chain is walked from + * their own tags, the last ballot found is cancelled. `epoch` defaults to the + * current one; the previous one is also accepted by the contract. + */ +export async function proveBallot( + election: any, + voter: Voter, + choice: number, + /** `members` replaces the election's roll: how a test builds a proof against a roll of its own. */ + opts: { epoch?: bigint; members?: bigint[] } = {}, +): Promise<{ ballot: ReturnType; proof: SolidityProof; tag: bigint }> { + const [slots, keys, scope, currentEpoch, ballots, members] = await Promise.all([ + election.circuitSlots().then(Number) as Promise, + election.tallyKeys().then((k: bigint[]) => unflattenPoints([...k])) as Promise, + election.scope() as Promise, + election.currentEpoch() as Promise, + readBallots(election), + readMembers(election), + ]); + const epoch = opts.epoch ?? currentEpoch; + + const voters = new LeanIMT(hash2, opts.members ?? members); + const votersPath = voters.generateProof(voters.indexOf(voter.commitment)); + + const byTag = new Map(ballots.map(b => [b.tag, b])); + let k = 0n; + while (byTag.has(ballotTag(poseidon4, voter.secretScalar, scope, k))) k++; + const previousBallot = k === 0n ? null : byTag.get(ballotTag(poseidon4, voter.secretScalar, scope, k - 1n))!; + + let ballotsRoot: bigint = await election.ballotsRoot(); + let previous = null; + if (previousBallot) { + const tree = new LeanIMT(hash2, ballots.map(b => b.leaf)); + const path = tree.generateProof(previousBallot.index); + ballotsRoot = path.root; + previous = { a: previousBallot.voteA, b: previousBallot.voteB, path }; + } + + const { inputs, ballot } = ballotCircuitInputs({ + poseidon3, + poseidon4, + circuitSlots: slots, + secret: voter.secretScalar, + scope, + epoch, + keys, + choice, + k, + voters: votersPath, + ballotsRoot, + previous, + }); + const files = artefacts("ballot", slots); + const { proof } = await snarkjs.groth16.fullProve(inputs, files.wasm, files.zkey); + return { + ballot: ballotCalldata(ballot, { votersRoot: votersPath.root, ballotsRoot, epoch }), + proof: solidityProof(proof), + tag: ballot.tag, + }; +} + +/** + * The election's tally from its aggregate, proved. Publishes when the voters + * reach the quorum, proves only that they did not otherwise. + */ +export async function proveTally( + election: any, + secrets: readonly bigint[], +): Promise<{ counts: bigint[]; voters: bigint; publish: boolean; proof: SolidityProof }> { + const [slots, keys, aggregate, quorum, ballots] = await Promise.all([ + election.circuitSlots().then(Number) as Promise, + election.tallyKeys().then((k: bigint[]) => unflattenPoints([...k])) as Promise, + election.aggregate() as Promise<[bigint[], bigint[]]>, + election.privacyQuorum() as Promise, + election.voteCount() as Promise, + ]); + const total = { a: [aggregate[0][0], aggregate[0][1]] as Point, b: unflattenPoints([...aggregate[1]]) }; + const counts = decryptTally(secrets, total, Number(ballots)); + const voters = counts.reduce((a, b) => a + b, 0n); + const publish = voters >= quorum; + const inputs = tallyCircuitInputs({ poseidon3, circuitSlots: slots, keys, secrets, counts, aggregate: total, quorum, publish }); + const files = artefacts("tally", slots); + const { proof } = await snarkjs.groth16.fullProve(inputs, files.wasm, files.zkey); + return { counts, voters, publish, proof: solidityProof(proof) }; +} diff --git a/contracts/scripts/seed-demo.ts b/contracts/scripts/seed-demo.ts index 3c346b4..3b83ec8 100644 --- a/contracts/scripts/seed-demo.ts +++ b/contracts/scripts/seed-demo.ts @@ -4,18 +4,21 @@ * `seed-local.ts` creates three empty elections, which is enough to see the * Discover grid but not enough to exercise the app. This one drives real * elections through their whole life: registers voters in PlatformRegistry, - * enrols them through ElectionPaymaster, casts genuine Groth16-proved Paillier - * ballots, re-votes to show coercion resistance, and publishes decrypted results + * enrols them through ElectionPaymaster, casts genuine Groth16-proved ElGamal + * ballots, re-votes to show coercion resistance, and publishes proved results * so every results layout has something to render. * * Time is moved with `evm_increaseTime`, so the historical elections are created * and completed FIRST and the live ones last: the chain clock only goes forward, * and an election created earlier would otherwise have its window dragged past. * - * The Paillier private key of each tallyable election is written to - * `deployments/tally-keys/`, because the UI normally derives it from the - * organizer passkey and a seeded election has no passkey behind it. Import the - * file with the "Import decryption key" button in the organizer view. + * The tally key file of each election left in tallying is written to + * `deployments/tally-keys/`, because the UI normally derives the keys from the + * organizer's wallet and a seeded election has no wallet signature behind it. + * Import the file with the "Import decryption key" button in the organizer view. + * + * Needs the circuits built (`npm run build` in circuits/): every ballot and + * every result is proved for real, against the verifiers deploy.ts installed. * * Usage: npx hardhat run scripts/seed-demo.ts --network localhost */ @@ -24,11 +27,9 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { Identity } from "@semaphore-protocol/identity"; -import { Group } from "@semaphore-protocol/group"; -import { generateProof } from "@semaphore-protocol/proof"; -import { poseidon2 } from "poseidon-lite/poseidon2"; -import { generateRandomKeys, PublicKey } from "paillier-bigint"; import type { Wallet } from "ethers"; +import { deriveTallyKeys, flattenPoints } from "../../frontend/src/lib/ballotCrypto.js"; +import { proveBallot, proveTally } from "./lib/prover.js"; import { createHmac } from "node:crypto"; const { ethers } = await network.getOrCreate(); @@ -56,7 +57,6 @@ const ONLY = (process.env.SEED_ONLY ?? "") const HOUR = 3600; const DAY = 24 * HOUR; -const COUNTER_BASE = 1_000_000_000_000n; /** * Privacy quorum for seeded elections. Below the number of voters each spec @@ -65,8 +65,6 @@ const COUNTER_BASE = 1_000_000_000_000n; * nothing else. */ const SEED_PRIVACY_QUORUM = 3; -/// 1024-bit keeps seeding quick; the app generates 2048-bit for real elections. -const PAILLIER_BITS = 1024; const VotingType = { SIMPLE_PLURALITY: 0, @@ -75,7 +73,6 @@ const VotingType = { WITNESS_THRESHOLD: 3, } as const; -const toHex = (x: bigint): string => "0x" + x.toString(16); /// Coarse timing, so a slow seed run says WHERE it is slow instead of just hanging. const t0 = Date.now(); @@ -84,24 +81,6 @@ function step(label: string): void { console.log(` [${elapsed()}] ${label}`); } -/** Ballot ciphertext goes to Solidity as `bytes`, so it must have even length. */ -function encryptBallot(pk: PublicKey, optionIndex: number): string { - const digits = pk.encrypt(COUNTER_BASE ** BigInt(optionIndex)).toString(16); - return "0x" + (digits.length % 2 === 0 ? digits : "0" + digits); -} - -function hashToField(v: bigint): bigint { - return BigInt(ethers.keccak256(ethers.zeroPadValue(ethers.toBeHex(v), 32))) >> 8n; -} - -function voteNullifier(identity: Identity, scope: bigint): bigint { - return poseidon2([hashToField(scope), identity.secretScalar]); -} - -function voteMessage(ciphertext: string, nonce: bigint): bigint { - return BigInt(ethers.solidityPackedKeccak256(["bytes", "uint256"], [ciphertext, nonce])); -} - /** * The time the NEXT block will carry, not the time the last one did. * @@ -282,6 +261,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 +301,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 +368,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 { @@ -483,13 +502,19 @@ async function main(): Promise { // tight and every enrollment reverted with `EnrollmentNotOpen`. The // advances below also step 60 seconds into each window, which sets the // floor. + // + // The voting window is sized by the PROVING too. A local node stamps blocks + // from the wall clock, and each ballot is a real Groth16 proof that takes + // 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) { - spec = { ...spec, enrollFrom: -30, enrollTo: 60, voteFrom: 60, voteTo: 150 }; + const ballots = (spec.ballots?.length ?? 0) + (spec.revote ? 1 : 0); + spec = { ...spec, enrollFrom: -30, enrollTo: 60, voteFrom: 60, voteTo: 120 + 60 * ballots }; } const organizer = signers[spec.organizerAccount ?? 0]; - step(`${spec.name}: generating Paillier key`); - const keys = await generateRandomKeys(PAILLIER_BITS); + // Random, and kept: a seeded election has no wallet to derive them from. + const keys = await deriveTallyKeys(crypto.getRandomValues(new Uint8Array(32)), "seed", spec.candidates.length + 1); if (spec.eligibility && !attesterWallet) { throw new Error( `"${spec.name}" declares an eligibility policy but no attester key was found. ` + @@ -510,19 +535,14 @@ async function main(): Promise { enrollEnd: created + spec.enrollTo, voteStart: created + spec.voteFrom, voteEnd: created + spec.voteTo, - scope: BigInt(ethers.hexlify(ethers.randomBytes(31))), - paillierPublicKey: JSON.stringify({ - n: toHex(keys.publicKey.n), - g: toHex(keys.publicKey.g), - }), - // No keyNonce: the key was not derived from a passkey, so the UI will ask - // the organizer to import it rather than trying to re-derive it. + tallyKeys: flattenPoints(keys.keys), + // No keyNonce: the keys were not derived from a wallet, so the UI will ask + // the organizer to import them rather than trying to re-derive them. metadataJson: JSON.stringify({ description: spec.description, organizerName: spec.organizerName, candidates: spec.candidates, privacyQuorum: spec.privacyQuorum ?? SEED_PRIVACY_QUORUM, - counterBase: COUNTER_BASE.toString(), ...(spec.eligibility ? { eligibility: canonicalPolicy(spec.eligibility) } : {}), tags: spec.tags ?? ["demo"], }), @@ -591,12 +611,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 +633,7 @@ async function main(): Promise { address, identity.commitment, tag, + documentTag, deadline, signature, spec.eligibility ? signature : "0x", @@ -650,38 +679,25 @@ async function main(): Promise { // Voting. await advanceTo(created + spec.voteFrom + 60); - const group = new Group(voting.map(identity => identity.commitment)); - const scope: bigint = await election.scope(); - - const castFor = async (voterIndex: number, option: number): 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); - 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]], - ) - ).wait(); + /** + * Proved and relayed exactly as a voter's browser does it. `epoch` is how + * the seed re-votes without waiting: one ballot per voter per epoch, and a + * proof may name the current epoch or the one before, so a re-vote proved + * for the previous epoch lands straight away. Waiting an hour instead would + * push the chain clock an hour past the wall for every election after it. + */ + const castFor = async (voterIndex: number, option: number, epoch?: bigint): Promise => { + const { ballot, proof } = await proveBallot(election, voting[voterIndex], option, { epoch }); + await (await paymaster.relayVote(address, ballot, proof)).wait(); }; + step(`${spec.name}: proving ${spec.ballots.length} ballots`); for (let i = 0; i < spec.ballots.length; i++) await castFor(i, spec.ballots[i]); const finalChoices = [...spec.ballots]; if (spec.revote) { const [voterIndex, replacement] = spec.revote; - await castFor(voterIndex, replacement); + await castFor(voterIndex, replacement, (await election.currentEpoch()) - 1n); finalChoices[voterIndex] = replacement; } @@ -694,10 +710,16 @@ 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, and checked by + // the election before it accepts the result. + const tally = await proveTally(election, keys.secrets); + 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, tally.proof) ).wait(); const outcomeNames = ["NONE", "WINNER", "TIE", "APPROVED", "REJECTED", "THRESHOLD_NOT_MET"]; @@ -710,17 +732,8 @@ async function main(): Promise { await advanceTo(created + spec.voteTo + 60); writeFileSync( join(keyDir, `${address}.json`), - JSON.stringify( - { - publicKey: { n: toHex(keys.publicKey.n), g: toHex(keys.publicKey.g) }, - privateKey: { - lambda: toHex(keys.privateKey.lambda), - mu: toHex(keys.privateKey.mu), - }, - }, - null, - 2, - ) + "\n", + // The format `tallyKey.ts` exports and imports. + JSON.stringify({ version: 2, secrets: keys.secrets.map(x => "0x" + x.toString(16)) }, null, 2) + "\n", ); console.log(`OK ${spec.name}\n -> ${address} [TALLYING - key in deployments/tally-keys/]`); return; diff --git a/contracts/scripts/seed-local.ts b/contracts/scripts/seed-local.ts index 37aa893..07b16a0 100644 --- a/contracts/scripts/seed-local.ts +++ b/contracts/scripts/seed-local.ts @@ -1,7 +1,8 @@ /** * Seeds the LOCAL chain with real elections so the dApp has genuine on-chain * data to render during development. Deliberately local-only: it uses Hardhat's - * dev accounts and a throwaway Paillier key. + * dev accounts and throwaway tally keys nobody holds the secrets of, so these + * elections can be voted in but never counted. * * Usage: npx hardhat run scripts/seed-local.ts --network localhost */ @@ -10,12 +11,15 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { BASE, multiply } from "../../frontend/src/lib/ballotCrypto.js"; + const { ethers } = await network.getOrCreate(); const HOUR = 3600; -// Throwaway Paillier public key (real keys are generated by the UI at creation). -const DEMO_PAILLIER_PK = JSON.stringify({ n: "0x" + "ab".repeat(64), g: "0x" + "ab".repeat(64) }); +/** Throwaway tally keys, x·G for small x (real keys are derived by the UI at creation). */ +const demoTallyKeys = (slots: number): bigint[] => + Array.from({ length: slots }, (_, i) => [...multiply(BASE, BigInt(i + 2))]).flat(); async function main() { const chainId = (await ethers.provider.getNetwork()).chainId; @@ -89,8 +93,7 @@ async function main() { enrollEnd: e.enrollEnd, voteStart: e.voteStart, voteEnd: e.voteEnd, - scope: BigInt(ethers.hexlify(ethers.randomBytes(31))), - paillierPublicKey: DEMO_PAILLIER_PK, + tallyKeys: demoTallyKeys(e.candidates.length + 1), metadataJson: JSON.stringify({ description: `${e.name} — seeded locally for development.`, organizerName: e.organizerName, diff --git a/contracts/test/AbiParity.test.ts b/contracts/test/AbiParity.test.ts new file mode 100644 index 0000000..8774793 --- /dev/null +++ b/contracts/test/AbiParity.test.ts @@ -0,0 +1,96 @@ +/** + * Every hand-written ABI fragment in the frontend, the backend and the auditor's + * CLI must exist, exactly, in the compiled contracts. + * + * WHY THIS EXISTS. Those packages talk to the contracts through human-readable + * fragments typed by hand, and nothing compiled them against the contracts. A + * signature that drifted, a parameter added to `relayEnrollPrivate` and not to + * the local relay's copy of it, compiled, linted and passed every unit test, + * and failed only when a voter pressed the button. This reads each fragment out + * of the source text and looks it up in the artifacts, so drift fails here. + */ +import { expect } from "chai"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { network } from "hardhat"; + +const { ethers } = await network.create(); + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +/** Files that declare fragments, relative to the repository root. */ +const SOURCES = [ + "frontend/src/lib/contracts.ts", + "frontend/src/lib/relay.ts", + "frontend/src/lib/organizerDomains.ts", + "backend/src/chain/relayer.ts", + "backend/src/chain/registrar.ts", + "backend/src/chain/election.ts", + "scripts-tally/tally-votes.ts", +]; + +const CONTRACTS = [ + "ElectionV4", + "ElectionFactory", + "ElectionPaymaster", + "PlatformRegistry", + "OrganizerDomains", +]; + +/** Every string literal that starts like a fragment, with `${PROOF}`-style constants expanded. */ +function fragmentsIn(file: string): string[] { + const text = readFileSync(join(ROOT, file), "utf-8"); + const constants = new Map(); + for (const m of text.matchAll(/const (\w+) = "([^"]+)";/g)) constants.set(m[1], m[2]); + const out: string[] = []; + for (const m of text.matchAll(/["'`]((?:function|event|error) [^"'`]+)["'`]/g)) { + out.push(m[1].replace(/\$\{(\w+)\}/g, (_, name: string) => constants.get(name) ?? `\${${name}}`)); + } + return out; +} + +describe("hand-written ABIs match the compiled contracts", () => { + const compiled = new Map>(); + + before(async () => { + for (const name of CONTRACTS) { + const artifact = await import(`../artifacts/contracts/${name}.sol/${name}.json`, { with: { type: "json" } }); + compiled.set(name, new ethers.Interface(artifact.default.abi)); + } + }); + + for (const file of SOURCES) { + it(`every fragment in ${file}`, () => { + const fragments = fragmentsIn(file); + expect(fragments.length, `no fragments found in ${file}`).to.be.greaterThan(0); + + for (const text of fragments) { + const wanted = ethers.Fragment.from(text); + const sighash = wanted.format("sighash"); + const matches = [...compiled.values()].flatMap(iface => + iface.fragments.filter(f => f.type === wanted.type && f.format("sighash") === sighash), + ); + expect(matches, `${file}: "${text}" matches nothing in ${CONTRACTS.join(", ")}`).to.not.be.empty; + + // A view function must also return what the caller decodes. + if (wanted instanceof ethers.FunctionFragment) { + const outputs = (f: InstanceType) => + f.outputs.map(o => o.format("sighash")).join(","); + expect( + matches.some(f => outputs(f as InstanceType) === outputs(wanted)), + `${file}: "${text}" returns something else in the contract`, + ).to.equal(true); + } + // An event must index the same parameters, or filtering by them finds nothing. + if (wanted instanceof ethers.EventFragment) { + const indexed = (f: InstanceType) => f.inputs.map(i => Boolean(i.indexed)).join(","); + expect( + matches.some(f => indexed(f as InstanceType) === indexed(wanted)), + `${file}: "${text}" indexes different parameters in the contract`, + ).to.equal(true); + } + } + }); + } +}); diff --git a/contracts/test/E2E.test.ts b/contracts/test/E2E.test.ts index 01a52de..5133f67 100644 --- a/contracts/test/E2E.test.ts +++ b/contracts/test/E2E.test.ts @@ -1,166 +1,112 @@ /** * End-to-end election, exercised the way the real dApp does it. * - * The other suites use MockVerifier, so until this file existed the **official - * Groth16 verifier had never checked a single proof**. That is the riskiest - * assumption in the whole stack: `ElectionV4._hashToField` has to reproduce - * Semaphore's own hash-to-field byte for byte, and the packed proof points have - * to land in the exact calldata order `ISemaphoreVerifier` expects. If either is - * off, every vote reverts on Amoy and no unit test would have caught it. + * The other suites use mock verifiers, so this is the one place the generated + * Groth16 verifiers check real proofs. That is the riskiest joint in the whole + * stack: the contract has to lay out the public signals in exactly the order + * the circuits declare them, hash the keys exactly as they do and accumulate + * exactly the points they encrypt. If any of that is off, every ballot reverts + * on Amoy and no unit test would have caught it. * - * 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. + * So this suite deploys the generated verifiers, proves ballots and tallies + * with the same `ballotCrypto.ts` the browser uses, routes everything through + * ElectionPaymaster exactly as the relayer does, and has the election itself + * verify the decryption before it publishes a result. + * + * It needs the circuits built (`npm run build` in circuits/). Without them it + * skips, loudly, unless REQUIRE_CIRCUITS=1, which CI sets so that a missing + * build fails instead of passing by doing nothing. */ import { expect } from "chai"; import { network } from "hardhat"; import { Identity } from "@semaphore-protocol/identity"; -import { Group } from "@semaphore-protocol/group"; -import { generateProof } from "@semaphore-protocol/proof"; -import { poseidon2 } from "poseidon-lite/poseidon2"; -import { PublicKey, PrivateKey, generateRandomKeys } from "paillier-bigint"; + import { - deployPoseidonT3, - POSEIDON_FQN, - baseConfig, - signPrivateEnrollment, - VotingType, - Outcome, - Phase, -} from "./fixtures.js"; + aggregate as addBallots, + deriveTallyKeys, + equals, + flattenPoints, + unflattenPoints, + type Point, +} from "../../frontend/src/lib/ballotCrypto.js"; +import { circuitsBuilt, proveBallot, proveTally } from "../scripts/lib/prover.js"; +import { baseConfig, deployPoseidon, Outcome, Phase, signPrivateEnrollment, VotingType } from "./fixtures.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); - -/** - * Mirrors frontend/src/lib/paillier.ts encryptBallot, padding included: the - * ciphertext is passed to Solidity as `bytes`, so an odd number of hex digits - * is rejected by ethers before the call is even made. - */ -function encryptBallot(pk: PublicKey, optionIndex: number): string { - const digits = pk.encrypt(COUNTER_BASE ** BigInt(optionIndex)).toString(16); - return "0x" + (digits.length % 2 === 0 ? digits : "0" + digits); -} - -/** Semaphore's hash-to-field, mirroring ElectionV4._hashToField. */ -function hashToField(value: bigint): bigint { - return BigInt(ethers.keccak256(ethers.zeroPadValue(ethers.toBeHex(value), 32))) >> 8n; -} - -/** - * The vote nullifier, computed directly instead of via a throwaway proof. - * Mirrors frontend/src/lib/semaphore.ts computeNullifier; generating a full - * Groth16 proof just to read this would double the cost of every ballot. - */ -function voteNullifier(identity: Identity, scope: bigint): bigint { - return poseidon2([hashToField(scope), identity.secretScalar]); -} - -/** Mirrors ElectionV4.castVote's message derivation exactly. */ -function voteMessage(ciphertext: string, nonce: bigint): bigint { - return BigInt(ethers.solidityPackedKeccak256(["bytes", "uint256"], [ciphertext, nonce])); -} +/** Circuit sizes this suite deploys: the two the default build produces. */ +const SIZES = [5, 9]; interface Voter { - /** What the identity is derived from, both the platform one and per-election ones. */ seed: string; identity: Identity; nullifier: bigint; } -interface Stack { - registry: any; - paymaster: any; - verifier: any; - factory: any; - poseidon: string; -} - -describe("E2E, real Groth16 proofs, real Paillier, relayed like production", () => { - let deployer: any; +describe("E2E, real Groth16 ballots and tallies, relayed like production", () => { let organizer: any; let relayer: any; let platform: any; - let stack: Stack; - let paillier: { publicKey: PublicKey; privateKey: PrivateKey }; + let registry: any; + let paymaster: any; + let factory: any; + let tally: { secrets: bigint[]; keys: Point[] }; before(async function () { + if (!SIZES.every(circuitsBuilt)) { + const message = "circuits not built: run `npm run build` in circuits/ to run the E2E suite"; + if (process.env.REQUIRE_CIRCUITS === "1") throw new Error(message); + console.warn(` SKIPPED: ${message}`); + this.skip(); + } this.timeout(180_000); - [deployer, organizer, relayer, platform] = await ethers.getSigners(); - - const poseidon = await deployPoseidonT3(ethers); - - const Registry = await ethers.getContractFactory("PlatformRegistry"); - const registry = await Registry.deploy(); - await registry.waitForDeployment(); - - const Paymaster = await ethers.getContractFactory("ElectionPaymaster"); - const paymaster = await Paymaster.deploy(); - await paymaster.waitForDeployment(); - - // The real thing, not MockVerifier. This is the point of the suite. - const Verifier = await ethers.getContractFactory("SemaphoreVerifierV4"); - const verifier = await Verifier.deploy(); - await verifier.waitForDeployment(); + [, organizer, relayer, platform] = await ethers.getSigners(); + + const libraries = await deployPoseidon(ethers); + registry = await (await ethers.getContractFactory("PlatformRegistry")).deploy(); + paymaster = await (await ethers.getContractFactory("ElectionPaymaster")).deploy(); + + // The generated verifiers, not the mocks. This is the point of the suite. + const ballotVerifiers: string[] = []; + const tallyVerifiers: string[] = []; + for (const size of SIZES) { + ballotVerifiers.push(await (await (await ethers.getContractFactory(`BallotVerifierS${size}`)).deploy()).getAddress()); + tallyVerifiers.push(await (await (await ethers.getContractFactory(`TallyVerifierS${size}`)).deploy()).getAddress()); + } + const deployer = await (await ethers.getContractFactory("ElectionDeployer", { libraries })).deploy(); - const Factory = await ethers.getContractFactory("ElectionFactory", { - libraries: { [POSEIDON_FQN]: poseidon }, - }); - const factory = await Factory.deploy( + factory = await (await ethers.getContractFactory("ElectionFactory")).deploy( await paymaster.getAddress(), - FORWARDER, - await verifier.getAddress(), + await deployer.getAddress(), + ballotVerifiers, + tallyVerifiers, await registry.getAddress(), platform.address, ); - await factory.waitForDeployment(); await (await paymaster.setFactory(await factory.getAddress())).wait(); - - stack = { registry, paymaster, verifier, factory, poseidon }; - paillier = await generateRandomKeys(PAILLIER_BITS); }); /** Registers a voter on the platform and returns their Semaphore identity. */ async function newVoter(seed: string): Promise { const identity = new Identity(seed); const worldIdNullifier = BigInt(ethers.keccak256(ethers.toUtf8Bytes("worldid:" + seed))); - await (await stack.registry.registerMember(worldIdNullifier, identity.commitment)).wait(); + await (await registry.registerMember(worldIdNullifier, identity.commitment)).wait(); return { seed, identity, nullifier: worldIdNullifier }; } /** - * The identity a voter derives for ONE election, mirroring the frontend. - * - * The commitment that lands in this election's tree is computed from the - * voter's own secret and this election's address, so it is reproducible from - * their recovery phrase and matches nothing they use anywhere else. Their - * platform identity, the one the registry names, never appears on chain - * again after registration. + * The identity a voter derives for ONE election, mirroring the frontend: it + * is reproducible from their own secret and matches nothing they use + * anywhere else. Their platform identity never appears on chain again. */ function electionIdentity(voter: Voter, electionAddress: string): Identity { return new Identity(`${voter.seed}:${electionAddress.toLowerCase()}`); } - /** - * What the platform derives to answer "has this person already enrolled - * here", and nothing else. In production a server-side key goes into this - * hash, so nobody who knows the World ID nullifier can recompute the tag and - * recognise the same person in another election. - */ + /** What the platform derives to answer "has this person already enrolled here". */ function humanTag(voter: Voter, electionAddress: string): bigint { return BigInt( - ethers.solidityPackedKeccak256( - ["string", "uint256", "address"], - ["test-pepper", voter.nullifier, electionAddress], - ), + ethers.solidityPackedKeccak256(["string", "uint256", "address"], ["test-pepper", voter.nullifier, electionAddress]), ); } @@ -178,317 +124,247 @@ describe("E2E, real Groth16 proofs, real Paillier, relayed like production", () tag, deadline, ); - await ( - await stack.paymaster - .connect(relayer) - .relayEnrollPrivate(address, identity.commitment, tag, deadline, signature, "0x") + await paymaster.connect(relayer).relayEnrollPrivate(address, identity.commitment, tag, 0n, deadline, signature, "0x") ).wait(); - return identity; } - async function createElection(overrides: Record = {}, deposit = "2"): Promise { + async function createElection(overrides: Record = {}, deposit = "5"): Promise { const now = await networkHelpers.time.latest(); + const numOptions = (overrides.numOptions as bigint | undefined) ?? 3n; + tally = await deriveTallyKeys(new Uint8Array(32).fill(9), "0xe2e", Number(numOptions) + 1); const cfg = baseConfig(now, { - numOptions: 3n, - paillierPublicKey: JSON.stringify({ - n: toHex(paillier.publicKey.n), - g: toHex(paillier.publicKey.g), - }), + numOptions, + // Long enough for re-votes an epoch apart. + voteEnd: now + 6 * 3600, + tallyKeys: flattenPoints(tally.keys), ...overrides, }); - await ( - await stack.factory.connect(organizer).createElection(cfg, 0n, { value: ethers.parseEther(deposit) }) - ).wait(); - const count = await stack.factory.electionsCount(); - return ethers.getContractAt("ElectionV4", await stack.factory.elections(count - 1n)); + await (await factory.connect(organizer).createElection(cfg, 0n, { value: ethers.parseEther(deposit) })).wait(); + const count = await factory.electionsCount(); + return ethers.getContractAt("ElectionV4", await factory.elections(count - 1n)); } - /** Full relayed vote: encrypt, prove, submit through the paymaster. */ - async function relayedVote( - election: any, - identity: Identity, - group: Group, - optionIndex: number, - ): 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); - - // The nonce must be read BEFORE proving: it is bound into the message. - const nonce: bigint = await election.nullifierNonces(voteNullifier(identity, scope)); - - const message = voteMessage(ciphertext, nonce); - const proof = await generateProof(identity, group, message, scope); - const p = proof.points.map(BigInt); - - await ( - await stack.paymaster.connect(relayer).relayVote( - await election.getAddress(), - 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]], - ) - ).wait(); - - return { ciphertext, nullifier: BigInt(proof.nullifier) }; + /** Proves and relays one ballot, as the voter's browser does. */ + async function relayedVote(election: any, identity: Identity, choice: number): Promise<{ tag: bigint; gas: bigint }> { + const { ballot, proof, tag } = await proveBallot(election, identity, choice); + const receipt = await (await paymaster.connect(relayer).relayVote(await election.getAddress(), ballot, proof)).wait(); + return { tag, gas: receipt.gasUsed }; } - it("runs a whole election: enroll, vote, re-vote, tally, publish", async function () { - this.timeout(300_000); + it("runs a whole election: enroll, vote, re-vote unseen, tally, prove, publish", async function () { + this.timeout(600_000); const election = await createElection(); - const address = await election.getAddress(); // ── Enrollment, relayed ── - const alice = await newVoter("alice"); - const bob = await newVoter("bob"); - const carol = await newVoter("carol"); - + const voters = await Promise.all(["alice", "bob", "carol", "dave"].map(newVoter)); const voting = new Map(); - for (const v of [alice, bob, carol]) { - voting.set(v.seed, await enrolPrivately(election, v)); - } - expect(await election.memberCount()).to.equal(3n); + for (const v of voters) voting.set(v.seed, await enrolPrivately(election, v)); + 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]) { + // NONE of these are the commitments the registry knows: the tree says four + // verified humans joined, and nothing on chain says which four. + for (const v of voters) { 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, - ]); - 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); - - // Carol votes for option 2, then changes her mind: coercion resistance means - // only her highest-nonce ballot may count. - const carolIdentity = voting.get("carol")!; - const coerced = await relayedVote(election, carolIdentity, group, 2); - 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 - - // ── Tally: dedup by max nonce, homomorphic sum, decrypt ── - 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 }); + const first = await relayedVote(election, voting.get("alice")!, 0); + await relayedVote(election, voting.get("bob")!, 0); + await relayedVote(election, voting.get("dave")!, 2); + console.log(` relayed ballot gas (5 slots): ${first.gas}`); + + // Carol votes for option 2 under pressure, then, an epoch later, for what + // she meant. Only her last ballot may count, and nothing public may say + // that she voted twice. + const coerced = await relayedVote(election, voting.get("carol")!, 2); + await networkHelpers.time.increase(Number(await election.EPOCH_LENGTH())); + const real = await relayedVote(election, voting.get("carol")!, 1); + expect(real.tag).to.not.equal(coerced.tag); + expect(await election.voteCount()).to.equal(5n); + + // What the chain shows is five unrelated ballots: every one of them carries + // a vote AND a cancellation, whether it replaced anything or not. + const events = await election.queryFilter(election.filters.BallotCast()); + expect(new Set(events.map((e: any) => e.args.tag)).size).to.equal(5); + for (const e of events as any[]) { + expect(equals([e.args.cancelA[0], e.args.cancelA[1]], [0n, 1n])).to.equal(false); } - 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); - - // 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]); - - // ── Publish ── + // ── Tally: the aggregate, decrypted and proved ── 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 proven = await proveTally(election, tally.secrets); + // Alice + Bob on 0, Carol's REPLACEMENT on 1, Dave on 2, nothing counted + // for the coerced ballot. + expect(proven.counts).to.deep.equal([2n, 1n, 1n, 0n]); + expect(proven.voters).to.equal(4n); + + // The count an organizer might have preferred is refused by the proof. + await expect( + election.connect(organizer).publishResults("QmStuffed", [2n, 1n, 2n, 0n], proven.proof), + ).to.be.revertedWithCustomError(election, "InvalidProof"); + await (await election.connect(organizer).publishResults("QmAuditTrail", proven.counts, proven.proof)).wait(); expect(await election.resultsPublished()).to.equal(true); + expect(await election.voters()).to.equal(4n); 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: the ballots on chain add up to the aggregate ── + const [a, b] = await election.aggregate(); + const recomputed = addBallots( + (events as any[]).map(e => ({ + tag: e.args.tag, + voteA: [e.args.voteA[0], e.args.voteA[1]], + voteB: unflattenPoints([...e.args.voteB]), + cancelA: [e.args.cancelA[0], e.args.cancelA[1]], + cancelB: unflattenPoints([...e.args.cancelB]), + })), + 4, + ); + expect(equals(recomputed.a, [a[0], a[1]])).to.equal(true); + expect(recomputed.b.every((p, i) => equals(p, unflattenPoints([...b])[i]))).to.equal(true); + }); + + it("voids an election below its quorum with a proof, and never reveals the counts", async function () { + this.timeout(600_000); + + // Three ballots from two voters: only the proof can say it was two. + const election = await createElection({ privacyQuorum: 3n, cancellable: false }); + const [x, y] = await Promise.all(["void-x", "void-y"].map(newVoter)); + const ix = await enrolPrivately(election, x); + const iy = await enrolPrivately(election, y); + await networkHelpers.time.increaseTo((await election.voteStart()) + 1n); + await relayedVote(election, ix, 0); + await relayedVote(election, iy, 1); + await networkHelpers.time.increase(Number(await election.EPOCH_LENGTH())); + await relayedVote(election, ix, 1); + + await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); + // Three ballots meet a quorum of three, so voiding without a proof would be + // a veto this election gave up. + await expect(election.connect(organizer).markVoided()).to.be.revertedWithCustomError(election, "ResultPublishable"); + + const proven = await proveTally(election, tally.secrets); + expect(proven.publish).to.equal(false); + expect(proven.voters).to.equal(2n); + await expect(election.voidBelowQuorum(2n, proven.proof)).to.emit(election, "VoidedBelowQuorum").withArgs(2n); + expect(await election.phase()).to.equal(Phase.VOIDED); + expect(await election.tally()).to.deep.equal([]); }); - it("rejects a proof from someone outside the group", async function () { + it("rejects a proof against a roll the voter made up", async function () { this.timeout(180_000); const election = await createElection(); - const address = await election.getAddress(); - - const member = await newVoter("member-only"); - await enrolPrivately(election, member); + await enrolPrivately(election, await newVoter("member-only")); await networkHelpers.time.increaseTo((await election.voteStart()) + 1n); - // An outsider builds a group containing only themselves: internally consistent, - // but its root is not the election's, so the contract must refuse it. + // Internally consistent, but its root is not the election's. const outsider = new Identity("outsider"); - const fakeGroup = new Group([outsider.commitment]); - - const scope: bigint = await election.scope(); - const ciphertext = encryptBallot(paillier.publicKey, 0); - const proof = await generateProof(outsider, fakeGroup, voteMessage(ciphertext, 0n), scope); - const p = proof.points.map(BigInt); - + const { ballot, proof } = await proveBallot(election, outsider, 0, { members: [outsider.commitment] }); await expect( - stack.paymaster.connect(relayer).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]], - ), + paymaster.connect(relayer).relayVote(await election.getAddress(), ballot, proof), ).to.be.revertedWithCustomError(election, "UnknownOrExpiredRoot"); }); - it("rejects a tampered proof against the real verifier, and charges nobody", async function () { + it("rejects a tampered proof or ballot against the real verifier, and charges nobody", async function () { this.timeout(180_000); const election = await createElection(); const address = await election.getAddress(); - const voter = await newVoter("tamper"); - const voting = await enrolPrivately(election, voter); + const identity = await enrolPrivately(election, await newVoter("tamper")); await networkHelpers.time.increaseTo((await election.voteStart()) + 1n); + const { ballot, proof } = await proveBallot(election, identity, 0); + const tankBefore = await paymaster.reservedFor(address); + + // Flip one field element: the pairing check must fail. + const bent = { ...proof, a: [proof.a[0] + 1n, proof.a[1]] }; + await expect(paymaster.connect(relayer).relayVote(address, ballot, bent)).to.be.revert(ethers); + + // Change the vote the proof was made for: the proof no longer holds. + const swapped = { ...ballot, voteB: [...ballot.voteB.slice(2), ...ballot.voteB.slice(0, 2)] }; + await expect(paymaster.connect(relayer).relayVote(address, swapped, proof)).to.be.revertedWithCustomError( + election, + "InvalidProof", + ); - const group = new Group([voting.commitment]); - const scope: bigint = await election.scope(); - const ciphertext = encryptBallot(paillier.publicKey, 0); - const proof = await generateProof(voting, group, voteMessage(ciphertext, 0n), scope); - const p = proof.points.map(BigInt); - - const tankBefore = await stack.paymaster.gasBalance(organizer.address); - - // Flip one field element: the Groth16 pairing check must fail. - await expect( - stack.paymaster.connect(relayer).relayVote( - address, - ciphertext, - BigInt(proof.nullifier), - BigInt(proof.merkleTreeRoot), - BigInt(proof.merkleTreeDepth), - [p[0] + 1n, p[1]], - [ - [p[2], p[3]], - [p[4], p[5]], - ], - [p[6], p[7]], - ), - ).to.be.revert(ethers); - - expect(await stack.paymaster.gasBalance(organizer.address)).to.equal(tankBefore); + expect(await paymaster.reservedFor(address)).to.equal(tankBefore); }); - it("rejects replaying someone else's ballot with a stale nonce", async function () { + it("refuses the same ballot twice, however it arrives", async function () { this.timeout(180_000); const election = await createElection(); - const address = await election.getAddress(); - const voter = await newVoter("replay"); - const voting = await enrolPrivately(election, voter); + const identity = await enrolPrivately(election, await newVoter("replay")); await networkHelpers.time.increaseTo((await election.voteStart()) + 1n); + const { ballot, proof } = await proveBallot(election, identity, 0); - const group = new Group([voting.commitment]); - const scope: bigint = await election.scope(); - const ciphertext = encryptBallot(paillier.publicKey, 0); - const proof = await generateProof(voting, group, voteMessage(ciphertext, 0n), scope); - const p = proof.points.map(BigInt); - - const args = [ - 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]], - ] as const; - - 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); + await (await paymaster.connect(relayer).relayVote(await election.getAddress(), ballot, proof)).wait(); + await expect(election.connect(relayer).castVote(ballot, proof)).to.be.revertedWithCustomError( + election, + "TagAlreadyCast", + ); expect(await election.voteCount()).to.equal(1n); }); it("counts a Yes/No supermajority correctly through the same pipeline", async function () { - this.timeout(300_000); + this.timeout(600_000); // 2 options (Yes/No) + blank. Two thirds of ballots cast must be Yes. - const election = await createElection({ - numOptions: 2n, - votingType: VotingType.SUPERMAJORITY_TWO_THIRDS, - }); - const address = await election.getAddress(); - - const voters: Voter[] = []; - for (const seed of ["s1", "s2", "s3"]) voters.push(await newVoter("two-thirds-" + seed)); + const election = await createElection({ numOptions: 2n, votingType: VotingType.SUPERMAJORITY_TWO_THIRDS }); const voting: Identity[] = []; - for (const v of voters) voting.push(await enrolPrivately(election, v)); - - const group = new Group(voting.map(identity => identity.commitment)); + for (const seed of ["s1", "s2", "s3"]) voting.push(await enrolPrivately(election, await newVoter("two-thirds-" + seed))); await networkHelpers.time.increaseTo((await election.voteStart()) + 1n); // 2 Yes (index 0), 1 No (index 1) => exactly 2/3. - await relayedVote(election, voting[0], group, 0); - await relayedVote(election, voting[1], group, 0); - await relayedVote(election, voting[2], group, 1); + await relayedVote(election, voting[0], 0); + await relayedVote(election, voting[1], 0); + await relayedVote(election, voting[2], 1); await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); - await (await election.connect(organizer).publishResults("QmTwoThirds", [2n, 1n, 0n])).wait(); - + const proven = await proveTally(election, tally.secrets); + expect(proven.counts).to.deep.equal([2n, 1n, 0n]); + await (await election.connect(organizer).publishResults("QmTwoThirds", proven.counts, proven.proof)).wait(); expect(await election.outcome()).to.equal(Outcome.APPROVED); }); + it("uses the larger circuit for an election with more options", async function () { + this.timeout(600_000); + + const election = await createElection({ numOptions: 6n }); + expect(await election.circuitSlots()).to.equal(9n); + const voting: Identity[] = []; + for (const seed of ["l1", "l2"]) voting.push(await enrolPrivately(election, await newVoter("large-" + seed))); + await networkHelpers.time.increaseTo((await election.voteStart()) + 1n); + const { gas } = await relayedVote(election, voting[0], 5); + console.log(` relayed ballot gas (9 slots): ${gas}`); + await relayedVote(election, voting[1], 6); // the blank vote + + await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); + const proven = await proveTally(election, tally.secrets); + expect(proven.counts).to.deep.equal([0n, 0n, 0n, 0n, 0n, 1n, 1n]); + await (await election.connect(organizer).publishResults("", proven.counts, proven.proof)).wait(); + expect(await election.tally()).to.deep.equal(proven.counts); + }); + it("keeps every voter's call arriving from the paymaster, never from the voter", async function () { this.timeout(180_000); const election = await createElection(); - const address = await election.getAddress(); const voter = await newVoter("unlinkable"); - const voting = await enrolPrivately(election, voter); - const enrollTx = await (await election.queryFilter(election.filters.MemberEnrolled()))[0] - .getTransaction(); + const enrollTx = await (await election.queryFilter(election.filters.MemberEnrolled()))[0].getTransaction(); await networkHelpers.time.increaseTo((await election.voteStart()) + 1n); - const group = new Group([voting.commitment]); - await relayedVote(election, voting, group, 0); - - const voteTx = await (await election.queryFilter(election.filters.VoteCast()))[0].getTransaction(); - const paymasterAddress = await stack.paymaster.getAddress(); + await relayedVote(election, voting, 0); + const voteTx = await (await election.queryFilter(election.filters.BallotCast()))[0].getTransaction(); + const paymasterAddress = await paymaster.getAddress(); // Both operations were sent TO the paymaster, so the on-chain trace exposes // no address that belongs to this voter and links enrollment to ballot. @@ -496,9 +372,8 @@ describe("E2E, real Groth16 proofs, real Paillier, relayed like production", () expect(voteTx.to).to.equal(paymasterAddress); // And the leaf is not the voter's platform identity, so the enrolment - // cannot be matched against the registry either, nor against the same - // person's enrolment anywhere else. + // cannot be matched against the registry either. expect(await election.hasMember(voter.identity.commitment)).to.equal(false); - expect(await stack.registry.nullifierOf(voting.commitment)).to.equal(0n); + expect(await registry.nullifierOf(voting.commitment)).to.equal(0n); }); }); diff --git a/contracts/test/Election.test.ts b/contracts/test/Election.test.ts index 5d118b9..8babc83 100644 --- a/contracts/test/Election.test.ts +++ b/contracts/test/Election.test.ts @@ -9,12 +9,15 @@ import { Outcome, DUMMY_PROOF, ZERO_ADDRESS, + makeBallot, + testTallySecrets, + type CastVote, type Stack, } from "./fixtures.js"; +import { decryptTally, unflattenPoints } from "../../frontend/src/lib/ballotCrypto.js"; const { ethers, networkHelpers } = await network.create(); -const FORWARDER = ethers.Wallet.createRandom().address; let stack: Stack; let organizer: any; @@ -22,7 +25,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 +35,30 @@ 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)); +} + +/// Publishes with a proof the mock tally verifier accepts. +function publishCall(election: any, counts: bigint[]) { + return election.connect(organizer).publishResults("cid", counts, DUMMY_PROOF); +} + +/// Casts one ballot for `choice`, cancelling `previous` when given. +async function castFor(election: any, choice: number, previous: CastVote | null = null): Promise { + const { ballot, vote } = await makeBallot(election, choice, { previous }); + await (await election.connect(voter).castVote(ballot, DUMMY_PROOF)).wait(); + return vote; +} + +/// What the contract's aggregate decrypts to under the test keys. +async function decryptAggregate(election: any): Promise { + const [a, b] = await election.aggregate(); + const numOptions: bigint = await election.numOptions(); + return decryptTally( + testTallySecrets(numOptions), + { a: [a[0], a[1]], b: unflattenPoints(b) }, + Number(await election.voteCount()), + ); } let commitmentSeq = 1_000n; @@ -44,14 +70,12 @@ function nextCommitment(): { nullifier: bigint; commitment: bigint } { describe("ElectionV4, config validation", () => { it("rejects invalid time windows and option counts", async () => { const now = await networkHelpers.time.latest(); - const Election = await ethers.getContractFactory("ElectionV4", { - libraries: { "PoseidonT3": stack.poseidonAddress }, - }); + const Election = await ethers.getContractFactory("ElectionV4", { libraries: stack.libraries }); const deployWith = (cfg: ReturnType) => Election.deploy( - FORWARDER, - stack.verifier.getAddress(), + stack.ballotVerifiers.at(-1).getAddress(), + stack.tallyVerifiers.at(-1).getAddress(), stack.registry.getAddress(), ZERO_ADDRESS, organizer.address, @@ -86,14 +110,12 @@ describe("ElectionV4, config validation", () => { it("rejects names and metadata outside the byte bounds", async () => { const now = await networkHelpers.time.latest(); - const Election = await ethers.getContractFactory("ElectionV4", { - libraries: { "PoseidonT3": stack.poseidonAddress }, - }); + const Election = await ethers.getContractFactory("ElectionV4", { libraries: stack.libraries }); const deployWith = (cfg: ReturnType) => Election.deploy( - FORWARDER, - stack.verifier.getAddress(), + stack.ballotVerifiers.at(-1).getAddress(), + stack.tallyVerifiers.at(-1).getAddress(), stack.registry.getAddress(), ZERO_ADDRESS, organizer.address, @@ -183,7 +205,10 @@ describe("ElectionV4, enrollment", () => { expect(await election.memberCount()).to.equal(1n); expect(await election.hasMember(commitment)).to.equal(true); - expect(await election.rootTimestamps(await election.merkleTreeRoot())).to.be.greaterThan(0n); + // The root it replaced, the empty tree's, is recorded as superseded now, so + // a proof built against it stays valid for the window; the current one is not. + expect(await election.rootTimestamps(0n)).to.be.greaterThan(0n); + expect(await election.rootTimestamps(await election.merkleTreeRoot())).to.equal(0n); // Duplicate enrollment await expect(election.connect(voter).enroll(commitment)).to.be.revertedWithCustomError( @@ -247,66 +272,181 @@ describe("ElectionV4, enrollment", () => { }); describe("ElectionV4, voting", () => { + async function votingElection(overrides = {}): Promise { + const election = await freshElection(overrides); + const { nullifier, commitment } = nextCommitment(); + await platformRegister(nullifier, commitment); + await (await election.connect(voter).enroll(commitment)).wait(); + await networkHelpers.time.increaseTo(await election.voteStart()); + return election; + } + it("full happy path: enroll, vote, re-vote (coercion resistance), close", async () => { const election = await freshElection(); const { nullifier, commitment } = nextCommitment(); await platformRegister(nullifier, commitment); await (await election.connect(voter).enroll(commitment)).wait(); - const root = await election.merkleTreeRoot(); - // Voting not open yet - await expect( - election.connect(voter).castVote("0x01", nullifier, root, 1n, DUMMY_PROOF.pA, DUMMY_PROOF.pB, DUMMY_PROOF.pC), - ).to.be.revertedWithCustomError(election, "VotingNotOpen"); + const early = await makeBallot(election, 0); + await expect(election.connect(voter).castVote(early.ballot, DUMMY_PROOF)).to.be.revertedWithCustomError( + election, + "VotingNotOpen", + ); await networkHelpers.time.increaseTo(await election.voteStart()); expect(await election.phase()).to.equal(Phase.ACTIVE); - // First vote (nonce 0) - await expect( - election.connect(voter).castVote("0x0111", nullifier, root, 1n, DUMMY_PROOF.pA, DUMMY_PROOF.pB, DUMMY_PROOF.pC), - ).to.emit(election, "VoteCast"); - expect(await election.nullifierNonces(nullifier)).to.equal(1n); + const first = await makeBallot(election, 0); + await expect(election.connect(voter).castVote(first.ballot, DUMMY_PROOF)).to.emit(election, "BallotCast"); + const rootAfterFirst = await election.ballotsRoot(); + expect(rootAfterFirst).to.not.equal(0n); - // Re-vote (nonce 1): coercion resistance: no revert on duplicate nullifier - await expect( - election.connect(voter).castVote("0x0222", nullifier, root, 1n, DUMMY_PROOF.pA, DUMMY_PROOF.pB, DUMMY_PROOF.pC), - ).to.emit(election, "VoteCast"); - expect(await election.nullifierNonces(nullifier)).to.equal(2n); + // The re-vote carries a cancellation of the first ballot and looks like any + // other ballot: nothing on chain says it is a second one. + const second = await makeBallot(election, 2, { previous: first.vote }); + await expect(election.connect(voter).castVote(second.ballot, DUMMY_PROOF)).to.emit(election, "BallotCast"); expect(await election.voteCount()).to.equal(2n); + expect(await decryptAggregate(election)).to.deep.equal([0n, 0n, 1n, 0n]); // After voteEnd await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); expect(await election.phase()).to.equal(Phase.TALLYING); - await expect( - election.connect(voter).castVote("0x0333", nullifier, root, 1n, DUMMY_PROOF.pA, DUMMY_PROOF.pB, DUMMY_PROOF.pC), - ).to.be.revertedWithCustomError(election, "VotingNotOpen"); + const late = await makeBallot(election, 1, { previous: second.vote }); + await expect(election.connect(voter).castVote(late.ballot, DUMMY_PROOF)).to.be.revertedWithCustomError( + election, + "VotingNotOpen", + ); }); - it("rejects unknown merkle roots and out-of-range depths", async () => { - const election = await freshElection(); - const { nullifier, commitment } = nextCommitment(); - await platformRegister(nullifier, commitment); - await (await election.connect(voter).enroll(commitment)).wait(); - const root = await election.merkleTreeRoot(); + it("keeps an aggregate that decrypts to each voter's LAST choice", async () => { + const election = await votingElection(); + // One voter votes once; one changes 2 -> 1; one goes 1, 1, then blank. + await castFor(election, 0); + await castFor(election, 1, await castFor(election, 2)); + await castFor(election, 3, await castFor(election, 1, await castFor(election, 1))); - await networkHelpers.time.increaseTo(await election.voteStart()); + expect(await election.voteCount()).to.equal(6n); + expect(await decryptAggregate(election)).to.deep.equal([1n, 1n, 0n, 1n]); + }); - await expect( - election.castVote("0x01", nullifier, 999999n, 1n, DUMMY_PROOF.pA, DUMMY_PROOF.pB, DUMMY_PROOF.pC), - ).to.be.revertedWithCustomError(election, "UnknownOrExpiredRoot"); + it("files each ballot as a leaf of the ballots tree, for the next step to prove against", async () => { + const election = await votingElection(); + const { ballot } = await makeBallot(election, 0); + await expect(election.connect(voter).castVote(ballot, DUMMY_PROOF)) + .to.emit(election, "BallotCast") + .withArgs(ballot.tag, 0n, ballot.leaf, ballot.voteA, ballot.voteB, ballot.cancelA, ballot.cancelB, (t: bigint) => t > 0n); + expect(await election.ballotsDepth()).to.equal(0n); + expect(await election.ballotsRoot()).to.equal(ballot.leaf); // a one-leaf LeanIMT's root is the leaf + }); - await expect( - election.castVote("0x01", nullifier, root, 0n, DUMMY_PROOF.pA, DUMMY_PROOF.pB, DUMMY_PROOF.pC), - ).to.be.revertedWithCustomError(election, "InvalidTreeDepth"); + it("casts each step of a chain once: a tag is refused the second time", async () => { + const election = await votingElection(); + const { ballot } = await makeBallot(election, 0, { tag: 777n }); + await (await election.connect(voter).castVote(ballot, DUMMY_PROOF)).wait(); + const again = await makeBallot(election, 1, { tag: 777n }); + await expect(election.connect(voter).castVote(again.ballot, DUMMY_PROOF)).to.be.revertedWithCustomError( + election, + "TagAlreadyCast", + ); + }); - await expect( - election.castVote("0x01", nullifier, root, 33n, DUMMY_PROOF.pA, DUMMY_PROOF.pB, DUMMY_PROOF.pC), - ).to.be.revertedWithCustomError(election, "InvalidTreeDepth"); + it("takes one ballot per voter per epoch, and the next epoch takes another", async () => { + const election = await votingElection({ voteEnd: (await networkHelpers.time.latest()) + 100_000 }); + const first = await makeBallot(election, 0, { epochTag: 888n }); + await (await election.connect(voter).castVote(first.ballot, DUMMY_PROOF)).wait(); + const second = await makeBallot(election, 1, { previous: first.vote, epochTag: 888n }); + await expect(election.connect(voter).castVote(second.ballot, DUMMY_PROOF)).to.be.revertedWithCustomError( + election, + "EpochAlreadyCast", + ); + + await networkHelpers.time.increase(Number(await election.EPOCH_LENGTH())); + const later = await makeBallot(election, 1, { previous: first.vote, epochTag: 889n }); + await expect(election.connect(voter).castVote(later.ballot, DUMMY_PROOF)).to.emit(election, "BallotCast"); }); - it("accepts recent superseded roots but rejects expired ones", async () => { + it("accepts a proof for this epoch or the last, and no other", async () => { + const election = await votingElection({ voteEnd: (await networkHelpers.time.latest()) + 100_000 }); + await networkHelpers.time.increase(3 * Number(await election.EPOCH_LENGTH())); + const epoch: bigint = await election.currentEpoch(); + + for (const wrong of [epoch + 1n, epoch - 2n]) { + const { ballot } = await makeBallot(election, 0, { epoch: wrong }); + await expect(election.connect(voter).castVote(ballot, DUMMY_PROOF)).to.be.revertedWithCustomError( + election, + "WrongEpoch", + ); + } + const { ballot } = await makeBallot(election, 0, { epoch: epoch - 1n }); + await expect(election.connect(voter).castVote(ballot, DUMMY_PROOF)).to.emit(election, "BallotCast"); + }); + + it("rejects unknown voter and ballot roots", async () => { + const election = await votingElection(); + const voters = await makeBallot(election, 0); + voters.ballot.votersRoot = 999_999n; + await expect(election.castVote(voters.ballot, DUMMY_PROOF)).to.be.revertedWithCustomError( + election, + "UnknownOrExpiredRoot", + ); + const ballots = await makeBallot(election, 0); + ballots.ballot.ballotsRoot = 999_999n; + await expect(election.castVote(ballots.ballot, DUMMY_PROOF)).to.be.revertedWithCustomError( + election, + "UnknownOrExpiredRoot", + ); + }); + + it("keeps a superseded ballots root valid for a while, so a busy election does not strand proofs", async () => { + const election = await votingElection(); + await castFor(election, 0); + // Built against the root of that moment, then overtaken by another ballot. + const stale = await makeBallot(election, 1); + await castFor(election, 2); + expect(await election.ballotsRoot()).to.not.equal(stale.ballot.ballotsRoot); + await expect(election.castVote(stale.ballot, DUMMY_PROOF)).to.emit(election, "BallotCast"); + }); + + it("times a root's validity from when it was replaced, not from when it was made", async () => { + // A quiet election: one ballot, then nothing for longer than the window. + const election = await votingElection({ voteEnd: (await networkHelpers.time.latest()) + 100_000 }); + await castFor(election, 0); + await networkHelpers.time.increase(Number(await election.MERKLE_ROOT_VALIDITY()) + 60); + + // A voter proves against the current root, and someone else votes first. + // Their proof must survive: the root was current until a moment ago. + const pending = await makeBallot(election, 1); + await castFor(election, 2); + await expect(election.castVote(pending.ballot, DUMMY_PROOF)).to.emit(election, "BallotCast"); + + // Once the window has passed since it was replaced, it is refused. + const late = await makeBallot(election, 1); + await castFor(election, 0); + await networkHelpers.time.increase(Number(await election.MERKLE_ROOT_VALIDITY()) + 60); + await expect(election.castVote(late.ballot, DUMMY_PROOF)).to.be.revertedWithCustomError( + election, + "UnknownOrExpiredRoot", + ); + }); + + it("refuses a ballot whose proof does not verify, or whose arrays are the wrong size", async () => { + const election = await votingElection(); + const verifier = await ethers.getContractAt("MockBallotVerifier", await election.ballotVerifier()); + await (await verifier.setAccept(false)).wait(); + try { + const { ballot } = await makeBallot(election, 0); + await expect(election.castVote(ballot, DUMMY_PROOF)).to.be.revertedWithCustomError(election, "InvalidProof"); + } finally { + await (await verifier.setAccept(true)).wait(); + } + + const { ballot } = await makeBallot(election, 0); + ballot.voteB = ballot.voteB.slice(2); + await expect(election.castVote(ballot, DUMMY_PROOF)).to.be.revertedWithCustomError(election, "InvalidProof"); + }); + + it("accepts recent superseded voter roots but rejects expired ones", async () => { const now = await networkHelpers.time.latest(); // Long voting window so we can play with the root validity clock const election = await freshElection({ @@ -331,14 +471,17 @@ describe("ElectionV4, voting", () => { // The superseded root was created long ago (enrollment happened at t≈now, // voting starts at now+100_000) so it is already expired. - await expect( - election.castVote("0x01", a.nullifier, oldRoot, 1n, DUMMY_PROOF.pA, DUMMY_PROOF.pB, DUMMY_PROOF.pC), - ).to.be.revertedWithCustomError(election, "UnknownOrExpiredRoot"); + const stale = await makeBallot(election, 0); + stale.ballot.votersRoot = oldRoot; + await expect(election.castVote(stale.ballot, DUMMY_PROOF)).to.be.revertedWithCustomError( + election, + "UnknownOrExpiredRoot", + ); // The current root always works - await expect( - election.castVote("0x01", a.nullifier, newRoot, 1n, DUMMY_PROOF.pA, DUMMY_PROOF.pB, DUMMY_PROOF.pC), - ).to.emit(election, "VoteCast"); + const { ballot } = await makeBallot(election, 0); + expect(ballot.votersRoot).to.equal(newRoot); + await expect(election.castVote(ballot, DUMMY_PROOF)).to.emit(election, "BallotCast"); }); }); @@ -404,53 +547,116 @@ describe("ElectionV4, organizer lifecycle", () => { await expect(election.connect(organizer).markVoided()).to.emit(election, "ElectionVoided"); expect(await election.phase()).to.equal(Phase.VOIDED); - await expect( - election.connect(organizer).publishResults("cid", [1n, 1n, 1n, 0n]), - ).to.be.revertedWithCustomError(election, "AlreadyDecided"); + await expect(publishCall(election, [1n, 1n, 1n, 0n])).to.be.revertedWithCustomError( + election, + "AlreadyDecided", + ); }); -}); -describe("ElectionV4, privacy quorum", () => { - /** Enrolls and votes once, so the election has exactly `n` distinct voters. */ - async function withVoters(election: any, n: number): Promise { - const ids = []; - for (let i = 0; i < n; i++) { - const id = nextCommitment(); - await platformRegister(id.nullifier, id.commitment); - await (await election.connect(voter).enroll(id.commitment)).wait(); - ids.push(id); - } + 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(); - for (const id of ids) { - await ( - await election - .connect(voter) - .castVote("0x01", id.nullifier, root, 1n, DUMMY_PROOF.pA, DUMMY_PROOF.pB, DUMMY_PROOF.pC) - ).wait(); - } + const { ballot } = await makeBallot(election, 0); + + await networkHelpers.time.setNextBlockTimestamp(await election.voteEnd()); + await expect(election.connect(voter).castVote(ballot, DUMMY_PROOF)).to.be.revertedWithCustomError( + election, + "VotingNotOpen", + ); + // The refused call mined nothing, so the pinned timestamp is still pending. + await networkHelpers.mine(); + }); +}); + +/// Enrolls `n` voters and has each cast one ballot for option 0. +async function withVoters(election: any, n: number): Promise { + for (let i = 0; i < n; i++) { + const id = nextCommitment(); + await platformRegister(id.nullifier, id.commitment); + await (await election.connect(voter).enroll(id.commitment)).wait(); } + await networkHelpers.time.increaseTo(await election.voteStart()); + for (let i = 0; i < n; i++) await castFor(election, 0); +} - it("counts people, not ballots, however often they re-vote", async () => { - const election = await freshElection(); +describe("ElectionV4, voiding an election that promised no veto", () => { + it("refuses to void once cancelling was given up and enough ballots exist for a result", async () => { + const election = await freshElection({ cancellable: false, privacyQuorum: 2n }); + await withVoters(election, 2); + await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); + await expect(election.connect(organizer).markVoided()).to.be.revertedWithCustomError( + election, + "ResultPublishable", + ); + }); + + it("voids without a proof when fewer ballots than the quorum were cast", async () => { + const election = await freshElection({ cancellable: false, privacyQuorum: 3n }); + await withVoters(election, 2); + await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); + 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 freshElection({ cancellable: true }); + await withVoters(election, 2); + await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); + await expect(election.connect(organizer).markVoided()).to.emit(election, "ElectionVoided"); + }); +}); + +describe("ElectionV4, voiding below the quorum with a proof", () => { + it("voids when the tally proof says fewer voted than the quorum, and keeps the counts secret", async () => { + // Four ballots, which might be four voters: only the proof can say it was two. + const election = await freshElection({ cancellable: false, privacyQuorum: 3n }); + await withVoters(election, 4); + await expect(election.voidBelowQuorum(2n, DUMMY_PROOF)).to.be.revertedWithCustomError( + election, + "VotingNotEnded", + ); + await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); + + await expect(election.voidBelowQuorum(2n, DUMMY_PROOF)) + .to.emit(election, "VoidedBelowQuorum") + .withArgs(2n); + expect(await election.phase()).to.equal(Phase.VOIDED); + expect(await election.voters()).to.equal(2n); + expect(await election.tally()).to.deep.equal([]); + }); + + it("refuses a proof the tally verifier rejects", async () => { + const election = await freshElection({ privacyQuorum: 3n }); + await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); + const verifier = await ethers.getContractAt("MockTallyVerifier", await election.tallyVerifier()); + await (await verifier.setAccept(false)).wait(); + try { + await expect(election.voidBelowQuorum(2n, DUMMY_PROOF)).to.be.revertedWithCustomError(election, "InvalidProof"); + } finally { + await (await verifier.setAccept(true)).wait(); + } + }); +}); + +describe("ElectionV4, privacy quorum", () => { + it("measures the quorum on the voters the tally counts, not on ballots", async () => { + const election = await freshElection({ privacyQuorum: 2n }); 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(); - - for (const ballot of ["0x01", "0x02", "0x03"]) { - await ( - await election - .connect(voter) - .castVote(ballot, nullifier, root, 1n, DUMMY_PROOF.pA, DUMMY_PROOF.pB, DUMMY_PROOF.pC) - ).wait(); - } + await castFor(election, 1, await castFor(election, 0, await castFor(election, 2))); // Three ballots, one person. The quorum is about how many people a result - // would expose, so re-voting must not inflate it. + // would expose, so re-voting must not satisfy it. expect(await election.voteCount()).to.equal(3n); - expect(await election.distinctVoters()).to.equal(1n); + await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); + await expect(publishCall(election, [0n, 1n, 0n, 0n])).to.be.revertedWithCustomError( + election, + "PrivacyQuorumNotMet", + ); }); it("refuses to publish a result that rests on too few voters", async () => { @@ -458,9 +664,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 () => { @@ -472,43 +679,47 @@ describe("ElectionV4, privacy quorum", () => { expect(await election.phase()).to.equal(Phase.VOIDED); }); - it("publishes once the quorum is reached", async () => { + it("publishes once the quorum is reached, and records how many voted", async () => { const election = await freshElection({ privacyQuorum: 3n }); 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); + expect(await election.voters()).to.equal(3n); }); it("is off by default, so an election that sets no floor behaves as before", async () => { 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); }); + + it("refuses a quorum the tally circuit cannot compare against", async () => { + await expect(freshElection({ privacyQuorum: 1n << 32n })).to.be.revertedWithCustomError( + await ethers.getContractFactory("ElectionV4", { libraries: stack.libraries }), + "InvalidConfig", + ); + }); }); describe("ElectionV4, option ceiling", () => { - it("refuses more options than the tally encoding can carry", async () => { + it("refuses more options than the largest circuit holds", async () => { const now = await networkHelpers.time.latest(); - const Election = await ethers.getContractFactory("ElectionV4", { - libraries: { "PoseidonT3": stack.poseidonAddress }, - }); + const Election = await ethers.getContractFactory("ElectionV4", { libraries: stack.libraries }); const deployWith = (cfg: ReturnType) => Election.deploy( - FORWARDER, - stack.verifier.getAddress(), + stack.ballotVerifiers.at(-1).getAddress(), + stack.tallyVerifiers.at(-1).getAddress(), stack.registry.getAddress(), ZERO_ADDRESS, organizer.address, cfg, ); - // 51 counters of 12 digits would pass the 2048-bit Paillier modulus, and a - // tally that wraps is worse than one that was never allowed to exist. + // The largest circuit built holds 50 options and the blank vote. await expect(deployWith(baseConfig(now, { numOptions: 51n }))) .to.be.revertedWithCustomError(Election, "InvalidConfig"); @@ -518,23 +729,104 @@ describe("ElectionV4, option ceiling", () => { }); }); +describe("ElectionV4, tally keys and verifiers", () => { + const deploy = async (cfg: ReturnType, sizeIndex = 0) => { + const Election = await ethers.getContractFactory("ElectionV4", { libraries: stack.libraries }); + return Election.deploy( + stack.ballotVerifiers[sizeIndex].getAddress(), + stack.tallyVerifiers[sizeIndex].getAddress(), + stack.registry.getAddress(), + ZERO_ADDRESS, + organizer.address, + cfg, + ); + }; + + it("hashes the keys exactly as the circuits do, padded to the circuit's size", async () => { + const { keysHash } = await import("../../frontend/src/lib/ballotCrypto.js"); + const { poseidon3 } = await import("poseidon-lite"); + const now = await networkHelpers.time.latest(); + const cfg = baseConfig(now); + const election = await deploy(cfg); + const slots = Number(await election.circuitSlots()); + expect(slots).to.equal(5); + expect(await election.keysHash()).to.equal(keysHash(poseidon3, unflattenPoints(cfg.tallyKeys), slots)); + expect(await election.tallyKeys()).to.deep.equal(cfg.tallyKeys); + }); + + it("refuses keys that are missing, off the curve or the identity", async () => { + const now = await networkHelpers.time.latest(); + const Election = await ethers.getContractFactory("ElectionV4", { libraries: stack.libraries }); + const good = baseConfig(now).tallyKeys; + for (const tallyKeys of [good.slice(2), [...good.slice(0, -1), good.at(-1)! + 1n], [0n, 1n, ...good.slice(2)]]) { + await expect(deploy(baseConfig(now, { tallyKeys }))).to.be.revertedWithCustomError(Election, "InvalidConfig"); + } + }); + + it("refuses verifiers too small for the options, or of two different sizes", async () => { + const now = await networkHelpers.time.latest(); + const Election = await ethers.getContractFactory("ElectionV4", { libraries: stack.libraries }); + // Five options and the blank vote do not fit a five-slot circuit. + await expect(deploy(baseConfig(now, { numOptions: 5n }))).to.be.revertedWithCustomError( + Election, + "VerifierMismatch", + ); + await expect( + Election.deploy( + stack.ballotVerifiers[0].getAddress(), + stack.tallyVerifiers[1].getAddress(), + stack.registry.getAddress(), + ZERO_ADDRESS, + organizer.address, + baseConfig(now), + ), + ).to.be.revertedWithCustomError(Election, "VerifierMismatch"); + }); + + it("derives its scope from its own address, never from the organizer", async () => { + const now = await networkHelpers.time.latest(); + const a = await deploy(baseConfig(now)); + const b = await deploy(baseConfig(now)); + expect(await a.scope()).to.not.equal(await b.scope()); + const { chainId } = await ethers.provider.getNetwork(); + const expected = + BigInt(ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["uint256", "address"], [chainId, await a.getAddress()]))) >> 8n; + expect(await a.scope()).to.equal(expected); + }); +}); + describe("ElectionV4, publishResults & outcomes", () => { + /// Publishes `tallyArr`; the mock tally verifier stands in for the proof. 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.connect(organizer).publishResults("QmTestCid", tallyArr, DUMMY_PROOF)).wait(); } 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( - election.connect(organizer).publishResults("cid", [1n, 2n, 3n]), - ).to.be.revertedWithCustomError(election, "InvalidTally"); + await expect(publishCall(election, [0n, 0n, 0n])).to.be.revertedWithCustomError( + election, + "InvalidTally", + ); + }); + + it("refuses counts the tally proof does not vouch for", async () => { + const election = await freshElection(); + await networkHelpers.time.increaseTo((await election.voteEnd()) + 1n); + const verifier = await ethers.getContractAt("MockTallyVerifier", await election.tallyVerifier()); + await (await verifier.setAccept(false)).wait(); + try { + await expect(publishCall(election, [0n, 0n, 0n, 0n])).to.be.revertedWithCustomError(election, "InvalidProof"); + } finally { + await (await verifier.setAccept(true)).wait(); + } }); it("SIMPLE_PLURALITY: highest vote count wins; equal top is a tie", async () => { @@ -628,9 +920,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 () => { @@ -717,7 +1010,7 @@ describe("ElectionV4, enrollment opened early", () => { * * `closeEnrollmentEarly` and `closeVotingEarly` read as conveniences until you * notice what the organizer can see while using them: `memberCount` and - * `distinctVoters` are public and rise in real time. So the roll can be cut off + * `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. * Neither leaves a trace saying what it was for. * diff --git a/contracts/test/Eligibility.test.ts b/contracts/test/Eligibility.test.ts index c42c789..5a7b0d9 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 { @@ -81,14 +79,12 @@ async function futureDeadline(): Promise { describe("ElectionV4, eligibility config", () => { it("rejects an attester without a policy hash, and a policy hash without an attester", async () => { const now = await networkHelpers.time.latest(); - const Election = await ethers.getContractFactory("ElectionV4", { - libraries: { PoseidonT3: stack.poseidonAddress }, - }); + const Election = await ethers.getContractFactory("ElectionV4", { libraries: stack.libraries }); const deployWith = (cfg: ReturnType) => Election.deploy( - FORWARDER, - stack.verifier.getAddress(), + stack.ballotVerifiers[0].getAddress(), + stack.tallyVerifiers[0].getAddress(), stack.registry.getAddress(), ZERO_ADDRESS, organizer.address, @@ -108,12 +104,10 @@ describe("ElectionV4, eligibility config", () => { /** The raw constructor, for the configurations the factory would never build. */ async function deployRaw(cfg: ReturnType) { - const Election = await ethers.getContractFactory("ElectionV4", { - libraries: { PoseidonT3: stack.poseidonAddress }, - }); + const Election = await ethers.getContractFactory("ElectionV4", { libraries: stack.libraries }); return Election.deploy( - FORWARDER, - stack.verifier.getAddress(), + stack.ballotVerifiers[0].getAddress(), + stack.tallyVerifiers[0].getAddress(), stack.registry.getAddress(), ZERO_ADDRESS, organizer.address, @@ -140,9 +134,7 @@ describe("ElectionV4, eligibility config", () => { }), ), ).to.be.revertedWithCustomError( - await ethers.getContractFactory("ElectionV4", { - libraries: { PoseidonT3: stack.poseidonAddress }, - }), + await ethers.getContractFactory("ElectionV4", { libraries: stack.libraries }), "InvalidConfig", ); }); @@ -152,9 +144,7 @@ describe("ElectionV4, eligibility config", () => { // attestation, so a level above DEVICE with nobody to sign one is a // configuration whose voters could never enroll. const now = await networkHelpers.time.latest(); - const Election = await ethers.getContractFactory("ElectionV4", { - libraries: { PoseidonT3: stack.poseidonAddress }, - }); + const Election = await ethers.getContractFactory("ElectionV4", { libraries: stack.libraries }); for (const level of [1, 2]) { await expect( deployRaw(baseConfig(now, { personhood: level })), @@ -219,7 +209,8 @@ describe("ElectionV4, attested enrollment", () => { expect(await election.memberCount()).to.equal(1n); // The root the event announced is the one the tree now reports, so a voter // building a proof from the event lands on a root the contract accepts. - expect(await election.rootTimestamps(await election.merkleTreeRoot())).to.be.greaterThan(0n); + const [event] = await election.queryFilter(election.filters.MemberEnrolled()); + expect((event as any).args.merkleTreeRoot).to.equal(await election.merkleTreeRoot()); }); it("closes the bypass: plain enroll is refused once a policy is declared", async () => { diff --git a/contracts/test/Factory.test.ts b/contracts/test/Factory.test.ts index b6a8f03..71e5047 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", () => { @@ -54,7 +53,41 @@ describe("ElectionFactory", () => { expect(await election.organizer()).to.equal(organizer.address); expect(await election.name()).to.equal("Presidential 2026"); expect(await election.votingType()).to.equal(BigInt(VotingType.SIMPLE_PLURALITY)); - expect(await election.scope()).to.equal(cfg.scope); + // Three options and the blank vote: the smallest circuit, five slots. + expect(await election.circuitSlots()).to.equal(5n); + expect(await election.ballotVerifier()).to.equal(await stack.ballotVerifiers[0].getAddress()); + expect(await election.tallyVerifier()).to.equal(await stack.tallyVerifiers[0].getAddress()); + }); + + it("gives each election the smallest circuit that holds its options, and refuses past the largest", async () => { + const [b5] = await stack.factory.verifiersFor(4n); + const [b9, t9] = await stack.factory.verifiersFor(5n); + const [b51] = await stack.factory.verifiersFor(50n); + expect(b5).to.equal(await stack.ballotVerifiers[0].getAddress()); + expect(b9).to.equal(await stack.ballotVerifiers[1].getAddress()); + expect(t9).to.equal(await stack.tallyVerifiers[1].getAddress()); + expect(b51).to.equal(await stack.ballotVerifiers[2].getAddress()); + await expect(stack.factory.verifiersFor(51n)).to.be.revertedWithCustomError(stack.factory, "TooManyOptions"); + }); + + it("refuses verifier lists that are empty, unequal or out of size order", async () => { + const Factory = await ethers.getContractFactory("ElectionFactory"); + const [b5, b9] = [await stack.ballotVerifiers[0].getAddress(), await stack.ballotVerifiers[1].getAddress()]; + const [t5, t9] = [await stack.tallyVerifiers[0].getAddress(), await stack.tallyVerifiers[1].getAddress()]; + const deploy = (ballots: string[], tallies: string[]) => + Factory.deploy( + stack.paymaster.getAddress(), + stack.factory.deployer(), + ballots, + tallies, + stack.registry.getAddress(), + ethers.ZeroAddress, + ); + await expect(deploy([], [])).to.be.revertedWithCustomError(Factory, "BadVerifiers"); + await expect(deploy([b5, b9], [t5])).to.be.revertedWithCustomError(Factory, "BadVerifiers"); + await expect(deploy([b9, b5], [t9, t5])).to.be.revertedWithCustomError(Factory, "BadVerifiers"); + await expect(deploy([b5, b9], [t9, t5])).to.be.revertedWithCustomError(Factory, "BadVerifiers"); + await expect(deploy([b5, b9], [t5, t9])).to.not.be.revert(ethers); }); it("paginates the elections list", async () => { diff --git a/contracts/test/Paymaster.test.ts b/contracts/test/Paymaster.test.ts index f6796ee..d7df444 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, makeBallot, 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,112 @@ 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, 6_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, 6_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 ballots", () => { + /** + * The paymaster no longer keeps a per-voter cooldown: it keyed on the + * Semaphore nullifier, which made every voter's ballots publicly linkable. + * The election's epoch tags bound the same rate, so a relayed voter gets one + * sponsored ballot per epoch and the relay learns nothing about whose. + */ + it("relays and reimburses a ballot, and one voter gets one per epoch", async () => { + const now = await networkHelpers.time.latest(); + await ( + await stack.factory + .connect(organizer) + .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 reserved = await stack.paymaster.reservedFor(address); + const first = await makeBallot(election, 0, { epochTag: 5_001n }); + await expect(stack.paymaster.connect(relayer).relayVote(address, first.ballot, DUMMY_PROOF)) + .to.emit(election, "BallotCast") + .and.to.emit(stack.paymaster, "VoteSponsored"); + expect(await stack.paymaster.reservedFor(address)).to.be.lessThan(reserved); + + const again = await makeBallot(election, 1, { previous: first.vote, epochTag: 5_001n }); + await expect( + stack.paymaster.connect(relayer).relayVote(address, again.ballot, DUMMY_PROOF), + ).to.be.revertedWithCustomError(election, "EpochAlreadyCast"); + + // The override is delayed, never refused: next epoch, it lands. + await networkHelpers.time.increase(Number(await election.EPOCH_LENGTH())); + const later = await makeBallot(election, 1, { previous: first.vote, epochTag: 5_002n }); + await expect(stack.paymaster.connect(relayer).relayVote(address, later.ballot, DUMMY_PROOF)).to.emit( + election, + "BallotCast", + ); + expect(await election.voteCount()).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..3d58317 100644 --- a/contracts/test/fixtures.ts +++ b/contracts/test/fixtures.ts @@ -1,7 +1,17 @@ import poseidon from "poseidon-solidity"; -/// Library name used for linking (HH3 resolves the bare name when unambiguous). -export const POSEIDON_FQN = "PoseidonT3"; +import { + BASE, + encryptCancellation, + encryptVote, + flattenPoints, + IDENTITY, + multiply, + padPoints, + randomScalar, + unflattenPoints, + type Point, +} from "../../frontend/src/lib/ballotCrypto.js"; export const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; export const ZERO_HASH = "0x" + "00".repeat(32); @@ -52,22 +62,31 @@ export async function signEnrollAttestation( return attester.signTypedData(domain, types, value); } -/// Deploys the PoseidonT3 external library (required by LeanIMT) on the local -/// test network and returns its address. -export async function deployPoseidonT3(ethers: any): Promise { +/// Deploys the Poseidon libraries ElectionV4 links against (T3 for its +/// trees, T4 for its keys hash) and returns them keyed for linking. +export async function deployPoseidon(ethers: any): Promise> { const [deployer] = await ethers.getSigners(); - const tx = await deployer.sendTransaction({ data: poseidon.PoseidonT3.bytecode }); - const receipt = await tx.wait(); - if (!receipt?.contractAddress) throw new Error("PoseidonT3 deployment failed"); - return receipt.contractAddress; + const libraries: Record = {}; + for (const name of ["PoseidonT3", "PoseidonT4"] as const) { + const tx = await deployer.sendTransaction({ data: poseidon[name].bytecode }); + const receipt = await tx.wait(); + if (!receipt?.contractAddress) throw new Error(`${name} deployment failed`); + libraries[name] = receipt.contractAddress; + } + return libraries; } +/// Circuit sizes the mock verifiers stand in for: the two built by default and +/// the largest, so every option count the contract allows can be deployed. +export const MOCK_SIZES = [5, 9, 51]; + export interface Stack { registry: any; paymaster: any; - verifier: any; factory: any; - poseidonAddress: string; + ballotVerifiers: any[]; + tallyVerifiers: any[]; + libraries: Record; } /// EIP-712 payload the platform signs to authorise one PRIVATE enrollment. @@ -78,6 +97,7 @@ export function privateEnrollmentTypedData( identityCommitment: bigint, humanTag: bigint, deadline: number | bigint, + documentTag: bigint = 0n, ) { return { domain: { @@ -90,10 +110,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 +126,7 @@ export async function signPrivateEnrollment( identityCommitment: bigint, humanTag: bigint, deadline: number | bigint, + documentTag: bigint = 0n, ): Promise { const { domain, types, value } = privateEnrollmentTypedData( electionAddress, @@ -112,19 +134,19 @@ export async function signPrivateEnrollment( identityCommitment, humanTag, deadline, + documentTag, ); return attester.signTypedData(domain, types, value); } -/// Deploys the full contract stack with the MockVerifier (unit tests only). +/// Deploys the full contract stack with mock verifiers (unit tests only). /// `platformAttester` defaults to nobody, which deploys elections that enrol /// 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); + const libraries = await deployPoseidon(ethers); const Registry = await ethers.getContractFactory("PlatformRegistry"); const registry = await Registry.deploy(); @@ -134,17 +156,23 @@ export async function deployStack( const paymaster = await Paymaster.deploy(); await paymaster.waitForDeployment(); - const Verifier = await ethers.getContractFactory("MockVerifier"); - const verifier = await Verifier.deploy(); - await verifier.waitForDeployment(); + const ballotVerifiers = []; + const tallyVerifiers = []; + for (const size of MOCK_SIZES) { + ballotVerifiers.push(await (await ethers.getContractFactory("MockBallotVerifier")).deploy(size)); + tallyVerifiers.push(await (await ethers.getContractFactory("MockTallyVerifier")).deploy(size)); + } + + const Deployer = await ethers.getContractFactory("ElectionDeployer", { libraries }); + const deployer = await Deployer.deploy(); + await deployer.waitForDeployment(); - const Factory = await ethers.getContractFactory("ElectionFactory", { - libraries: { [POSEIDON_FQN]: poseidonAddress }, - }); + const Factory = await ethers.getContractFactory("ElectionFactory"); const factory = await Factory.deploy( await paymaster.getAddress(), - forwarder, - await verifier.getAddress(), + await deployer.getAddress(), + await Promise.all(ballotVerifiers.map(v => v.getAddress())), + await Promise.all(tallyVerifiers.map(v => v.getAddress())), await registry.getAddress(), platformAttester, ); @@ -153,7 +181,7 @@ export async function deployStack( // Only the factory may bind an election to the tank that funds its gas. await (await paymaster.setFactory(await factory.getAddress())).wait(); - return { registry, paymaster, verifier, factory, poseidonAddress }; + return { registry, paymaster, factory, ballotVerifiers, tallyVerifiers, libraries }; } export interface ElectionConfig { @@ -165,8 +193,8 @@ export interface ElectionConfig { enrollEnd: number; voteStart: number; voteEnd: number; - scope: bigint; - paillierPublicKey: string; + /** Flattened affine tally keys, one per option plus the blank vote. */ + tallyKeys: bigint[]; metadataJson: string; eligibilityAttester: string; eligibilityPolicyHash: string; @@ -205,9 +233,24 @@ export const Outcome = { THRESHOLD_NOT_MET: 5n, } as const; +/** + * The secrets behind `testTallyKeys`. Fixed and tiny ON PURPOSE: the unit tests + * decrypt the contract's aggregate with them to show it holds what was voted, + * and nothing about them needs to be secret. + */ +export const testTallySecrets = (numOptions: bigint): bigint[] => + Array.from({ length: Number(numOptions) + 1 }, (_, i) => BigInt(i + 2)); + +/** Valid tally keys for an election of `numOptions`: x·G for each test secret. */ +export const testTallyKeys = (numOptions: bigint): Point[] => + testTallySecrets(numOptions).map(x => multiply(BASE, x)); + /// Base election config: enrollment open now, voting starts in 1000s, ends in 2000s. +/// The tally keys follow `numOptions` unless an override supplies its own. export function baseConfig(now: number, overrides: Partial = {}): ElectionConfig { + const numOptions = overrides.numOptions ?? 3n; return { + tallyKeys: flattenPoints(testTallyKeys(numOptions)), name: "Test Election", votingType: VotingType.SIMPLE_PLURALITY, thresholdValue: 0n, @@ -216,8 +259,6 @@ export function baseConfig(now: number, overrides: Partial = {}) enrollEnd: now + 1000, voteStart: now + 1000, voteEnd: now + 2000, - scope: 42n, - paillierPublicKey: '{"n":"0x1234","g":"0x1235"}', metadataJson: "{}", eligibilityAttester: ZERO_ADDRESS, eligibilityPolicyHash: ZERO_HASH, @@ -232,21 +273,20 @@ export function baseConfig(now: number, overrides: Partial = {}) }; } -/// Deploys a standalone ElectionV4 (bypassing the factory) for focused unit tests. +/// Deploys a standalone ElectionV4 (bypassing the factory) for focused unit +/// tests, on the smallest mock verifiers that fit its options. export async function deployElection( ethers: any, stack: Stack, - forwarder: string, organizer: any, cfg: ElectionConfig, platformAttester: string = ZERO_ADDRESS, ): Promise { - const Election = await ethers.getContractFactory("ElectionV4", { - libraries: { [POSEIDON_FQN]: stack.poseidonAddress }, - }); + const Election = await ethers.getContractFactory("ElectionV4", { libraries: stack.libraries }); + const [ballotVerifier, tallyVerifier] = await stack.factory.verifiersFor(cfg.numOptions); const election = await Election.deploy( - forwarder, - await stack.verifier.getAddress(), + ballotVerifier, + tallyVerifier, await stack.registry.getAddress(), platformAttester, organizer.address, @@ -256,12 +296,53 @@ export async function deployElection( return election; } -/// Dummy Groth16 proof accepted by the MockVerifier. +/// Dummy Groth16 proof, accepted by the mock verifiers. export const DUMMY_PROOF = { - pA: [0n, 0n] as [bigint, bigint], - pB: [ + a: [0n, 0n] as [bigint, bigint], + b: [ [0n, 0n], [0n, 0n], ] as [[bigint, bigint], [bigint, bigint]], - pC: [0n, 0n] as [bigint, bigint], + c: [0n, 0n] as [bigint, bigint], }; + +/** What a voter keeps of a ballot, to cancel it with the next one. */ +export interface CastVote { + a: Point; + b: Point[]; +} + +let tagSeq = 1n; + +/** + * A ballot as the client builds it, minus the proof: a real encryption of + * `choice` under the election's keys and a real cancellation of `previous`, + * so the contract's aggregate can be decrypted and checked. Tags are fresh + * unless given, and roots and epoch are the election's current ones. + */ +export async function makeBallot( + election: any, + choice: number, + opts: { previous?: CastVote | null; tag?: bigint; epochTag?: bigint; epoch?: bigint } = {}, +): Promise<{ ballot: any; vote: CastVote }> { + const slots = Number(await election.circuitSlots()); + const keys = unflattenPoints(await election.tallyKeys()); + const vote = encryptVote(keys, choice, randomScalar()); + const cancel = encryptCancellation(keys, randomScalar(), opts.previous ?? null); + const pad = (points: Point[]) => flattenPoints(padPoints(points, slots)); + const ballot = { + votersRoot: await election.merkleTreeRoot(), + ballotsRoot: await election.ballotsRoot(), + epoch: opts.epoch ?? (await election.currentEpoch()), + tag: opts.tag ?? tagSeq++, + epochTag: opts.epochTag ?? tagSeq++, + leaf: tagSeq++, + voteA: [...vote.a], + voteB: pad(vote.b), + cancelA: [...cancel.a], + cancelB: pad(cancel.b), + }; + return { ballot, vote }; +} + +export { IDENTITY }; diff --git a/contracts/tsconfig.json b/contracts/tsconfig.json index b3c86ea..81e9955 100644 --- a/contracts/tsconfig.json +++ b/contracts/tsconfig.json @@ -9,6 +9,13 @@ "esModuleInterop": true, "resolveJsonModule": true, "outDir": "dist", + // One level up: the E2E suite and the seed share `ballotCrypto.ts` with the + // frontend, so the three can never disagree about what a valid tally is. + "rootDir": "..", "types": ["node", "mocha"] - } + }, + // Everything here, plus the frontend's snarkjs declaration: one description + // of the one API the prover and the browser share. + "include": ["**/*.ts", "../frontend/src/types/snarkjs.d.ts"], + "exclude": ["node_modules", "artifacts", "cache", "dist"] } 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. diff --git a/docs/PLAN.md b/docs/PLAN.md index 33c9a58..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, ERC-2771) 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 4958938..fd9ce80 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -2,115 +2,168 @@ ## Overview -``` -┌─────────────────────────────────────────────────────────────────┐ -│ USER │ -│ Browser (IPFS gateway / 4EVERLAND CDN) │ -│ React 19 + Vite + Tailwind 4 + Semaphore v4 + Paillier │ -└─────────────────┬───────────────────────────┬───────────────────┘ - │ World ID QR + relayed tx │ RPC (read only) - ▼ ▼ -┌─────────────────────────┐ ┌──────────────────────────────────┐ -│ BACKEND ISSUER (TEE) │ │ BLOCKCHAIN (Polygon Amoy) │ -│ Node.js Express │ │ │ -│ Phala Network TEE │ │ ElectionFactory.sol │ -│ (Intel TDX) │ │ ElectionV4.sol │ -│ │ │ ElectionPaymaster.sol │ -│ POST /verify-human │ │ PlatformRegistry.sol │ -│ ← World ID proof │ │ │ -│ → SD-JWT cookie │ │ │ -│ POST /relay/vote │───▶│ relayed through │ -│ (unauthenticated) │ │ ElectionPaymaster │ -│ /attestation │ │ │ -│ → Intel TDX report │ │ Semaphore V4 Verifier │ -└─────────────────────────┘ └──────────────────────────────────┘ - │ - │ queryFilter(VoteCast) - ▼ - ┌──────────────────────────────────┐ - │ 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│ - │ 4. Generate auditable JSON │ - │ 5. Pin to IPFS (CLI only) │ - │ 6. publishResults(cid, tally) │ - └──────────────────────────────────┘ +```mermaid +flowchart TB + subgraph user["User's browser: frontend on IPFS (4EVERLAND)"] + ui["React 19 + Vite + Tailwind 4"] + idn["Semaphore V4 identity
from a 12-word phrase"] + zk["ElGamal ballots and tally,
Groth16 proofs (snarkjs)"] + end + + subgraph issuer["Issuer backend: Node + Express in a Phala TEE (Intel TDX)"] + verify["POST /verify-human
World ID proof in, SD-JWT cookie out"] + enrol["POST /relay/enroll
platform-signed private enrolment"] + vote["POST /relay/vote
no session: the proof authorises"] + end + tdx["TDX attestation
served by the Phala platform (dstack)"] + + subgraph chain["Polygon Amoy"] + factory["ElectionFactory"] + election["ElectionV4
keeps the running ElGamal
aggregate of every ballot"] + paymaster["ElectionPaymaster
relays, reimburses from the organizer"] + registry["PlatformRegistry"] + verifiers["Ballot + tally Groth16 verifiers
one pair per circuit size"] + end + + subgraph tally["Tally: in the app, or scripts-tally/"] + t1["read aggregate()"] + t2["decrypt with the election's keys"] + t3["prove the decryption (tally circuit)"] + t4["audit JSON, pinned to IPFS by the CLI"] + t5["publishResults(cid, counts, proof)
or voidBelowQuorum(voters, proof)"] + end + + user -- "World ID proof, enrolments, ballots" --> issuer + user -. "RPC reads" .-> chain + issuer -. "attested by" .-> tdx + enrol --> paymaster + vote --> paymaster + paymaster --> election + factory -- "deploys" --> election + election --> verifiers + election --> registry + election -- "aggregate()" --> t1 + t1 --> t2 --> t3 --> t4 --> t5 + t5 -- "verified on chain" --> election ``` ## Full voting flow -``` -1. IDENTITY REGISTRATION - User → World ID App (Orb/Device) - → Backend: POST /api/verify-human - → Backend verifies World ID v4 proof (on-chain / off-chain) - → Backend issues SD-JWT (`__Host-` httpOnly cookie, Secure, SameSite=strict, - 7 days). See "The voter's session" below - → SD-JWT claims: nullifier_hash, verification_level, issued_at - -2. VOTING IDENTITY - Frontend, on the device - → a twelve-word recovery phrase is minted; the Semaphore identity is a - pure function of it, so the same words rebuild the same voter anywhere - → POST /api/identity/vault {commitment} → PlatformRegistry.registerMember - → WebAuthn (Passkey) → PRF secret → the phrase sealed under it - → POST /api/identity/vault {commitment, credentialId, blob} - → PlatformRegistry.addVaultEntry - → REGISTRATION AND THE PASSKEY ARE TWO WRITES, on purpose. An - authenticator that creates a credential and then refuses to evaluate it - (Windows Hello, measured 2026-09-15) must still leave a registered - voter, holding their phrase. Welding the two together meant such a voter - was never on chain at all, and their next device read the empty vault as - "new" and minted a second identity for one human. - → the voter never holds an address: transactions go through the - issuer relayer, because a per-voter address would link their - enrolment to their ballot on chain - -3. ELECTION ENROLLMENT - Frontend reads SD-JWT cookie - → Verifies eligibility criteria - → ElectionV4 adds the already-registered commitment to the Semaphore group - → `enroll` refuses a commitment PlatformRegistry has not verified, which is - what step 2 wrote - -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 - -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 - -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), - 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). +```mermaid +sequenceDiagram + autonumber + actor U as Voter + participant W as World App + participant F as Frontend + participant B as Issuer + participant R as PlatformRegistry + participant P as ElectionPaymaster + participant E as ElectionV4 + + rect rgba(120,120,120,0.08) + Note over U,B: 1. Identity registration + U->>W: verify (Orb or device) + W-->>F: World ID proof + F->>B: POST /api/verify-human + B->>B: verify the proof under the server-fixed action + B-->>F: SD-JWT in a __Host- httpOnly cookie (7 days) + end + + rect rgba(120,120,120,0.08) + Note over U,R: 2. Voting identity + F->>F: mint a 12-word phrase, derive the Semaphore identity + F->>B: POST /api/identity/vault {commitment} + B->>R: registerMember(nullifier, commitment) + F->>F: seal the phrase under a passkey's PRF secret + F->>B: POST /api/identity/vault {commitment, credentialId, blob} + B->>R: addVaultEntry + end + + rect rgba(120,120,120,0.08) + Note over F,E: 3. Enrolment in one election + F->>F: derive a commitment for THIS election (HKDF of the secret) + F->>B: POST /api/relay/enroll {election, commitment} + B->>P: relayEnrollPrivate(humanTag, documentTag, platform signature) + P->>E: enrollPrivate: one per account,
and one per document where required + end + + rect rgba(120,120,120,0.08) + Note over F,E: 4 and 5. Voting, and voting again + F->>F: find the voter's chain of ballots by their tags + F->>F: encrypt the vote, cancel the previous ballot,
prove it (ballot circuit) + F->>B: POST /api/relay/vote {ballot, proof} + B->>P: relayVote + P->>E: castVote: roots, tag, epoch tag, proof,
file the leaf, add to the aggregate + P->>B: reimburse the relayer from the organizer's reserve + end + + rect rgba(120,120,120,0.08) + Note over F,E: 6. Tally + F->>E: aggregate() + F->>F: decrypt with keys derived from a wallet signature,
prove the decryption (tally circuit) + F->>E: publishResults(cid, counts, proof) + E->>E: verify the tally proof, then publish + end ``` +What each step rests on: + +1. **Identity registration.** The World ID proof is checked under an action the + server fixes; a client-chosen action would give one person as many + nullifiers as actions. The SD-JWT carries the nullifier and the + verification level, and no identity attributes: nothing here verifies any. + See "The voter's session" below. +2. **Voting identity.** The Semaphore identity is a pure function of the + phrase, so the same words rebuild the same voter anywhere. REGISTRATION AND + THE PASSKEY ARE TWO WRITES, on purpose: an authenticator that creates a + credential and then refuses to evaluate it (Windows Hello, measured + 2026-09-15) must still leave a registered voter holding their phrase. + Welding the two together meant such a voter was never on chain, and their + next device read the empty vault as "new" and minted a second identity for + one human. The voter never holds an address: a per-voter address would + link their enrolment to their ballot on chain. +3. **Enrolment.** What enters an election's tree is a commitment derived for + that election alone, so no two trees share a leaf and the chain does not + say which elections one person joined. The issuer signs a human tag, from + the World ID nullifier, which the election accepts once: one enrolment per + World ID account. Where the election requires a document it also signs a + document tag, accepted once too: one enrolment per passport or ID card, + whichever account it arrives with. +4. **Voting.** Exponential ElGamal on Baby Jubjub, one key per option: + vote `A = r·G, B_i = v_i·G + r·H_i`, cancellation + `A' = -A_prev + s·G, B'_i = -B_prev_i + s·H_i` (of nothing on a first + ballot). The ballot circuit (`circuits/src/ballot.circom`) proves the voter + is on the roll, the vote is exactly one option, the cancellation undoes the + voter's OWN previous ballot (by membership in the ballots tree, not by + pointing at it), and the tags are theirs. The relayer is reimbursed in the + same transaction. +5. **Voting again (coercion resistance).** The same pipeline, one tag further + along the voter's chain. The aggregate telescopes each chain down to the + voter's LAST vote, and a first ballot and a re-vote share no public value, + so nobody watching can tell a voter changed their mind. At most two ballots + per voter within an hour (current and previous epoch tags, drawn at random) + bound the gas one voter can spend, through tags that link nothing across + hours. +6. **Tally.** Two interchangeable paths, one pipeline. In the app (default) + the keys are derived on demand from a wallet signature (public per-election + `keyNonce` in the metadata) and never stored at rest; publishing is a + wallet-signed transaction. The offline CLI (`scripts-tally/`) does the same + from a key file and pins the audit JSON to IPFS. Both: below the privacy + quorum, `voidBelowQuorum(voters, proof)` and the counts are never revealed; + otherwise `publishResults(cid, counts, proof)`. The contract verifies the + proof either way. + ## Module breakdown ### contracts/ | Contract | Purpose | |----------|---------| -| `ElectionV4.sol` | Single election: ERC-2771, 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 +314,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. @@ -376,13 +429,29 @@ no elections, and the address filter is applied either way. flags). `PENDING_VOTE` only occurs when a separate enrollment window leaves a gap (`enrollEnd < voteStart`); with no gap it collapses away. -``` -Upcoming → Enrolling → PendingVote → Active → Tallying → Closed (Approved / Rejected) -└──────────── any pre-decision phase ────────┘ │ - ↓ ↓ - Cancelled Voided (Privacy Quorum not reached) +```mermaid +stateDiagram-v2 + direction LR + [*] --> Upcoming: deployed + Upcoming --> Enrolling: enrollStart + Enrolling --> PendingVote: enrollEnd, if voting opens later + Enrolling --> Active: enrollEnd = voteStart + PendingVote --> Active: voteStart + Active --> Tallying: voteEnd + Tallying --> Closed: publishResults with a tally proof + Tallying --> Voided: voidBelowQuorum, or markVoided + Upcoming --> Cancelled: cancelElection + Enrolling --> Cancelled: cancelElection + PendingVote --> Cancelled: cancelElection + Active --> Cancelled: cancelElection + Closed --> [*] + Voided --> [*] + Cancelled --> [*] ``` +Cancelling is only open to an election created as cancellable. Closed results +are Approved or Rejected according to the voting type below. + - **Upcoming**: deployed, enrollment not open yet (`now < enrollStart`). - **Enrolling**: enrollment open (`enrollStart ≤ now < enrollEnd`). - **PendingVote**: enrollment closed, voting not open (`enrollEnd ≤ now < voteStart`). @@ -391,7 +460,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 +485,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 +537,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 +575,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 +710,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 +797,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 @@ -1554,3 +1616,126 @@ Unlinkability, recovery after total loss of the secret, and one-human-one-vote cannot all hold without some party that keeps the link. The choice is where to put it. This design puts it in the one party that already decides who becomes a member, and takes it off the public record. + +## Known limits, stated plainly + +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. + +- **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, 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 + 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; + a voter who needs more should reach it over Tor or a VPN. +- **One backend instance.** Rate limits, eligibility sessions and World ID + requests in flight live in process memory, which is what a single TEE + deployment is. Running several instances behind a balancer would need that + state moved to a shared store first. +- **No indexer.** The browser reads events in 10,000-block windows from the + deployment block, with bounded concurrency. That is fine for a thesis-sized + deployment and grows with the chain; a public platform would add an indexer + (a subgraph or the issuer itself) and keep the browser path for auditors. +- **A wallet signature is the organizer's tally key.** Any site that persuades + an organizer to sign the same typed data obtains it, since EIP-712 cannot + bind a signature to an origin. Wallets whose signatures are not deterministic + are detected and given a random, exportable key instead. +- **Nothing makes the organizer tally.** There is no deadline for publishing: + an organizer who never decrypts leaves the election in TALLYING for good, + and once voting has ended they can release its gas reserve back to their + balance. Voters lose nothing they cast, but no result appears. A tally + deadline, after which the election voids itself, would bound it; the auto- + tally and threshold designs below would remove the dependence altogether. + +## Future work + +Deliberately left for after the thesis. Each item is designed in the sections +linked; none needs the ballots or the circuits of this version to change. + +- **Threshold decryption of the tally keys.** Trustees run a distributed key + generation (Pedersen/Feldman), so each option's key is `H_i = Σ_j x_ij·G` + and no party ever holds a whole `x_i`. At the close each trustee publishes a + partial decryption `x_ij·A` of the contract's aggregate with a proof of + correctness (Chaum-Pedersen, or a small circuit), the contract combines any + `t` of them, and the tally circuit takes the combined value in place of + `x_i`. It removes the organizer's power to open a single ballot or to sit on + the result. On ElGamal this is a protocol to add, not a primitive to change. + See *Threshold decryption, and why it is not here* and *Who would hold the + shares*. +- **Auto-tally.** A result with nobody acting: keys generated and sealed inside + a TEE that decrypts and proves only the aggregate, and only once `phase()` + reads TALLYING, checked through its attestation; or a threshold network + (Lit Actions) doing the same. See *Why the contract does not tally by itself* + and *An auto-tally that gives nothing up*, written for Paillier and to be + reworked for ElGamal, where the decryption is a scalar multiplication and a + small discrete log. +- **A tally deadline.** A fixed time after `voteEnd` past which anyone can void + an election nobody tallied, so a result can no longer be withheld forever. +- **Last-hour coercion.** A proof of "at most K sponsored ballots" per voter, + with the voter paying beyond it, would replace the hourly limit and its + last-hour window (see *Two votes forced in a row in the last hour stand*). +- **No per-user gas for the platform.** Today the issuer's registrar pays, and + is never reimbursed, for every `registerMember`, vault entry, preferences + blob, rotation and revocation in `PlatformRegistry`: a cost that grows with + users, not with elections. None of it is on the voting path any more: + `enrollPrivate` checks only the platform's signature and never reads the + registry, which only the legacy public `enroll` still needs. So it can go: + - **Batch it behind one root.** Keep the vault and preference blobs in + content-addressed storage (IPFS or Arweave, pinned by the issuer and by + anyone else who wants to), and anchor them with one Merkle root of + `nullifier -> blob hash` per batch, posted hourly or daily. A device + fetches its blob and checks it against the root, so losing the issuer still + does not lock a voter out. Members and revocations go the same way: one + root per batch, and the legacy `enroll` takes a Merkle proof instead of + reading a mapping. The platform's cost becomes one transaction per batch, + whatever the number of users. The price: a new passkey is usable on another + device only once its batch lands, which the issuer can bridge by serving + pending entries signed with its key. + - **Or bill it to the organizer who brings the voter.** The paymaster already + charges each election for its relays; it could add a fixed platform fee + per newly enrolled voter, paid to the platform's treasury from the same + reserve. Simpler, but it only covers voters who enrol, and a vault written + at sign-up belongs to no election yet, so it works best combined with the + batching above. + + Deploying the contracts stays a one-off cost for whoever deploys them, and + organizers keep paying their own transactions and their voters' gas. +- **Scale.** An indexer (a subgraph, or the issuer) serving lists, receipts and + Merkle paths, with the browser's own reads kept as the audit path; the + backend's in-memory state moved to a shared store so it can run as more than + one instance. diff --git a/docs/dev/deployment.md b/docs/dev/deployment.md index c0e2de7..c1b48b1 100644 --- a/docs/dev/deployment.md +++ b/docs/dev/deployment.md @@ -11,24 +11,41 @@ what the attestation exists to rule out. ## The shape of it -``` -push to main - │ - ├─ frontend ─┐ - ├─ backend ─┼─ all three must pass - └─ contracts ┘ - │ - ├─ has anything changed? - │ ├─ frontend/ ............... → publish the frontend (4EVERLAND) - │ └─ what enters the image ... → publish the backend image (GHCR) - │ │ - │ └─ AUTO_DEPLOY says: - │ none → stop - │ heroku → deploy to heroku ─┐ - │ phala → deploy to phala ──┤ - │ └─ dns follows - │ - └─ (pull requests stop here: checked, never published) +```mermaid +flowchart TB + push["push to main"] + pr["pull request"] + + subgraph checks["checks.yml: all must pass"] + frontend["frontend
lint, typecheck, tests, build, smoke"] + backend["backend
lint, typecheck, tests, image build"] + circuits["circuits
witness tests, cached trusted setup"] + contracts["contracts
E2E with real Groth16 proofs"] + tally["tally CLI"] + circuits -- "artefacts" --> contracts + end + + changed{"has anything changed?"} + stop(["checked, never published"]) + site["publish the frontend
(4EVERLAND)"] + image["publish the backend image
(GHCR, pinned by digest)"] + auto{"AUTO_DEPLOY"} + heroku["deploy to heroku"] + phala["deploy to phala"] + dns["DNS follows"] + release["publish the release"] + + push --> checks + pr --> checks --> stop + checks --> changed + changed -- "frontend/" --> site + changed -- "what enters the image" --> image + image --> auto + auto -- "none" --> done(["stop"]) + auto -- "heroku" --> heroku --> dns + auto -- "phala" --> phala --> dns + site --> release + image --> release ``` Manual entry points, at any time, for any published version: @@ -222,6 +239,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 adb107f..3dd6088 100644 --- a/docs/dev/glossary.md +++ b/docs/dev/glossary.md @@ -4,19 +4,27 @@ **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. At most two ballots per voter within an hour (the epoch tag) bound 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. **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. + +**Epoch tag**: Poseidon(EPOCH, secret, scope, epoch), one per voter per clock hour. The contract accepts each once and takes a ballot naming the current hour or the last, so a voter casts at most two within an hour; the app draws between the two at random so neither marks a re-vote. Nothing links a voter's 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. ## Blockchain and Smart Contracts @@ -26,11 +34,11 @@ **Paymaster (gas tank)**: `ElectionPaymaster.sol`. Organizers deposit POL; `relayEnroll` / `relayVote` call the election and reimburse whoever relayed, out of that election's organizer's balance, in the same transaction. -**ERC-2771 (Meta-transactions / Trusted Forwarder)**: allows a relay to submit on behalf of a user while preserving the real `msg.sender`. In Votain the forwarder is set to a burn address: voter calls go through `ElectionPaymaster` and neither `enroll` nor `castVote` reads `msg.sender`, so `_msgSender()` only affects organizer-only functions. +**ERC-2771 (Meta-transactions / Trusted Forwarder)**: allows a relay to submit on behalf of a user while preserving the real `msg.sender`. Votain does not use it: voter calls go through `ElectionPaymaster` and neither `enroll` nor `castVote` reads `msg.sender`, and a trusted forwarder could speak as any organizer, so `ElectionV4` does not inherit `ERC2771Context`. **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. diff --git a/docs/dev/state.md b/docs/dev/state.md index 66fde60..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,4 +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 | diff --git a/frontend/.env.example b/frontend/.env.example index ea17530..e232d29 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -45,6 +45,14 @@ VITE_ELECTION_FACTORY_ADDRESS= VITE_PLATFORM_REGISTRY_ADDRESS= VITE_PAYMASTER_ADDRESS= +# ── Circuits ── +# Where the ballot and tally circuits' proving files (_s.wasm/.zkey) +# are served from. They are outputs of the deployment's trusted setup, far too +# large for the repository, so a deployment publishes them itself (an IPFS pin +# of circuits/build's public copies) and names the location here. Unset, the +# app reads /circuits/, which `npm run build` in circuits/ fills for local use. +VITE_CIRCUITS_URL= + # ── Public site URL ── # The absolute origin this build will be served from, with no trailing slash. # Production is https://votain.app and lives in .env.production, which is 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..396a555 100644 --- a/frontend/DEVELOPMENT.md +++ b/frontend/DEVELOPMENT.md @@ -12,8 +12,8 @@ - **`@radix-ui/react-select`** 2.3.3 (headless primitive, LanguageSelector) - **country-flag-emoji-polyfill** 0.1.8 (flag emojis on Windows/Chromium) - **ethers** 6.17.0 (chain reads, organizer EOA, local relay) -- **`@semaphore-protocol/{identity,group,proof}`** 4.14.3 + **poseidon-lite** (nullifier) -- **paillier-bigint** 3.4.3 +- **`@semaphore-protocol/{identity,group}`** 4.14.3 + **poseidon-lite** (tags, leaves) +- **snarkjs** 0.7 (ballot and tally proofs, in the browser) + **`@zk-kit/lean-imt`** (ballots tree) - **`@worldcoin/idkit`** 4.2.3 + **`@worldcoin/idkit-core`** 4.2.4 (types only here now: the request itself is built by the backend) - **`@sd-jwt/core`** 0.20.0 + **`@sd-jwt/present`** 0.19.0 (no stable 0.20 of present yet) @@ -195,7 +195,7 @@ Firefox on Windows) could enrol and then be unable to vote after a page reload. - **Signing out clears the identity** (`clearIdentity` from `voterSignOut`). Leaving it behind let the next person on the browser inherit the previous voter's identity, since `getOrCreateIdentity` returns the stored one whenever the mode is "local". Organizer - sign-out keeps `votain_paillier_sk_*`: those decrypt results of live elections. + sign-out keeps `votain_tally_keys_*`: those count elections still running. ## src/ structure @@ -216,7 +216,7 @@ src/ │ ├── seed.ts # hardcoded elections (6, all phases + 4 voting types) │ └── languages.ts └── lib/ # contracts, deployments, relay, semaphore, identityVault, - # paillier, tally, logs, worldId + # ballotCrypto, ballot, tally, logs, worldId ``` ## Installable, and findable @@ -472,17 +472,20 @@ src/lib/ ├── deployments.ts # resolve addresses (manifest glob or VITE_* env) ├── contracts.ts # ethers v6 clients + human-readable ABIs ├── chainElections.ts # ElectionV4 state → Election UI model; members from events -├── paillier.ts # homomorphic ballot encryption (base-1e6 packing) -├── semaphore.ts # identity, group-from-events, nullifier, vote proof +├── ballotCrypto.ts # ElGamal on Baby Jubjub, tags, leaves, circuit inputs (shared with CLI + contracts) +├── ballot.ts # a voter's chain of ballots, building and proving the next one +├── semaphore.ts # identity, group-from-events, the device's last ballot tag ├── identityVault.ts # seal/unseal the Semaphore secret per passkey ├── relay.ts # voter calls submitted through ElectionPaymaster ├── logs.ts # queryFilter from the deployment block, windowed on strict RPCs ├── worldId.ts # World ID verification held by the backend: open, resume, wait -├── tally.ts # in-app homomorphic tally +├── tally.ts # decrypt the on-chain aggregate and prove it (tally circuit) +├── tallyKey.ts # tally keys from the wallet signature, key file +├── tallyAudit.ts # the reader's check: ballots on chain add up to the aggregate ├── countries.ts # ISO 3166-1 alpha-3 table, localised names, flags, search ├── eligibility.ts # attribute policy, its hash, and the voter-side challenge ├── voting.ts # enroll / castVote / history (voter actions) -└── organizer.ts # createElection (+ Paillier keygen), lifecycle, gas +└── organizer.ts # createElection (+ tally keys), lifecycle, publish / void, gas src/hooks/ ├── useElections.ts # chain-aware list/detail (seed fallback) └── useOrganizerWallet.ts # injected EOA + chain enforcement (organizers) @@ -494,24 +497,22 @@ Key rules: not only about gas: a per-voter sending address would publicly link a voter enrollment to their ballot and defeat the Semaphore proof. - Organizers use an **injected EOA** (MetaMask) to deploy/manage and pay for it. -- The election's **Paillier private key** is DERIVED, not kept: from a deterministic +- The election's **tally keys** are DERIVED, not kept: from a deterministic EIP-712 signature by the organizer's wallet plus the election's public `keyNonce` - (`lib/organizerKey.ts`, `lib/tallyKey.ts`). Nothing is at rest, so there is nothing to - lose and nothing to steal from a disk. A random key stored in localStorage - (`votain_paillier_sk_
`) remains the fallback for a caller with no wallet, and - the export exists for the tally CLI. + (`lib/organizerKey.ts`, `lib/tallyKey.ts`), one key per option and one for the + blank vote. Nothing is at rest, so there is nothing to lose and nothing to steal + from a disk. Random keys stored in localStorage (`votain_tally_keys_
`) + remain the fallback for a wallet that cannot derive, and the export exists for + the tally CLI and for another device. - History/receipts prove *that* and *when* you voted. They do not reveal *what*, - but say why precisely: the ballot is a Paillier ciphertext in the `VoteCast` - event, paired with the voter's nullifier, and the receipt shown to the voter IS - that nullifier. So the choice is hidden by the tally key being secret, not by - the chain being unable to hold it. Anyone with a voter's receipt AND that key - reads their vote, and every re-vote they made. The key never leaves the - organizer, is never published as an audit artefact, and the management page - stops offering to export it once an election is decided (see below). -- The nullifier is not an identity: Semaphore keeps it unlinkable from the - enrolment commitment, so the key alone yields an anonymous table of votes and - deanonymizes nobody. It is the pairing with a receipt that matters, which is - also why it weakens coercion resistance rather than privacy in general. + but say why precisely: the ballot is an ElGamal ciphertext in the `BallotCast` + event, filed under the ballot's tag, and the receipt shown to the voter IS + that tag. So the choice is hidden by the tally keys being secret. Anyone with a + voter's receipt AND those keys reads THAT ballot. What they no longer read is + the voter's other ballots: each carries a tag unrelated to the others, so a + receipt points at one ballot and says nothing about whether it was replaced. +- The tag is not an identity: nothing links it to the enrolment commitment, so + the keys alone yield an anonymous table of ballots and deanonymize nobody. ## Vote references never render at full length @@ -531,55 +532,52 @@ readable. ## Coercion: what actually defends, and what only looks like it -The receipt shown to a voter is their nullifier, and the `VoteCast` event pairs -that nullifier with the ballot ciphertext. So a coercer holding both a receipt -and the tally key reads the vote. Two ideas for closing that turn out to be -worth writing down, one because it fails and one because it works. - -**Deriving the receipt from the nullifier does not help.** A one-way function of -the nullifier looks safer, but the attacker never has to invert it: they compute -it over every nullifier on chain, of which there are only as many as there are -voters, and match. One-wayness buys nothing when the input space is small and -public. More fundamentally, any receipt that lets a voter confirm their ballot is -on chain also lets them point at it. That is the same capability, and it is the -classic tension between individual verifiability and receipt-freeness rather than -a formatting problem. - -**Re-voting is the defence, and it holds even against a coercer with the key.** -The tally keeps only the highest nonce per nullifier (`tally.ts`), so a coercer -can read a ballot but can never know it is the final one. To be certain they -would have to control the voter at the moment voting closes, which is a far more -expensive attack than reading a receipt. - -**The threat that stays open is the organizer.** A coercer only holds the key if -the organizer leaked it or is the coercer, and the second case breaks the -property outright. The structural answer is threshold decryption, splitting the -key so no single party can decrypt alone. Votain has one key and one organizer, -so the honest claim is coercion MITIGATION through re-voting, in the Estonian -sense, not receipt-freeness in the JCJ or Civitas sense. Worth a paragraph in the -thesis rather than a silent gap. +The receipt shown to a voter is their ballot's tag, and the `BallotCast` event +pairs that tag with the ciphertext. So a coercer holding a receipt and the tally +keys reads THAT ballot. Re-voting is what defends, and since 2026-09-24 it holds +even against a coercer who can read chains: + +**A re-vote cannot be seen.** Every ballot carries a vote and a cancellation of +the voter's previous ballot, of nothing on a first one, proved in zero knowledge +without saying which ballot it cancels. The tags of one voter's ballots are +unrelated to each other. So a coercer who watched the voter cast a ballot, and +kept its receipt, cannot tell from the chain whether a later ballot replaced it. +Under the old scheme the ballot carried the voter's nullifier and a nonce, and a +coercer who knew the nullifier could see that a re-vote had happened. + +**What the key holder can still see.** The tally keys can open any single +ballot, and its cancellation too, which shows whether that ballot replaced an +earlier vote and for which option. Not whose ballot it is, nor which ballot it +replaced. A coercer holding a receipt and the keys can therefore check the +receipt's ballot and see SOME re-vote happened somewhere, never that it was their +voter's. Removing even that needs threshold decryption; see the architecture +document. + +**Deriving the receipt from the tag does not help**, for the old reason: any +receipt that lets a voter confirm their ballot is on chain also lets them point +at it. That is the tension between individual verifiability and +receipt-freeness, not a formatting problem. The honest claim is coercion +resistance through invisible re-voting, not receipt-freeness in the JCJ sense. ## The tally key disappears once the election is decided -Export and import of the Paillier key exist for one reason: making sure the tally +Export and import of the tally keys exist for one reason: making sure the tally CAN be run, on this device or another. A closed, voided or cancelled election has either had its tally published or will never have one, so both actions are gone from that point and a line explains why. -Removing the export is not only tidiness. Every ballot is a Paillier ciphertext -stored publicly on chain, and that key is the only thing between those -ciphertexts and reading them one at a time. Writing a fresh unencrypted copy of -it into a Downloads folder is worth doing while it protects against losing the -ability to count, and is pure liability afterwards. The replacement note says to -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. +Removing the export is not only tidiness. Every ballot is an ElGamal ciphertext +stored publicly on chain, and those keys are the only thing between the +ciphertexts and reading them one at a time. Writing a fresh unencrypted copy into +a Downloads folder is worth doing while it protects against losing the ability to +count, and is pure liability afterwards. The replacement note says to delete the +copies, and says why. + +This is also why the keys are never published as an audit artefact. The tally +circuit proves the counts are the decryption of the election's aggregate, and +the contract checks it before it accepts them (`lib/tally.ts`); a reader checks +the other half, that the aggregate is the sum of the ballots on chain, with no +key at all (`TallyCheck`, `lib/tallyAudit.ts`, `scripts-tally --verify`). ## Radix Selects are controlled, so a tap can close them @@ -625,32 +623,18 @@ they were already on. present. It used to be three conditions in three files, and two of them tested `ipfsCid`, which is empty for every tally run in the app. -## Tally key derivation cost - -The organizer's Paillier key is re-derived from a wallet signature rather than -stored, which means a 2048-bit key is generated in the browser every time an -election is created. That is pure BigInt work: **nothing in the path touches the -chain or the network**, so the cost is identical on a local node and on Amoy. - -The prime search discards candidates by trial division against the primes below -10000 before running Miller-Rabin, and screens survivors with a single MR round -before paying for the full forty. Roughly seven candidates in eight never reach a -modular exponentiation. Measured in the vitest environment, one derivation went -from **43.7s to about 6.5s**. - -Both filters are exact, not probabilistic shortcuts: trial division rejects only -numbers a small prime divides, and the one-round screen rejects only numbers -Miller-Rabin proves composite. Neither can skip a value the original walk would -have accepted, so the derivation is unchanged. +## Tally key derivation cost, and proving cost -**`tallyKey.test.ts` pins the derived keypair for a fixed secret and nonce.** The -derivation is a compatibility surface, not an implementation detail: a change -that yields a different key silently makes every election created before it -undecryptable. If that test fails, the fix is not to update the vector. +Deriving the tally keys is a handful of HMACs and a scalar multiplication per +option: milliseconds. The 2048-bit Paillier key it replaced took seconds on a +phone, which is why this section used to be long. -What is left is unavoidable arithmetic, and a handful of seconds of frozen tab -still reads as a crash. The create wizard should say what it is doing between the -signature prompt and the transaction. +The cost that matters now is PROVING. A ballot proof over the 5-slot circuit is +around 73k constraints, and the tally proof around 40k; the browser downloads +each circuit's proving key once (tens of megabytes, from `VITE_CIRCUITS_URL`) and +proves in a few seconds on a laptop, longer on an old phone. The progress screen +exists for that wait. Both run in snarkjs's worker threads, so the page stays +responsive. ## What an election asks of a voter, and where that claim lives @@ -746,13 +730,13 @@ which made the one public verification tool the only screen in the app that never touched the chain. It now goes through `findVoteReceipt` in `lib/voting.ts`, reading the same -public `VoteCast` events the voter's own history reads. Two ways in, and the -ambiguity between them is worth knowing about: a nullifier is a field element +public `BallotCast` events the voter's own history reads. Two ways in, and the +ambiguity between them is worth knowing about: a ballot tag is a field element and prints at exactly the same width as a transaction hash, so a `0x` string of 32 bytes could be either and nothing about it says which. The transaction lookup runs first because it is one request and settles the question; only when -no such transaction exists is the string tried as a nullifier, which costs one -`queryFilter` per election. +no such transaction exists is the string tried as a tag, which costs one +indexed `queryFilter` per election. **It stays unauthenticated, and that is the point.** The value of publishing a nullifier is that a THIRD party can check a receipt somebody shows them: an @@ -1219,7 +1203,7 @@ Two consequences of the roles becoming simultaneous, both on the profiles: stays for when the answer really is everything. Each exit clears only its own data (`voterSignOut` takes the voting identity and this device's vote records, `organizerSignOut` the remembered wallet and display name; neither touches the - Paillier keys, which decrypt elections already on chain) and lands the person + tally keys, which count elections already on chain) and lands the person on the remaining role's home rather than the landing page, which would read as having signed them out of that one too. diff --git a/frontend/README.md b/frontend/README.md index 302d997..9f8175b 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,13 +1,66 @@ # frontend/ -React 19 + Vite + Tailwind 4. Voter and organizer dApp for Votain. Semaphore V4 identities derived from a 12-word recovery phrase, sealed on the device under a WebAuthn passkey's PRF secret. Deployed on IPFS via 4EVERLAND at `votain.app`, with CD from `main`. +React 19 + Vite + Tailwind 4. Voter and organizer dApp for Votain. Semaphore V4 identities derived from a 12-word recovery phrase, sealed on the device under a WebAuthn passkey's PRF secret. Ballots are encrypted and proved in the browser. Deployed on IPFS via 4EVERLAND at `votain.app`, with CD from `main`. ```bash npm install -npm test # 562 tests +npm test # 597 tests +npm run test:e2e -- --workers=1 # Playwright smoke, every screen on three devices cp .env.example .env # fill in VITE_BACKEND_URL, the contract addresses, ... (World ID lives in backend/.env) npm run dev # http://localhost:5173 npm run build ``` +## How it is put together + +Screens never talk to the chain or the issuer directly: they go through hooks, +and the hooks through `src/lib/`, where each concern has one module. + +```mermaid +flowchart TB + subgraph pages["src/pages"] + public["public/
Discover, results, verify receipt,
how it works, terms, privacy"] + election["ElectionPage
one election, public and voter"] + voterp["voter/
sign in, identity, proof, confirmation,
change vote, history"] + orgp["organizer/
create, manage, gas, members"] + end + + hooks["src/hooks
useElection, useVoteCost, useElectionFunding,
useVoterIdentity, useScheduleWatch, ..."] + + subgraph lib["src/lib"] + voting["voting.ts
enrol, cast, receipts, history"] + ballot["ballot.ts
voter's chain, epoch, prove"] + crypto["ballotCrypto.ts
ElGamal on Baby Jubjub,
circuit inputs, tally decryption"] + tally["tally.ts + tallyKey.ts
decrypt the aggregate, prove it"] + identity["semaphore.ts, electionIdentity.ts,
recoveryPhrase.ts, passkeyPrf.ts"] + relay["relay.ts
relayed calls, revert names"] + chainread["contracts.ts, chainElections.ts, logs.ts"] + backendlib["backend.ts, worldId.ts, eligibility.ts"] + end + + snark["snarkjs
Groth16 in the browser"] + files[("public/circuits or VITE_CIRCUITS_URL
ballot_s*, tally_s*")] + api["Issuer backend
/api"] + rpc["Polygon RPC
/rpc locally"] + wallet["Organizer wallet
WalletConnect / injected"] + + pages --> hooks --> lib + voting --> ballot --> crypto + voting --> identity + voting --> relay + tally --> crypto + ballot --> snark + tally --> snark + snark -. "fetched once" .-> files + relay --> api + backendlib --> api + chainread --> rpc + ballot --> chainread + orgp --> wallet +``` + +`ballotCrypto.ts` has no dependencies on purpose: the tally CLI, the contract +tests and the demo seed import the same file, so the browser, the auditor and +the tests encrypt, prove and decrypt with one implementation. + See [`DEVELOPMENT.md`](DEVELOPMENT.md) for the full stack, routes, environment variables and known technical debt. See the [root README](../README.md) for project context. diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index a2bb1c4..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' }, @@ -36,20 +35,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 +76,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..8250e9d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,47 +13,54 @@ "@radix-ui/react-select": "^2.3.7", "@sd-jwt/core": "^0.21.0", "@sd-jwt/present": "^0.19.0", - "@semaphore-protocol/contracts": "^4.14.3", "@semaphore-protocol/group": "^4.14.3", "@semaphore-protocol/identity": "^4.14.3", - "@semaphore-protocol/proof": "^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", - "paillier-bigint": "^3.4.3", + "lucide-react": "^1.48.0", + "poseidon-lite": "^0.3.0", "qrcode.react": "^4.2.0", "react": "^19.3.0", "react-dom": "^19.3.0", "react-i18next": "^17.0.15", "react-router-dom": "^7.18.4", "recharts": "^3.10.1", + "snarkjs": "^0.7.6", "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", "@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", + "@zk-kit/baby-jubjub": "^1.0.3", "autoprefixer": "^10.6.1", "globals": "^17.12.0", "jsdom": "^30.1.1", + "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", + "npm": ">=11" } }, "node_modules/@adobe/css-tools": { @@ -1250,15 +1257,6 @@ } } }, - "node_modules/@commander-js/extra-typings": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-12.1.0.tgz", - "integrity": "sha512-wf/lwQvWAA0goIghcb91dQYpkLBcyhOhQNqG/VgWhnKzgt+UOMvra7EX/2fv70arm5RW+PUHoQHHDa6/p77Eqg==", - "license": "MIT", - "peerDependencies": { - "commander": "~12.1.0" - } - }, "node_modules/@csstools/color-helpers": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", @@ -1443,116 +1441,6 @@ "integrity": "sha512-Xzdyxqm1bOFF6pdIsiHLLl3HkSLjbhqJHVyqaTxXt3RqXBEnmsUmEW47H7VOi/ak7TdkRpNkxjyK5Zbkm+y52g==", "license": "GPL-3.0" }, - "node_modules/@iden3/binfileutils": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@iden3/binfileutils/-/binfileutils-0.0.11.tgz", - "integrity": "sha512-LylnJoZ0CTdgErnKY8OxohvW4K+p6UHD3sxt+3P9AmMyBQjYR4IpoqoYZZ+9aMj89cmCQ21UvdhndAx04er3NA==", - "license": "GPL-3.0", - "dependencies": { - "fastfile": "0.0.20", - "ffjavascript": "^0.2.48" - } - }, - "node_modules/@inquirer/core": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-9.2.1.tgz", - "integrity": "sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==", - "license": "MIT", - "dependencies": { - "@inquirer/figures": "^1.0.6", - "@inquirer/type": "^2.0.0", - "@types/mute-stream": "^0.0.4", - "@types/node": "^22.5.5", - "@types/wrap-ansi": "^3.0.0", - "ansi-escapes": "^4.3.2", - "cli-width": "^4.1.0", - "mute-stream": "^1.0.0", - "signal-exit": "^4.1.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/core/node_modules/@inquirer/type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-2.0.0.tgz", - "integrity": "sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==", - "license": "MIT", - "dependencies": { - "mute-stream": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/core/node_modules/@types/node": { - "version": "22.20.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", - "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@inquirer/core/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" - }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/input": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-2.3.0.tgz", - "integrity": "sha512-XfnpCStx2xgh1LIRqPXrTNEEByqQWoxsWYzNRSEUxJ5c6EQlhMogJ3vHKu8aXuTacebtaZzMAHwEL0kAflKOBw==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/type": "^1.5.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/select": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-2.5.0.tgz", - "integrity": "sha512-YmDobTItPP3WcEI86GvPo+T2sRHkxxOq/kXmsBjHS5BVXUgvgZ5AfJjkvQvZr03T81NnI3KrrRuMzeuYUQRFOA==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^9.1.0", - "@inquirer/figures": "^1.0.5", - "@inquirer/type": "^1.5.3", - "ansi-escapes": "^4.3.2", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/type": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.5.tgz", - "integrity": "sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==", - "license": "MIT", - "dependencies": { - "mute-stream": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1670,48 +1558,395 @@ "integrity": "sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==", "license": "MIT", "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/secp256k1": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-2.3.0.tgz", + "integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@owf/identity-common": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@owf/identity-common/-/identity-common-0.4.0.tgz", + "integrity": "sha512-YvbEeIuLE+TlR7wfzsfR3tAzNPFssbZ559QZactwGVunl1zgZG0smzArQwpgc3qNv0LrLdeCBzPd2ZcyfGyDHQ==", + "license": "Apache-2.0" + }, + "node_modules/@oxc-project/types": { + "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": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "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.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" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@noble/hashes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", - "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "node_modules/@oxlint/binding-openharmony-arm64": { + "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" + ], + "dev": true, "license": "MIT", "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@noble/secp256k1": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-2.3.0.tgz", - "integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==", + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "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" + ], + "dev": true, "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@owf/identity-common": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@owf/identity-common/-/identity-common-0.4.0.tgz", - "integrity": "sha512-YvbEeIuLE+TlR7wfzsfR3tAzNPFssbZ559QZactwGVunl1zgZG0smzArQwpgc3qNv0LrLdeCBzPd2ZcyfGyDHQ==", - "license": "Apache-2.0" + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.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==", + "node_modules/@oxlint/binding-win32-x64-msvc": { + "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" + ], "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/oxc-project" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@phosphor-icons/webcomponents": { @@ -1723,6 +1958,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", @@ -3064,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" ], @@ -3081,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" ], @@ -3098,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" ], @@ -3115,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" ], @@ -3132,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" ], @@ -3149,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" ], @@ -3166,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" ], @@ -3186,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" ], @@ -3206,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" ], @@ -3226,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" ], @@ -3246,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" ], @@ -3266,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" ], @@ -3286,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" ], @@ -3303,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" ], @@ -3320,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" ], @@ -3559,15 +3810,6 @@ "node": ">=20" } }, - "node_modules/@semaphore-protocol/contracts": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@semaphore-protocol/contracts/-/contracts-4.14.3.tgz", - "integrity": "sha512-N6UdFINfvDbpsIRSGuvOXZbKczDTrJATQQN5ToFI+oAVAO8s9TuBrFxDIsATnGnP8q/Dz/j7ZBEE6ypAU5Zebw==", - "license": "MIT", - "dependencies": { - "@zk-kit/lean-imt.sol": "2.0.1" - } - }, "node_modules/@semaphore-protocol/group": { "version": "4.14.3", "resolved": "https://registry.npmjs.org/@semaphore-protocol/group/-/group-4.14.3.tgz", @@ -3591,132 +3833,6 @@ "poseidon-lite": "0.3.0" } }, - "node_modules/@semaphore-protocol/proof": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@semaphore-protocol/proof/-/proof-4.14.3.tgz", - "integrity": "sha512-Zu7vIA161+jieo1DzhKoGB/UgTnG6cGMJtAN1/O6o4yKXKBkq7ly7JC3RZ0SbayfdN4TpTJyfO27i1USQ51eyw==", - "license": "MIT", - "dependencies": { - "@semaphore-protocol/utils": "4.14.3", - "@zk-kit/artifacts": "2.0.1", - "@zk-kit/utils": "1.3.0", - "ethers": "6.13.4", - "snarkjs": "0.7.5" - }, - "peerDependencies": { - "@semaphore-protocol/group": "4.14.3", - "@semaphore-protocol/identity": "4.14.3" - } - }, - "node_modules/@semaphore-protocol/proof/node_modules/@adraffy/ens-normalize": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", - "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", - "license": "MIT" - }, - "node_modules/@semaphore-protocol/proof/node_modules/@noble/curves": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", - "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.3.2" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@semaphore-protocol/proof/node_modules/@noble/hashes": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", - "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@semaphore-protocol/proof/node_modules/@types/node": { - "version": "22.7.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", - "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.19.2" - } - }, - "node_modules/@semaphore-protocol/proof/node_modules/ethers": { - "version": "6.13.4", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.13.4.tgz", - "integrity": "sha512-21YtnZVg4/zKkCQPjrDj38B1r4nQvTZLopUGMLQ1ePU2zV/joCfDC3t3iKQjWRzjjjbzR+mdAIoikeBRNkdllA==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/ethers-io/" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@adraffy/ens-normalize": "1.10.1", - "@noble/curves": "1.2.0", - "@noble/hashes": "1.3.2", - "@types/node": "22.7.5", - "aes-js": "4.0.0-beta.5", - "tslib": "2.7.0", - "ws": "8.17.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@semaphore-protocol/proof/node_modules/tslib": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", - "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", - "license": "0BSD" - }, - "node_modules/@semaphore-protocol/proof/node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "license": "MIT" - }, - "node_modules/@semaphore-protocol/proof/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/@semaphore-protocol/utils": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@semaphore-protocol/utils/-/utils-4.14.3.tgz", - "integrity": "sha512-22oW6hyw2y1m5JKFM4rop7b342M+PV/VC4dmaByXZ3nTGWwDe4SDcgujycIoR2EQq3eVc8C0rfeMeUhzQHidxA==", - "license": "MIT", - "dependencies": { - "ethers": "^6.13.4" - } - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3964,6 +4080,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", @@ -4202,19 +4384,11 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mute-stream": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", - "integrity": "sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "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": { "undici-types": "~8.9.0" @@ -4240,13 +4414,6 @@ "@types/react": "^19.3.0" } }, - "node_modules/@types/snarkjs": { - "version": "0.7.9", - "resolved": "https://registry.npmjs.org/@types/snarkjs/-/snarkjs-0.7.9.tgz", - "integrity": "sha512-pb4Bq3GI2YQOQOG0dR/YuQs/mqcuL6k/vnz68LIPtpA2frrUL3twf69a3AUK9eUmNNeW0RIKkq6scDlC75Is+g==", - "license": "MIT", - "peer": true - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4259,12 +4426,6 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, - "node_modules/@types/wrap-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", - "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==", - "license": "MIT" - }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -4616,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" }, @@ -4654,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": { @@ -4664,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": { @@ -5092,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": { @@ -5152,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", @@ -5191,23 +5330,6 @@ "node": ">=10.13.0" } }, - "node_modules/@zk-kit/artifacts": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@zk-kit/artifacts/-/artifacts-2.0.1.tgz", - "integrity": "sha512-RvEOFkU5LAKqEre/o94pRcjYMyqHxs6TpLDE6wLLsfUWgcxabaWsZ7/UqLikR/ueQjspMDGuJYi3n9FlK/K9Kg==", - "license": "MIT", - "dependencies": { - "@commander-js/extra-typings": "^12.1.0", - "@inquirer/input": "^2.1.8", - "@inquirer/select": "^2.3.4", - "circomkit": "^0.2.1", - "ora": "^5.4.1", - "snarkjs": "^0.7.4" - }, - "bin": { - "snarkli": "dist/cli/index.js" - } - }, "node_modules/@zk-kit/baby-jubjub": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@zk-kit/baby-jubjub/-/baby-jubjub-1.0.3.tgz", @@ -5256,15 +5378,6 @@ "@zk-kit/utils": "1.4.1" } }, - "node_modules/@zk-kit/lean-imt.sol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@zk-kit/lean-imt.sol/-/lean-imt.sol-2.0.1.tgz", - "integrity": "sha512-2dy/wfzch2vy5QtDCFmQwN7E4GZP0xBcucTb2mfZoJP5V69cjPRdy6KcbS0YOcBMH/Lb7BgXLsekRYjh9JNKEw==", - "license": "MIT", - "dependencies": { - "poseidon-solidity": "0.0.5" - } - }, "node_modules/@zk-kit/lean-imt/node_modules/@adraffy/ens-normalize": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", @@ -5425,21 +5538,6 @@ "node": ">= 6.0.0" } }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -5550,15 +5648,6 @@ "util": "^0.12.5" } }, - "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -5622,6 +5711,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -5659,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", @@ -5751,60 +5827,6 @@ "url": "https://opencollective.com/bigjs" } }, - "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==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "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", @@ -6059,6 +6081,7 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -6077,6 +6100,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6090,6 +6114,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -6132,40 +6157,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chai": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", - "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", - "license": "MIT", - "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/charenc": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", @@ -6176,30 +6167,12 @@ "node": "*" } }, - "node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.2" - }, - "engines": { - "node": "*" - } - }, "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/child_process": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz", - "integrity": "sha512-Wmza/JzL0SiWz7kl6MhIKT5ceIlnFPJX+lwUGj7Clhy5MMldsSoJR0+uvRzOS5Kv45Mq7t1PoE8TsOA9bzvb6g==", - "license": "ISC" - }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -6253,96 +6226,6 @@ "web-worker": "1.2.0" } }, - "node_modules/circom_tester": { - "version": "0.0.19", - "resolved": "https://registry.npmjs.org/circom_tester/-/circom_tester-0.0.19.tgz", - "integrity": "sha512-SNHaBsGxcBH6XsVWfsRbRPA7NF8m8AMKJI9dtJJCFGUtOTT2+zsoIqAwi50z6XCnO4TtjyXq7AeXa1PLHqT0tw==", - "license": "GPL-3.0", - "dependencies": { - "chai": "^4.3.6", - "child_process": "^1.0.2", - "ffjavascript": "^0.2.56", - "fnv-plus": "^1.3.1", - "r1csfile": "^0.0.41", - "snarkjs": "0.5.0", - "tmp-promise": "^3.0.3", - "util": "^0.12.4" - } - }, - "node_modules/circom_tester/node_modules/circom_runtime": { - "version": "0.1.21", - "resolved": "https://registry.npmjs.org/circom_runtime/-/circom_runtime-0.1.21.tgz", - "integrity": "sha512-qTkud630B/GK8y76hnOaaS1aNuF6prfV0dTrkeRsiJKnlP1ryQbP2FWLgDOPqn6aKyaPlam+Z+DTbBhkEzh8dA==", - "license": "Apache-2.0", - "dependencies": { - "ffjavascript": "0.2.56" - }, - "bin": { - "calcwit": "calcwit.js" - } - }, - "node_modules/circom_tester/node_modules/ffjavascript": { - "version": "0.2.56", - "resolved": "https://registry.npmjs.org/ffjavascript/-/ffjavascript-0.2.56.tgz", - "integrity": "sha512-em6G5Lrj7ucIqj4TYEgyoHs/j99Urwwqa4+YxEVY2hggnpRimVj+noX5pZQTxI1pvtiekZI4rG65JBf0xraXrg==", - "license": "GPL-3.0", - "dependencies": { - "wasmbuilder": "0.0.16", - "wasmcurves": "0.2.0", - "web-worker": "^1.2.0" - } - }, - "node_modules/circom_tester/node_modules/snarkjs": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/snarkjs/-/snarkjs-0.5.0.tgz", - "integrity": "sha512-KWz8mZ2Y+6wvn6GGkQo6/ZlKwETdAGohd40Lzpwp5TUZCn6N6O4Az1SuX1rw/qREGL6Im+ycb19suCFE8/xaKA==", - "license": "GPL-3.0", - "dependencies": { - "@iden3/binfileutils": "0.0.11", - "bfj": "^7.0.2", - "blake2b-wasm": "^2.4.0", - "circom_runtime": "0.1.21", - "ejs": "^3.1.6", - "fastfile": "0.0.20", - "ffjavascript": "0.2.56", - "js-sha3": "^0.8.0", - "logplease": "^1.2.15", - "r1csfile": "0.0.41" - }, - "bin": { - "snarkjs": "build/cli.cjs" - } - }, - "node_modules/circom_tester/node_modules/wasmcurves": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/wasmcurves/-/wasmcurves-0.2.0.tgz", - "integrity": "sha512-3e2rbxdujOwaod657gxgmdhZNn+i1qKdHO3Y/bK+8E7bV8ttV/fu5FO4/WLBACF375cK0QDLOP+65Na63qYuWA==", - "license": "GPL-3.0", - "dependencies": { - "wasmbuilder": "0.0.16" - } - }, - "node_modules/circomkit": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/circomkit/-/circomkit-0.2.1.tgz", - "integrity": "sha512-7O8QsOLUq2QvwGMimvWxwdg7OgV33OT7ZBND+81dv3JrVp8ove93yV16jF3TW6XBncSY92/Aka8F4CAi/H9VQw==", - "license": "MIT", - "dependencies": { - "circom_tester": "^0.0.19", - "commander": "^12.0.0", - "loglevel": "^1.8.1" - }, - "bin": { - "circomkit": "dist/cli.js" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "@types/snarkjs": "^0.7.x", - "snarkjs": "^0.7.x" - } - }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -6355,39 +6238,6 @@ "url": "https://polar.sh/cva" } }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, "node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -6399,15 +6249,6 @@ "wrap-ansi": "^6.2.0" } }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -6448,15 +6289,6 @@ "node": ">= 0.8" } }, - "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/console-browserify": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", @@ -6820,34 +6652,11 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, - "node_modules/deep-eql": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", - "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", - "license": "MIT", - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -6993,6 +6802,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "devOptional": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -7091,6 +6901,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7100,6 +6911,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7116,6 +6928,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -7385,17 +7198,6 @@ } } }, - "node_modules/ffjavascript": { - "version": "0.2.63", - "resolved": "https://registry.npmjs.org/ffjavascript/-/ffjavascript-0.2.63.tgz", - "integrity": "sha512-dBgdsfGks58b66JnUZeZpGxdMIDQ4QsD3VYlRJyFVrKQHb2kJy4R2gufx5oetrTxXPT+aEjg0dOvOLg1N0on4A==", - "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", @@ -7449,12 +7251,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/fnv-plus": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/fnv-plus/-/fnv-plus-1.3.1.tgz", - "integrity": "sha512-Gz1EvfOneuFfk4yG458dJ3TLJ7gV19q3OM/vVvvHf7eT02Hm1DleB4edsia6ahbKgAYxO9gvyQ1ioWZR+a00Yw==", - "license": "MIT" - }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -7480,6 +7276,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.2.7" @@ -7523,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" }, @@ -7570,6 +7367,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7579,6 +7377,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7593,19 +7392,11 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "devOptional": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -7639,6 +7430,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "devOptional": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -7665,6 +7457,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7693,23 +7486,15 @@ "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", - "ufo": "^1.6.3", - "uncrypto": "^0.1.3" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" } }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -7722,6 +7507,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7734,6 +7520,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "devOptional": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -7774,6 +7561,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "devOptional": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -7942,6 +7730,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, "node_modules/internmap": { @@ -7966,6 +7755,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7989,6 +7779,7 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8026,6 +7817,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.4", @@ -8041,15 +7833,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-nan": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", @@ -8078,6 +7861,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8109,6 +7893,7 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -8120,18 +7905,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -8207,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", @@ -8630,50 +8397,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/loglevel": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", - "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - }, - "funding": { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/loglevel" - } - }, "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/loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.1" - } - }, "node_modules/lru-cache": { "version": "11.5.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", @@ -8684,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" @@ -8716,6 +8445,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8796,15 +8526,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -8830,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" @@ -8857,21 +8578,6 @@ "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", "license": "(Apache-2.0 AND MIT)" }, - "node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "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", @@ -9130,44 +8836,6 @@ "node": ">=14.0.0" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/os-browserify": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", @@ -9218,6 +8886,55 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/oxlint": { + "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": { + "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.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": { + "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", @@ -9259,18 +8976,6 @@ "node": ">=6" } }, - "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==", - "license": "MIT", - "dependencies": { - "bigint-crypto-utils": "^3.3.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -9331,15 +9036,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/pbkdf2": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.6.tgz", @@ -9427,6 +9123,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", @@ -9442,16 +9167,11 @@ "integrity": "sha512-ilJj4MIve4uBEG7SrtPqUUNkvpJ/pLVbndxa0WvebcQqeIhe+h72JR4g0EvwchUzm9sOQDlOjiDNmRAgxNZl4A==", "license": "MIT" }, - "node_modules/poseidon-solidity": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/poseidon-solidity/-/poseidon-solidity-0.0.5.tgz", - "integrity": "sha512-NzrvSwHzvZgT4hvg2GyGqeR+UOU/eLSEt4wAoXEua+VaR7NTKKwx1X9bPlh1VMBEVEno+IWvkRBbidFGzTeAqQ==", - "license": "MIT" - }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9679,38 +9399,6 @@ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, - "node_modules/r1csfile": { - "version": "0.0.41", - "resolved": "https://registry.npmjs.org/r1csfile/-/r1csfile-0.0.41.tgz", - "integrity": "sha512-Q1WDF3u1vYeAwjHo4YuddkA8Aq0TulbKjmGm99+Atn13Lf5fTsMZBnBV9T741w8iSyPFG6Uh6sapQby77sREqA==", - "license": "GPL-3.0", - "dependencies": { - "@iden3/bigarray": "0.0.2", - "@iden3/binfileutils": "0.0.11", - "fastfile": "0.0.20", - "ffjavascript": "0.2.56" - } - }, - "node_modules/r1csfile/node_modules/ffjavascript": { - "version": "0.2.56", - "resolved": "https://registry.npmjs.org/ffjavascript/-/ffjavascript-0.2.56.tgz", - "integrity": "sha512-em6G5Lrj7ucIqj4TYEgyoHs/j99Urwwqa4+YxEVY2hggnpRimVj+noX5pZQTxI1pvtiekZI4rG65JBf0xraXrg==", - "license": "GPL-3.0", - "dependencies": { - "wasmbuilder": "0.0.16", - "wasmcurves": "0.2.0", - "web-worker": "^1.2.0" - } - }, - "node_modules/r1csfile/node_modules/wasmcurves": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/wasmcurves/-/wasmcurves-0.2.0.tgz", - "integrity": "sha512-3e2rbxdujOwaod657gxgmdhZNn+i1qKdHO3Y/bK+8E7bV8ttV/fu5FO4/WLBACF375cK0QDLOP+65Na63qYuWA==", - "license": "GPL-3.0", - "dependencies": { - "wasmbuilder": "0.0.16" - } - }, "node_modules/radix3": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", @@ -9945,6 +9633,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -10089,25 +9778,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, "node_modules/ripemd160": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", @@ -10186,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": { @@ -10202,27 +9872,28 @@ "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": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, "funding": [ { "type": "github", @@ -10243,6 +9914,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -10312,6 +9984,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -10429,25 +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/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/slow-redact": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/slow-redact/-/slow-redact-0.3.2.tgz", @@ -10455,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" }, @@ -10485,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", @@ -10557,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", @@ -10608,6 +10266,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -10652,18 +10311,6 @@ "node": ">=8" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -10793,24 +10440,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tmp": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", - "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, - "node_modules/tmp-promise": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", - "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", - "license": "MIT", - "dependencies": { - "tmp": "^0.2.0" - } - }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -10871,27 +10500,6 @@ "dev": true, "license": "MIT" }, - "node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -10983,6 +10591,7 @@ "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, "license": "MIT" }, "node_modules/unstorage": { @@ -11201,6 +10810,7 @@ "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -11214,6 +10824,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/valtio": { @@ -11392,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": { @@ -11760,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", @@ -11775,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" @@ -11793,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" @@ -11897,15 +11508,6 @@ "wasmbuilder": "0.0.16" } }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, "node_modules/web-worker": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.2.0.tgz", @@ -11975,6 +11577,7 @@ "version": "1.1.22", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -11993,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": { @@ -12177,18 +11776,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "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/frontend/package.json b/frontend/package.json index 62be5e2..32100a0 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", @@ -20,46 +25,49 @@ "@radix-ui/react-select": "^2.3.7", "@sd-jwt/core": "^0.21.0", "@sd-jwt/present": "^0.19.0", - "@semaphore-protocol/contracts": "^4.14.3", "@semaphore-protocol/group": "^4.14.3", "@semaphore-protocol/identity": "^4.14.3", - "@semaphore-protocol/proof": "^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", - "paillier-bigint": "^3.4.3", + "lucide-react": "^1.48.0", + "poseidon-lite": "^0.3.0", "qrcode.react": "^4.2.0", "react": "^19.3.0", "react-dom": "^19.3.0", "react-i18next": "^17.0.15", "react-router-dom": "^7.18.4", "recharts": "^3.10.1", + "snarkjs": "^0.7.6", "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", "@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", + "@zk-kit/baby-jubjub": "^1.0.3", "autoprefixer": "^10.6.1", "globals": "^17.12.0", "jsdom": "^30.1.1", + "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/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/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/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/DatePicker.tsx b/frontend/src/components/ui/DatePicker.tsx index b13ab73..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 ( + + + + + ); +} 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/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/SchedulePromise.tsx b/frontend/src/components/ui/SchedulePromise.tsx index 1bdaf86..eac2274 100644 --- a/frontend/src/components/ui/SchedulePromise.tsx +++ b/frontend/src/components/ui/SchedulePromise.tsx @@ -60,7 +60,7 @@ interface SchedulePromiseProps { * * WHAT IT IS ABOUT. An organizer can normally close enrolment or voting early, * and while doing it they can see exactly what they are doing: `memberCount` and - * `distinctVoters` are public and rise in real time. So the roll can be cut off + * `voteCount` are public and rise in real time. So the roll can be cut off * once it suits, and the vote ended once the result does, and neither leaves any * trace of why. Giving that power up at deployment is a promise the contract * keeps rather than one the organizer makes. diff --git a/frontend/src/components/ui/SignOutActions.tsx b/frontend/src/components/ui/SignOutActions.tsx index db47dee..c05fc09 100644 --- a/frontend/src/components/ui/SignOutActions.tsx +++ b/frontend/src/components/ui/SignOutActions.tsx @@ -10,8 +10,8 @@ * Each exit clears its OWN browser data and leaves the other session untouched, * which is the whole reason the two are separable: `voterSignOut` takes the * voting identity and this device's vote records with it, `organizerSignOut` - * takes the remembered wallet and display name. Neither touches the Paillier - * tally keys, since those decrypt elections that are already on chain. + * takes the remembered wallet and display name. Neither touches the stored + * tally keys, since those count elections that are already on chain. * * Shared by both profiles so the wording and the confirmation cannot drift, and * so neither page has to work out where to send someone whose other session is diff --git a/frontend/src/components/ui/TallyCheck.tsx b/frontend/src/components/ui/TallyCheck.tsx index 4e1cdb5..8f22a9c 100644 --- a/frontend/src/components/ui/TallyCheck.tsx +++ b/frontend/src/components/ui/TallyCheck.tsx @@ -1,52 +1,82 @@ +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ShieldAlert, ShieldCheck } from 'lucide-react'; +import { isAddress } from 'ethers'; +import { Loader2, ShieldAlert, ShieldCheck } 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. + * The contract accepted the counts only with a proof that they decrypt its + * aggregate; this re-adds every ballot in the reader's own browser and checks + * the aggregate is their sum (see `tallyAudit`). 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 : ShieldAlert; + const tone = audit.status === 'verified' ? 'text-success' : 'text-error'; 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', { + ballots: audit.ballots, + voters: t('results.voters_count', { count: audit.voters }), + }) + : t(`results.check_${audit.status}_body`)}

-

{t('results.check_limit')}

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

{audit.reason}

+ )}
diff --git a/frontend/src/components/voter/PhraseCheck.test.tsx b/frontend/src/components/voter/PhraseCheck.test.tsx index 8273e37..289e4f6 100644 --- a/frontend/src/components/voter/PhraseCheck.test.tsx +++ b/frontend/src/components/voter/PhraseCheck.test.tsx @@ -11,7 +11,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, within } from '@testing-library/react'; -const PHRASE = 'harbor pewter pepper pine denim prairie meadow arrow crystal lagoon cinder anchor'; +const PHRASE = 'habit nacho koala panda gecko quilt jelly bagel fable igloo daisy acorn'; const WORDS = PHRASE.split(' '); const { PhraseCheck } = await import('./PhraseCheck'); diff --git a/frontend/src/components/voter/PhraseInput.test.tsx b/frontend/src/components/voter/PhraseInput.test.tsx index 301b7de..4ab4722 100644 --- a/frontend/src/components/voter/PhraseInput.test.tsx +++ b/frontend/src/components/voter/PhraseInput.test.tsx @@ -15,7 +15,7 @@ import { useState } from 'react'; import { PhraseInput } from './PhraseInput'; -const FRASE = 'harbor pewter pepper pine denim prairie meadow arrow crystal lagoon cinder anchor'; +const FRASE = 'habit nacho koala panda gecko quilt jelly bagel fable igloo daisy acorn'; /** Controlled the way the screen controls it: one string, in and out. */ function Anfitrion({ inicial = '' }: { inicial?: string }) { @@ -64,12 +64,12 @@ describe('typing the phrase back in', () => { it('pastes into the boxes from where it lands, not always the first', () => { render(); - pegar(cajas()[4], 'denim prairie meadow'); + pegar(cajas()[4], 'gecko quilt jelly'); const puestas = cajas().map(c => c.value); - expect(puestas[4]).toBe('denim'); - expect(puestas[5]).toBe('prairie'); - expect(puestas[6]).toBe('meadow'); + expect(puestas[4]).toBe('gecko'); + expect(puestas[5]).toBe('quilt'); + expect(puestas[6]).toBe('jelly'); expect(puestas[0]).toBe(''); }); @@ -86,29 +86,29 @@ describe('typing the phrase back in', () => { // Not intercepted: one word is a normal edit, and preventing it would stop // somebody fixing a single box from their clipboard. - const evento = fireEvent.paste(cajas()[3], { clipboardData: { getData: () => 'pine' } }); + const evento = fireEvent.paste(cajas()[3], { clipboardData: { getData: () => 'panda' } }); expect(evento).toBe(true); }); it('moves on when a word is finished with a space', () => { render(); - fireEvent.change(cajas()[0], { target: { value: 'harbor ' } }); + fireEvent.change(cajas()[0], { target: { value: 'habit ' } }); - expect(cajas()[0].value).toBe('harbor'); + expect(cajas()[0].value).toBe('habit'); expect(document.activeElement).toBe(cajas()[1]); }); it('lowercases what is typed, because the words are', () => { render(); - fireEvent.change(cajas()[0], { target: { value: 'HARBOR' } }); + fireEvent.change(cajas()[0], { target: { value: 'HABIT' } }); - expect(valor()?.startsWith('harbor')).toBe(true); + expect(valor()?.startsWith('habit')).toBe(true); }); it('walks back when backspace is pressed in an empty box', () => { - render(); + render(); cajas()[2].focus(); fireEvent.keyDown(cajas()[2], { key: 'Backspace' }); @@ -119,12 +119,12 @@ describe('typing the phrase back in', () => { it('keeps a half-filled grid down to the words it holds', () => { render(); - fireEvent.change(cajas()[0], { target: { value: 'harbor' } }); - fireEvent.change(cajas()[2], { target: { value: 'pepper' } }); + fireEvent.change(cajas()[0], { target: { value: 'habit' } }); + fireEvent.change(cajas()[2], { target: { value: 'koala' } }); // The gap is an empty box, not a word: the screen normalises before sealing, and // this is the string it gets. - expect(valor()?.split(' ').filter(Boolean)).toEqual(['harbor', 'pepper']); + expect(valor()?.split(' ').filter(Boolean)).toEqual(['habit', 'koala']); }); @@ -143,12 +143,12 @@ describe('typing the phrase back in', () => { render(); expect(screen.queryByText('recover.clear')).toBeNull(); - fireEvent.change(cajas()[0], { target: { value: 'harbor' } }); + fireEvent.change(cajas()[0], { target: { value: 'habit' } }); expect(screen.getByText('recover.clear')).toBeInTheDocument(); }); it('marks the box holding a word that is not on the list', () => { - render(); + render(); const marcadas = (screen.getAllByRole('textbox') as HTMLInputElement[]) .filter(c => c.getAttribute('aria-invalid') === 'true'); diff --git a/frontend/src/contexts/AuthProvider.tsx b/frontend/src/contexts/AuthProvider.tsx index c3e5575..9b0bc74 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); }, []); @@ -291,9 +296,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { }; // The remembered wallet address and display name identify the organizer, so - // they go too. The votain_paillier_sk_* entries deliberately do NOT: they are - // the tally private keys for elections already on chain, and deleting them - // would leave those results permanently undecryptable. Signing out must not be + // they go too. The votain_tally_keys_* entries deliberately do NOT: they are + // the tally keys for elections already on chain, and deleting them would leave + // those results permanently undecryptable. Signing out must not be // able to destroy an election's outcome. // // The cached wallet SIGNATURE is a different matter and does go: it is the diff --git a/frontend/src/data/seed.ts b/frontend/src/data/seed.ts index 5bdace8..b3379e3 100644 --- a/frontend/src/data/seed.ts +++ b/frontend/src/data/seed.ts @@ -41,7 +41,7 @@ export interface VoteRecord { phase: ElectionPhase; date: Date; referenceNumber: string; - nullifier: string; + tag: string; } /** @@ -114,18 +114,12 @@ export interface Election { totalEnrolled: number; /** Ballots on chain, re-votes included. Not the number of people. */ 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. + * People who voted, as the tally proved it. Undefined until a result is + * published (or the election voided below its quorum): while voting runs, + * nothing public says which ballots are re-votes, so nobody can count them. */ - tallyCheck?: { declared: number; voters: number; matches: boolean }; + distinctVoters?: number; contractAddress: string; ipfsCid?: string; /** Attribute restrictions on enrolment, verified against the contract's hash. */ @@ -174,14 +168,14 @@ export interface Election { hasVoted?: boolean; userVote?: string; /** - * The vote's anonymous on-chain identifier, as `0x` hex. + * The tag of this device's latest ballot here, as `0x` hex: its anonymous + * on-chain identifier, linked to no other ballot of the voter's. * - * Named for what it is. It used to be called `referenceNumber`, which is also - * what the history and the confirmation screen call a TRANSACTION HASH, so - * one name meant two different values and either could be handed to the - * verifier expecting the other to work. + * Named for what it is, and not `referenceNumber`, which is what the history + * and the confirmation screen call a TRANSACTION HASH: one name for two + * values let either be handed to a verifier expecting the other. */ - voteNullifier?: string; + ballotTag?: string; tags?: string[]; } @@ -273,7 +267,7 @@ export const ELECTIONS: Election[] = [ isEnrolled: true, hasVoted: true, userVote: 'c10', - voteNullifier: 'VTN-2025-003841', + ballotTag: 'VTN-2025-003841', tags: ['government', 'regional', 'infrastructure'], candidates: [ { id: 'c9', name: 'High-speed rail expansion', description: 'Connect all towns >5k pop. by 2030' }, @@ -308,7 +302,7 @@ export const ELECTIONS: Election[] = [ isEnrolled: true, hasVoted: true, userVote: 'c15', - voteNullifier: 'VTN-2025-001122', + ballotTag: 'VTN-2025-001122', tags: ['neighbourhood', 'local'], candidates: [ { id: 'c14', name: 'Itziar Zubicaray', description: 'Incumbent president', votes: 198, isWinner: true }, @@ -383,7 +377,7 @@ export const VOTER_HISTORY: VoteRecord[] = [ phase: 'tallying', date: past(3), referenceNumber: 'VTN-2025-003841', - nullifier: '0x1234…abcd', + tag: '0x1234…abcd', }, { electionId: 'e4', @@ -392,7 +386,7 @@ export const VOTER_HISTORY: VoteRecord[] = [ phase: 'closed', date: past(18), referenceNumber: 'VTN-2025-001122', - nullifier: '0x5678…ef01', + tag: '0x5678…ef01', }, ]; 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/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/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/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/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/i18n/locales/ar.json b/frontend/src/i18n/locales/ar.json index b378ad6..9c9f5b3 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": "نعم", @@ -211,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": "مبني على", @@ -228,11 +230,17 @@ "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": "لم تقبل الانتخابات هذه النتائج إلا مع إثبات معرفة صفرية بأنها فكّ تشفير مجموع كل البطاقات، وقد أُعيد هنا جمع البطاقات الموجودة على السلسلة وعددها {{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": "تعذّر التحقق من الأعداد المنشورة مقابل أوراق الاقتراع المسجلة على السلسلة. لا تعتمد على هذه النتيجة." }, "verify_receipt": { "title": "تحقّق من صوت", @@ -248,7 +256,6 @@ "reference": "المرجع", "nullifier": "معرّف الصوت", "date": "التاريخ", - "ballots": "الأصوات المُدلى بها", "choice_never_shown": "يحفظ السجل العام أن صوتًا أُدلي به ومتى، لا ما كان يحتويه أبدًا. لا أحد يستطيع قراءة الاختيار من إيصال، ولا نحن.", "your_receipts": "إيصالاتك", "unlock_hint": "لا يعرف هذا المتصفح أي صوت بعد. إن كنت قد صوّتّ من جهاز آخر، فلمسة واحدة بمفتاح المرور ستجدها وتتذكرها هنا." @@ -291,7 +298,8 @@ "reference": "رقم المرجع", "view_tx": "عرض السجل العام", "back_elections": "العودة إلى الانتخابات", - "check_receipt": "تحقّق من إيصالي" + "check_receipt": "تحقّق من إيصالي", + "demo_reference": "بيانات تجريبية: لم تُسجَّل أي ورقة اقتراع، لذا لا يوجد مرجع للتحقق منه." }, "change_vote": { "title": "تغيير صوتك", @@ -482,7 +490,9 @@ "open_voting_early": "افتح التصويت الآن", "open_voting_title": "بدء التصويت قبل موعده؟", "open_voting_desc": "أُغلق التسجيل ولم يحن التصويت بعد. سيتمكن كل المسجّلين من التصويت من الآن. تاريخ الإغلاق لا يتغير.", - "voting_opened_done": "التصويت مفتوح" + "voting_opened_done": "التصويت مفتوح", + "void_refused": "تعهّدت هذه الانتخابات بعدم إلغائها، وقد صوّت عدد كافٍ من الأشخاص لنشر نتيجتها، لذا لا يمكن إبطالها. استورد مفتاح فك التشفير لنشر النتيجة.", + "key_device_only": "تعذّر اشتقاق مفتاح فك تشفير هذه الانتخابات من محفظتك، لذا فهو موجود على هذا الجهاز فقط. صدّره الآن واحفظه بأمان: بدونه لا يمكن فرز الأصوات." }, "gas": { "title": "إدارة الغاز", @@ -620,7 +630,13 @@ "wallet_request_rejected": "لقد ألغيت الطلب في محفظتك. لم يحدث أي شيء.", "passkey_cancelled": "أغلقت طلب مفتاح المرور. لم يُنشأ أي شيء: يمكنك المحاولة مرة أخرى.", "list_unreadable": "تعذّرت قراءة هذه القائمة من السلسلة، وهذا ليس كأنها فارغة. تحقق من الاتصال ثم أعد المحاولة.", - "passkey_unproven": "أُنشئ مفتاح المرور، لكننا لم نتأكد بعد من قدرته على فكّ التشفير. اضغط مرة أخرى: سُيسأل المفتاح نفسه، دون إنشاء مفتاح جديد." + "election_unreadable": "تعذّرت قراءة هذه الانتخابات من السلسلة، وهذا لا يعني أنها غير موجودة. تحقق من الاتصال ثم حاول مرة أخرى.", + "passkey_unproven": "أُنشئ مفتاح المرور، لكننا لم نتأكد بعد من قدرته على فكّ التشفير. اضغط مرة أخرى: سُيسأل المفتاح نفسه، دون إنشاء مفتاح جديد.", + "epoch_already_cast": "لقد غيّرت صوتك منذ وقت قصير جدًا. تُحتسب بطاقتك الأخيرة؛ يمكنك تغييرها مجددًا خلال ساعة.", + "epoch_already_cast_until": "لقد غيّرت صوتك منذ وقت قصير جدًا. تُحتسب بطاقتك الأخيرة؛ يمكنك تغييرها مجددًا ابتداءً من {{time}}.", + "epoch_wait_title": "صوتك الأخير هو المحتسب", + "ballot_already_cast": "هذه البطاقة مسجّلة بالفعل. لا حاجة لفعل أي شيء آخر: صوتك محتسب.", + "ballot_expired": "أُعدّت هذه البطاقة منذ وقت طويل جدًا فلم تعد مقبولة. يُرجى التصويت مجددًا؛ إعداد بطاقة جديدة يستغرق بضع ثوانٍ." }, "demo": { "banner": "بيانات تجريبية: لا توجد عقود منشورة. لا شيء هنا على السلسلة." @@ -670,7 +686,7 @@ }, "terms": { "title": "شروط الاستخدام", - "updated": "آخر تحديث: 3 سبتمبر 2026", + "updated": "آخر تحديث: 28 سبتمبر 2026", "what_title": "ما هو Votain", "what_1": "‏Votain مشروع تخرج جامعي: تطبيق تصويت مجهول قابل للتحقق من طرف إلى طرف، مبني على شبكة الاختبار Polygon Amoy. إنه نموذج بحثي أولي وليس خدمة تجارية.", "what_2": "يعمل على شبكة اختبار. الرموز المستخدمة فيه لا قيمة مالية لها، والشبكة لا تضمن الاستمرارية: فشبكات الاختبار تُعاد تهيئتها أو تُغلق من حين لآخر، وقد يزول معها أي انتخاب أُجري هنا.", @@ -682,7 +698,7 @@ "identity_2": "من يملك العبارة يستطيع استعادة الهوية على أي جهاز، بلا مفتاح مرور وبلا مساعدة منا. وإذا فقدت العبارة وكل مفاتيح المرور التي كانت تحفظها، فإن التحقق من جديد عبر World ID يتيح لك البدء من الصفر. وهذه البداية محدودة عن قصد: يمكنك الانضمام إلى انتخابات لم تكن قد سجّلت فيها، لكن لا يمكنك أبداً العودة إلى انتخاب كنت مسجّلاً فيه، لأن النظام لا يستطيع معرفة ما إذا كنت قد صوّت هناك دون كسر سرية الصوت.", "organizer_title": "إذا كنت تنظم انتخابًا", "organizer_1": "أنت مسؤول عما تنشره على السلسلة. تُخزَّن الأسماء والأوصاف وقوائم المرشحين بشكل علني ودائم، ولا يمكن تعديلها أو حذفها بعد النشر. لا تُدرج فيها بيانات شخصية تخص الآخرين.", - "organizer_2": "أنت تموّل رصيد غاز واحدًا يدفع رسوم معاملات الناخبين في جميع انتخاباتك. وإذا نفد، توقف التصويت في كل واحد منها حتى تعيد تعبئته.", + "organizer_2": "يدفع كل اقتراع رسوم ناخبيه أولًا من الغاز الذي حجزته له، ولا يمكنك سحبه حتى ينتهي، ثم من رصيدك الحر الذي تتشاركه كل انتخاباتك. إذا نفد الاثنان يتوقف التصويت في ذلك الاقتراع حتى تعيد التعبئة.", "warranty_title": "بلا ضمان", "warranty_1": "يُقدَّم Votain كما هو، دون أي ضمان من أي نوع. ولم يخضع لتدقيق مستقل. وقد يحتوي على عيوب تؤثر على صحة التصويت أو توافره أو خصوصيته.", "warranty_2": "إلى أقصى حد يسمح به القانون، لا يتحمل المؤلف أي مسؤولية عن خسارة أو ضرر ناشئ عن الاستخدام.", @@ -691,9 +707,9 @@ }, "privacy": { "title": "الخصوصية", - "updated": "آخر تحديث: 16 سبتمبر 2026", + "updated": "آخر تحديث: 28 سبتمبر 2026", "summary_title": "باختصار", - "summary_1": "لا نعرف اسمك ولا وجهك ولا بياناتك الحيوية في أي وقت. ولا نعرف بماذا صوّتّ، والمنظِّم لا يعرف كذلك، لأن الأوراق تُشفَّر قبل أن تغادر جهازك ولا يُفك تشفير سوى المجموع النهائي.", + "summary_1": "لا نعرف أبدًا اسمك أو وجهك أو بياناتك الحيوية. لا نعرف كيف صوّتّ. تُشفَّر البطاقات قبل أن تغادر جهازك ولا يُفك إلا تشفير المجموع النهائي؛ يستطيع مفتاح المنظّم فتح بطاقة واحدة، لكن لا شيء فيها يدل على صاحبها.", "summary_2": "تصف هذه الصفحة ما يفعله البرنامج فعلًا لا ما يُسمح له بفعله، بما في ذلك المواضع التي لا يحميك فيها التصميم.", "worldid_title": "ما يسلّمه لنا World ID", "worldid_1": "عند التحقق يرسل إلينا World ID برهانًا ومُبطِلًا: رقمًا مشتقًا منك ومن هذا التطبيق. وهو نفسه في كل مرة تعود فيها، وبه يُمنَع الحساب الواحد من التسجيل مرتين في الانتخاب نفسه، ويختلف مع كل تطبيق تستخدم فيه World ID. وهو لا يثبت أنك شخص واحد: فالانتخاب الذي يحتاج ذلك يطلب أيضًا فحص مستند يُنتج رقمًا ثانيًا خاصًا بذلك الانتخاب وحده.", @@ -702,14 +718,15 @@ "identity_1": "سرّك الانتخابي عبارة تُولَّد على جهازك وتُعرض عليك مرة واحدة كي تحتفظ بها. وحيث يستطيع مفتاح مرور حفظها، تُخزَّن نسخة مختومة لكل مفتاح مرور تسجّله، فتصوّت من عدة أجهزة دون كتابة أي شيء. وتُكتب تلك النسخ على البلوكتشين لا على خادمنا، فاختفاؤنا لا يحبسك خارجاً، وما يفتحها لا يغادر مُصادِقك أبداً. نحن لا نرى العبارة ولا نستطيع استعادتها لك.", "identity_2": "ليست هذه مسألة شكلية. فلو كان بوسعنا فك تشفيره، لأمكننا حساب المُعرِّف الذي تحمله ورقتك وربط كل صوت بمن أدلى به. التشفير هو ما يجعل ذلك مستحيلًا علينا، لا ما يمنعنا منه فحسب.", "ballot_title": "ورقتك", - "ballot_1": "يُشفَّر اختيارك في متصفحك قبل إرساله، بمفتاح يحتفظ به المنظِّم. وتُجمع الأوراق وهي مشفّرة ولا يُفك تشفير سوى المجموع، فلا يُكشف أي صوت منفرد لأحد، ولا للمنظِّم نفسه.", - "ballot_2": "يمكنك إعادة التصويت كما تشاء؛ ولا تُحتسب إلا الورقة الأخيرة. وتبقى الأوراق السابقة مخزّنة لكنها لا تُميَّز عنها، وهذا بالذات ما يتيح لك أن تستبدل بهدوء صوتًا أدليت به تحت ضغط.", + "ballot_1": "يُشفَّر اختيارك في متصفحك قبل إرساله بمفاتيح يحتفظ بها المنظّم. تجمع الانتخابات كل البطاقات وهي مشفّرة، ولا يُفك إلا تشفير هذا المجموع، مع إثبات يتحقق منه العقد. تستطيع هذه المفاتيح فتح أي بطاقة منفردة، ولهذا لا يوجد في البطاقة ما يدل على من أدلى بها ولا على أي من بطاقاتك سبقها.", + "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 bfa7f56..5657fb1 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", @@ -203,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", @@ -220,11 +222,13 @@ "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 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." }, "verify_receipt": { "title": "Eine Stimme prüfen", @@ -240,7 +244,6 @@ "reference": "Referenz", "nullifier": "Stimmkennung", "date": "Datum", - "ballots": "Abgegebene Stimmen", "choice_never_shown": "Das öffentliche Register hält fest, dass eine Stimme abgegeben wurde und wann, nie was darin stand. Niemand kann aus einer Quittung eine Wahl ablesen, wir auch nicht.", "your_receipts": "Ihre Quittungen", "unlock_hint": "Dieser Browser kennt noch keine Stimme. Falls Sie von einem anderen Gerät gewählt haben, findet eine Passkey-Bestätigung sie und merkt sie sich hier." @@ -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,9 @@ "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." }, "gas": { "title": "Gas-Verwaltung", @@ -600,7 +606,13 @@ "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." + "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": "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." }, "demo": { "banner": "Demo-Daten: keine Verträge bereitgestellt. Nichts hiervon ist on-chain." @@ -650,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.", @@ -662,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.", @@ -671,9 +683,9 @@ }, "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 gewählt haben, und die organisierende Person ebenso wenig, denn Stimmzettel werden verschlüsselt, bevor sie Ihr Gerät verlassen, und nur die Endsumme wird entschlüsselt.", + "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.", "worldid_title": "Was World ID uns übergibt", "worldid_1": "Bei der Verifizierung sendet World ID uns einen Beweis und einen Nullifier: eine Zahl, abgeleitet aus Ihnen und dieser Anwendung. Sie ist bei jeder Rückkehr dieselbe, wodurch ein Konto sich nicht zweimal für dieselbe Wahl anmelden kann, und sie ist für jede Anwendung anders. Sie belegt nicht, dass Sie eine einzelne Person sind: eine Wahl, die das verlangt, fordert zusätzlich eine Dokumentprüfung, die eine zweite Zahl liefert, die nur für diese Wahl gilt.", @@ -682,14 +694,15 @@ "identity_1": "Ihr Wahlgeheimnis ist eine Phrase, die auf Ihrem Gerät erzeugt und Ihnen einmal gezeigt wird, damit Sie sie aufbewahren. Wo ein Passkey sie halten kann, wird für jeden Passkey, den Sie registrieren, eine versiegelte Kopie gespeichert, damit Sie von mehreren Geräten aus wählen können, ohne etwas einzutippen. Diese Kopien werden in die Blockchain geschrieben und nicht auf unseren Server: Uns zu verlieren sperrt Sie nicht aus, und was sie öffnet, verlässt Ihren Authentifikator nie. Wir sehen die Phrase nie und können sie nicht für Sie wiederherstellen.", "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 abgeschickt wird, mit einem Schlüssel der organisierenden Person. Stimmzettel werden in verschlüsseltem Zustand addiert, und nur die Summe wird entschlüsselt, sodass keine einzelne Stimme jemals jemandem offengelegt wird, auch der organisierenden Person nicht.", - "ballot_2": "Sie dürfen beliebig oft erneut wählen; nur der letzte Stimmzettel zählt. Frühere bleiben gespeichert, sind von ihm aber nicht zu unterscheiden, und genau das erlaubt Ihnen, eine unter Druck abgegebene Stimme unbemerkt zu ersetzen.", + "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": "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." @@ -872,16 +885,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 c30ae35..7d2caf1 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", @@ -203,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", @@ -220,11 +222,13 @@ "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 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." }, "verify_receipt": { "title": "Verify a vote", @@ -240,7 +244,6 @@ "reference": "Reference", "nullifier": "Vote identifier", "date": "Date", - "ballots": "Ballots cast", "choice_never_shown": "The public record shows that a vote was cast and when, never what it said. Nobody can read a choice from a receipt, including us.", "your_receipts": "Your receipts", "unlock_hint": "This browser knows of no ballot yet. If you voted from another device, one passkey tap will find those and remember them here." @@ -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,9 @@ "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." }, "gas": { "title": "Gas Management", @@ -600,7 +606,13 @@ "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." + "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 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." }, "demo": { "banner": "Demo data: no contracts deployed. Nothing here is on-chain." @@ -650,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.", @@ -662,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.", @@ -671,9 +683,9 @@ }, "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, and neither does the organizer, because ballots are encrypted before they leave your device and only the final total is ever decrypted.", + "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.", "worldid_title": "What World ID gives us", "worldid_1": "When you verify, World ID sends us a proof and a nullifier: a number derived from you and from this application. It is the same every time you return, which is how one account is stopped from enrolling twice in the same election, and it is different for every application you use World ID with. It does not prove you are one person: an election that needs that asks for a document check as well, which yields a second number, scoped to that election alone.", @@ -682,14 +694,15 @@ "identity_1": "Your voting secret is a phrase generated on your device and shown to you once, so you can keep it. Where a passkey can hold it, a sealed copy is stored for each passkey you register, so you can vote from several devices without typing anything. Those copies are written to the blockchain rather than to our server, so losing us cannot lock you out, and what opens them never leaves your authenticator. We never see the phrase and cannot recover it for you.", "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 a key held by the organizer. Ballots are added together while still encrypted, and only the sum is decrypted, so no individual vote is ever revealed to anyone, including the organizer.", - "ballot_2": "You may vote again as many times as you like; only your last ballot counts. Earlier ones remain stored but are indistinguishable from it, which is what allows you to quietly replace a vote you were pressured into casting.", + "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: 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 486c25a..0fb0e9a 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í", @@ -205,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", @@ -222,11 +224,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": "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." }, "verify_receipt": { "title": "Verifica un voto", @@ -242,7 +247,6 @@ "reference": "Referencia", "nullifier": "Identificador del voto", "date": "Fecha", - "ballots": "Votos emitidos", "choice_never_shown": "El registro público guarda que se emitió un voto y cuándo, nunca qué decía. Nadie puede leer la elección a partir de un recibo, nosotros tampoco.", "your_receipts": "Tus recibos", "unlock_hint": "Este navegador aún no conoce ningún voto. Si votaste desde otro dispositivo, un toque de passkey los encuentra y los recuerda aquí." @@ -282,7 +286,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 +475,9 @@ "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." }, "gas": { "title": "Gestión de Gas", @@ -605,7 +612,13 @@ "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." + "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": "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." }, "demo": { "banner": "Datos de demostración: sin contratos desplegados. Nada de esto está en cadena." @@ -655,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.", @@ -667,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.", @@ -676,9 +689,9 @@ }, "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 qué has votado, y el organizador tampoco, porque las papeletas se cifran antes de salir de tu dispositivo y solo se descifra el total final.", + "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.", "worldid_title": "Qué nos aporta World ID", "worldid_1": "Al verificarte, World ID nos envía una prueba y un nulificador: un número derivado de ti y de esta aplicación. Es el mismo cada vez que vuelves, y así se impide que una misma cuenta se inscriba dos veces en la misma elección; además es distinto para cada aplicación con la que uses World ID. No demuestra que seas una única persona: la elección que lo necesite pide también una comprobación de documento, que produce un segundo número, válido solo para esa elección.", @@ -687,14 +700,15 @@ "identity_1": "Tu secreto de voto es una frase que se genera en tu dispositivo y se te enseña una vez, para que la guardes. Donde una passkey pueda custodiarla, se almacena una copia sellada por cada passkey que registres, para que puedas votar desde varios dispositivos sin escribir nada. Esas copias se escriben en la cadena de bloques y no en nuestro servidor, así que perdernos no te deja fuera, y lo que las abre nunca sale de tu autenticador. Nosotros no vemos la frase ni podemos recuperarla por ti.", "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 una clave que custodia el organizador. Las papeletas se suman sin descifrarse y solo se descifra el total, de modo que ningún voto individual llega a revelarse a nadie, tampoco al organizador.", - "ballot_2": "Puedes volver a votar tantas veces como quieras; solo cuenta la última. Las anteriores siguen almacenadas pero son indistinguibles de ella, y eso es lo que te permite sustituir discretamente un voto que hayas emitido bajo presión.", + "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 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." @@ -875,19 +889,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 79b0fc1..cce6109 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", @@ -205,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", @@ -222,11 +224,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": "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." }, "verify_receipt": { "title": "Vérifier un vote", @@ -242,7 +247,6 @@ "reference": "Référence", "nullifier": "Identifiant du vote", "date": "Date", - "ballots": "Bulletins déposés", "choice_never_shown": "Le registre public retient qu'un vote a été émis et quand, jamais ce qu'il disait. Personne ne peut lire un choix à partir d'un reçu, nous non plus.", "your_receipts": "Vos reçus", "unlock_hint": "Ce navigateur ne connaît encore aucun bulletin. Si vous avez voté depuis un autre appareil, une pression sur votre passkey les trouvera et les retiendra ici." @@ -282,7 +286,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 +475,9 @@ "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." }, "gas": { "title": "Gestion du gaz", @@ -605,7 +612,13 @@ "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." + "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 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." }, "demo": { "banner": "Données de démonstration: aucun contrat déployé. Rien ici n'est sur la blockchain." @@ -655,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.", @@ -667,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.", @@ -676,9 +689,9 @@ }, "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 ce que vous avez voté, et l'organisateur non plus, car les bulletins sont chiffrés avant de quitter votre appareil et seul le total final est déchiffré.", + "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.", "worldid_title": "Ce que World ID nous transmet", "worldid_1": "Lors de la vérification, World ID nous envoie une preuve et un nullificateur : un nombre dérivé de vous et de cette application. Il est identique à chaque retour, ce qui empêche un même compte de s'inscrire deux fois à la même élection, et il diffère pour chaque application utilisant World ID. Il ne prouve pas que vous êtes une seule personne : une élection qui l'exige demande aussi une vérification de document, qui produit un second nombre, propre à cette élection.", @@ -687,14 +700,15 @@ "identity_1": "Votre secret de vote est une phrase générée sur votre appareil et affichée une seule fois, pour que vous la conserviez. Là où une clé d'accès peut la détenir, une copie scellée est conservée pour chaque clé d'accès que vous enregistrez, afin de voter depuis plusieurs appareils sans rien saisir. Ces copies sont écrites sur la blockchain et non sur notre serveur : nous perdre ne vous enferme pas dehors, et ce qui les ouvre ne quitte jamais votre authentificateur. Nous ne voyons jamais la phrase et ne pouvons pas la récupérer pour vous.", "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 l'envoi, avec une clé détenue par l'organisateur. Les bulletins sont additionnés sans être déchiffrés et seul le total l'est, de sorte qu'aucun vote individuel n'est jamais révélé à personne, pas même à l'organisateur.", - "ballot_2": "Vous pouvez voter autant de fois que vous le souhaitez ; seul le dernier bulletin compte. Les précédents restent stockés mais en sont indiscernables, ce qui vous permet de remplacer discrètement un vote émis sous pression.", + "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 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é." @@ -874,20 +888,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 afac9ce..4bb8b32 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": "हाँ", @@ -203,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": "पर निर्मित", @@ -220,11 +222,13 @@ "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": "चुनाव ने ये गिनतियाँ तभी स्वीकार कीं जब साथ में शून्य-ज्ञान प्रमाण था कि ये सभी मतपत्रों के योग का विकोडन हैं, और चेन पर मौजूद {{ballots}} मतपत्रों को यहाँ फिर से जोड़कर वही योग मिला: {{voters}}। कोई भी बिना कुंजी और बिना भरोसे के यह जाँच दोहरा सकता है।", + "voters_count_one": "{{count}} मतदाता", + "voters_count_other": "{{count}} मतदाता", + "check_failed_title": "चेन पर दर्ज मतपत्रों से मेल नहीं खाता", + "check_failed_body": "प्रकाशित गिनती को चेन पर दर्ज मतपत्रों से सत्यापित नहीं किया जा सका। इस परिणाम पर भरोसा न करें।" }, "verify_receipt": { "title": "किसी मत की पुष्टि करें", @@ -240,7 +244,6 @@ "reference": "संदर्भ", "nullifier": "वोट पहचानकर्ता", "date": "दिनांक", - "ballots": "डाले गए मत", "choice_never_shown": "सार्वजनिक रिकॉर्ड में यह रहता है कि वोट पड़ा और कब, कभी यह नहीं कि उसमें क्या था। रसीद से कोई भी पसंद नहीं पढ़ सकता, हम भी नहीं।", "your_receipts": "आपकी रसीदें", "unlock_hint": "इस ब्राउज़र को अभी किसी मत की जानकारी नहीं है। यदि आपने किसी अन्य डिवाइस से मतदान किया, तो एक पासकी टैप उन्हें खोजकर यहाँ याद रख लेगा।" @@ -279,7 +282,8 @@ "reference": "संदर्भ नंबर", "view_tx": "सार्वजनिक रिकॉर्ड देखें", "back_elections": "चुनावों पर वापस जाएं", - "check_receipt": "मेरी रसीद जाँचें" + "check_receipt": "मेरी रसीद जाँचें", + "demo_reference": "डेमो डेटा: कोई मतपत्र दर्ज नहीं हुआ, इसलिए जाँचने के लिए कोई संदर्भ नहीं है।" }, "change_vote": { "title": "अपना वोट बदलें", @@ -466,7 +470,9 @@ "open_voting_early": "मतदान अभी खोलें", "open_voting_title": "मतदान समय से पहले शुरू करें?", "open_voting_desc": "पंजीकरण बंद हो चुका है और मतदान अभी बाक़ी है। सभी पंजीकृत लोग अभी से मत दे सकेंगे। समाप्ति तिथि नहीं बदलती।", - "voting_opened_done": "मतदान खुला है" + "voting_opened_done": "मतदान खुला है", + "void_refused": "इस चुनाव ने वादा किया था कि इसे रद्द नहीं किया जाएगा, और परिणाम प्रकाशित करने लायक पर्याप्त लोगों ने मतदान किया है, इसलिए इसे अमान्य नहीं किया जा सकता। परिणाम प्रकाशित करने के लिए डिक्रिप्शन कुंजी आयात करें।", + "key_device_only": "इस चुनाव की डिक्रिप्शन कुंजी आपके वॉलेट से नहीं बनाई जा सकी, इसलिए यह केवल इसी डिवाइस पर है। इसे अभी निर्यात करें और सुरक्षित रखें: इसके बिना चुनाव की गिनती नहीं हो सकती।" }, "gas": { "title": "गैस प्रबंधन", @@ -600,7 +606,13 @@ "wallet_request_rejected": "आपने अपने वॉलेट में अनुरोध रद्द कर दिया। कुछ नहीं हुआ।", "passkey_cancelled": "आपने पासकी का संवाद बंद कर दिया। कुछ नहीं बना: आप दोबारा कोशिश कर सकते हैं।", "list_unreadable": "यह सूची चेन से नहीं पढ़ी जा सकी, जो खाली होने जैसा नहीं है। कनेक्शन जाँचें और फिर से कोशिश करें।", - "passkey_unproven": "पासकी बन गई, पर अभी यह पक्का नहीं हो सका कि वह डिक्रिप्ट कर सकती है। दोबारा दबाएँ: उसी से पूछा जाएगा, नई नहीं बनेगी।" + "election_unreadable": "यह चुनाव चेन से नहीं पढ़ा जा सका, जिसका मतलब यह नहीं कि यह मौजूद नहीं है। कनेक्शन जाँचें और फिर से कोशिश करें।", + "passkey_unproven": "पासकी बन गई, पर अभी यह पक्का नहीं हो सका कि वह डिक्रिप्ट कर सकती है। दोबारा दबाएँ: उसी से पूछा जाएगा, नई नहीं बनेगी।", + "epoch_already_cast": "आपने अभी हाल ही में अपना वोट बदला है। आपका अंतिम मतपत्र ही गिना जाता है; आप इसे एक घंटे के भीतर फिर से बदल सकते हैं।", + "epoch_already_cast_until": "आपने अभी हाल ही में अपना वोट बदला है। आपका अंतिम मतपत्र ही गिना जाता है; आप इसे {{time}} से फिर बदल सकते हैं।", + "epoch_wait_title": "आपका नवीनतम वोट पहले से गिना जा रहा है", + "ballot_already_cast": "यह मतपत्र पहले ही दर्ज हो चुका है। आपको और कुछ नहीं करना है: आपका मत दर्ज है।", + "ballot_expired": "यह मतपत्र बहुत पहले तैयार किया गया था, इसलिए स्वीकार नहीं किया जा सकता। कृपया फिर से मतदान करें; नया मतपत्र बनने में कुछ ही सेकंड लगते हैं।" }, "demo": { "banner": "डेमो डेटा: कोई अनुबंध तैनात नहीं। यहाँ कुछ भी ऑन-चेन नहीं है।" @@ -650,7 +662,7 @@ }, "terms": { "title": "उपयोग की शर्तें", - "updated": "अंतिम अद्यतन: 3 सितंबर 2026", + "updated": "अंतिम अद्यतन: 28 सितंबर 2026", "what_title": "Votain क्या है", "what_1": "Votain एक स्नातक शोध परियोजना है: Polygon Amoy परीक्षण नेटवर्क पर बना, आद्योपांत सत्यापन योग्य गुमनाम मतदान अनुप्रयोग। यह एक शोध प्रारूप है, कोई व्यावसायिक सेवा नहीं।", "what_2": "यह एक परीक्षण नेटवर्क पर चलता है। इसमें उपयोग होने वाले टोकनों का कोई मौद्रिक मूल्य नहीं है और नेटवर्क स्थायित्व की कोई गारंटी नहीं देता: परीक्षण नेटवर्क समय समय पर पुनः आरंभ या बंद कर दिए जाते हैं, और यहाँ हुआ कोई भी चुनाव उनके साथ लुप्त हो सकता है।", @@ -662,7 +674,7 @@ "identity_2": "जिसके पास वाक्यांश है वह किसी भी डिवाइस पर पहचान बहाल कर सकता है, बिना पासकी के और बिना हमारी मदद के। यदि वाक्यांश और उसे संभालने वाली हर पासकी खो जाए, तो World ID से दोबारा सत्यापन करके नए सिरे से शुरू किया जा सकता है। यह शुरुआत जानबूझकर सीमित है: आप उन चुनावों में शामिल हो सकते हैं जिनमें आपने अभी पंजीकरण नहीं कराया था, पर उस चुनाव में दोबारा कभी नहीं जिसमें करा चुके थे, क्योंकि मत की गुमनामी तोड़े बिना यह प्रणाली नहीं जान सकती कि आपने वहाँ पहले मतदान किया था या नहीं।", "organizer_title": "यदि आप चुनाव आयोजित करते हैं", "organizer_1": "शृंखला पर आप जो प्रकाशित करते हैं उसके लिए आप उत्तरदायी हैं। नाम, विवरण और उम्मीदवार सूचियाँ सार्वजनिक और स्थायी रूप से संग्रहीत होती हैं, और प्रकाशन के बाद उन्हें न बदला जा सकता है न हटाया। उनमें दूसरों का व्यक्तिगत डेटा शामिल न करें।", - "organizer_2": "आप एक ही गैस कोष में धन डालते हैं जो आपके सभी चुनावों के मतदाताओं का लेनदेन शुल्क चुकाता है। वह खाली हो जाए तो हर चुनाव में मतदान रुक जाता है, जब तक आप उसे फिर से न भरें।", + "organizer_2": "हर चुनाव अपने मतदाताओं का शुल्क पहले उस गैस से चुकाता है जो आपने उसके लिए आरक्षित की है, जिसे चुनाव खत्म होने तक निकाला नहीं जा सकता, और फिर आपके मुक्त शेष से, जिसे आपके सभी चुनाव साझा करते हैं। दोनों खत्म होने पर उस चुनाव में मतदान तब तक रुक जाता है जब तक आप फिर से भर न दें।", "warranty_title": "कोई वारंटी नहीं", "warranty_1": "Votain जैसा है वैसा ही प्रदान किया जाता है, बिना किसी प्रकार की वारंटी के। इसका स्वतंत्र अंकेक्षण नहीं हुआ है। इसमें ऐसी त्रुटियाँ हो सकती हैं जो मतदान की शुद्धता, उपलब्धता या गोपनीयता को प्रभावित करें।", "warranty_2": "कानून द्वारा अनुमत अधिकतम सीमा तक, लेखक उपयोग से उत्पन्न किसी हानि या क्षति की जिम्मेदारी स्वीकार नहीं करता।", @@ -671,9 +683,9 @@ }, "privacy": { "title": "गोपनीयता", - "updated": "अंतिम अद्यतन: 16 सितंबर 2026", + "updated": "अंतिम अद्यतन: 28 सितंबर 2026", "summary_title": "संक्षेप में", - "summary_1": "हम आपका नाम, आपका चेहरा या आपका बायोमेट्रिक डेटा कभी नहीं जानते। हम नहीं जानते कि आपने किसे मत दिया, और आयोजक भी नहीं जानता, क्योंकि मतपत्र आपके उपकरण से निकलने से पहले ही कूटबद्ध हो जाते हैं और केवल अंतिम योग को खोला जाता है।", + "summary_1": "हम आपका नाम, चेहरा या बायोमेट्रिक डेटा कभी नहीं जानते। हम नहीं जानते कि आपने कैसे वोट दिया। मतपत्र आपके डिवाइस से निकलने से पहले एन्क्रिप्ट होते हैं और केवल अंतिम योग ही डिक्रिप्ट किया जाता है; आयोजक की कुंजी किसी एक मतपत्र को खोल सकती है, पर उस पर कुछ भी नहीं बताता कि वह किसका है।", "summary_2": "यह पृष्ठ बताता है कि सॉफ़्टवेयर वास्तव में क्या करता है, न कि उसे क्या करने की छूट है, और उन जगहों को भी बताता है जहाँ यह रचना आपकी रक्षा नहीं करती।", "worldid_title": "World ID हमें क्या सौंपता है", "worldid_1": "सत्यापन के समय World ID हमें एक प्रमाण और एक नलिफ़ायर भेजता है: आपसे और इस ऐप्लिकेशन से निकाला गया एक अंक। लौटने पर वह हर बार वही रहता है, और इसी से एक ही खाते को उसी चुनाव में दो बार पंजीकृत होने से रोका जाता है; हर ऐप्लिकेशन के लिए वह अलग होता है। यह सिद्ध नहीं करता कि आप एक ही व्यक्ति हैं: जिस चुनाव को यह चाहिए, वह दस्तावेज़ जाँच भी माँगता है, जिससे उसी चुनाव तक सीमित दूसरा अंक बनता है।", @@ -682,14 +694,15 @@ "identity_1": "आपका मतदान रहस्य एक वाक्यांश है जो आपके डिवाइस पर बनता है और एक ही बार दिखाया जाता है, ताकि आप उसे संभाल सकें। जहाँ कोई पासकी उसे रख सकती है, वहाँ आपकी हर पंजीकृत पासकी के लिए एक सीलबंद प्रति रखी जाती है, ताकि आप कुछ टाइप किए बिना कई डिवाइस से मतदान कर सकें। वे प्रतियाँ हमारे सर्वर पर नहीं, ब्लॉकचेन पर लिखी जाती हैं, इसलिए हमारा न रहना आपको बाहर नहीं करता, और उन्हें खोलने वाली चीज़ आपके प्रमाणक से कभी बाहर नहीं जाती। हम वाक्यांश न कभी देखते हैं और न आपके लिए उसे वापस ला सकते हैं।", "identity_2": "यह औपचारिकता भर नहीं है। यदि हम उसे खोल पाते, तो आपके मतपत्र में निहित पहचानक की गणना करके हर मत को उसके देने वाले से जोड़ सकते थे। कूटलेखन ही वह है जो हमारे लिए इसे असंभव बनाता है, केवल वर्जित नहीं करता।", "ballot_title": "आपका मतपत्र", - "ballot_1": "आपका चयन भेजे जाने से पहले आपके ब्राउज़र में ही कूटबद्ध हो जाता है, आयोजक की रखी कुंजी से। मतपत्र कूटबद्ध अवस्था में ही जोड़े जाते हैं और केवल योग खोला जाता है, इसलिए कोई एकल मत कभी किसी पर प्रकट नहीं होता, आयोजक पर भी नहीं।", - "ballot_2": "आप जितनी बार चाहें दोबारा मत दे सकते हैं; गिना केवल अंतिम मतपत्र जाता है। पिछले मतपत्र संग्रहीत रहते हैं पर उससे अलग पहचाने नहीं जा सकते, और यही बात आपको दबाव में दिए गए मत को चुपचाप बदल देने देती है।", + "ballot_1": "आपका विकल्प भेजे जाने से पहले आपके ब्राउज़र में आयोजक के पास रखी कुंजियों से एन्क्रिप्ट होता है। चुनाव सभी मतपत्रों को एन्क्रिप्टेड रहते हुए ही जोड़ता है, और केवल वह योग ही डिक्रिप्ट होता है, एक ऐसे प्रमाण के साथ जिसे अनुबंध जाँचता है। ये कुंजियाँ किसी भी एक मतपत्र को खोल सकती हैं, इसीलिए किसी मतपत्र पर कुछ भी नहीं बताता कि उसे किसने डाला या आपका कौन-सा मतपत्र उससे पहले आया।", + "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 5d7f674..961e952 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ì", @@ -205,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", @@ -222,11 +224,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": "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." }, "verify_receipt": { "title": "Verifica un voto", @@ -242,7 +247,6 @@ "reference": "Riferimento", "nullifier": "Identificativo del voto", "date": "Data", - "ballots": "Schede depositate", "choice_never_shown": "Il registro pubblico conserva che un voto è stato espresso e quando, mai cosa diceva. Nessuno può leggere una scelta da una ricevuta, nemmeno noi.", "your_receipts": "Le tue ricevute", "unlock_hint": "Questo browser non conosce ancora alcun voto. Se hai votato da un altro dispositivo, un tocco della passkey li trova e li ricorda qui." @@ -282,7 +286,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 +475,9 @@ "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." }, "gas": { "title": "Gestione Gas", @@ -605,7 +612,13 @@ "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." + "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 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." }, "demo": { "banner": "Dati dimostrativi: nessun contratto distribuito. Niente qui è on-chain." @@ -655,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.", @@ -667,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.", @@ -676,9 +689,9 @@ }, "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 cosa hai votato, e nemmeno l'organizzatore lo sa, perché le schede vengono cifrate prima di lasciare il tuo dispositivo e solo il totale finale viene decifrato.", + "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.", "worldid_title": "Che cosa ci consegna World ID", "worldid_1": "Quando ti verifichi, World ID ci invia una prova e un nullificatore: un numero derivato da te e da questa applicazione. È lo stesso a ogni ritorno, ed è così che si impedisce a uno stesso account di iscriversi due volte alla stessa elezione; è inoltre diverso per ogni applicazione. Non dimostra che sei una sola persona: l’elezione che lo richiede chiede anche una verifica del documento, che produce un secondo numero, valido solo per quella elezione.", @@ -687,14 +700,15 @@ "identity_1": "Il tuo segreto di voto è una frase generata sul tuo dispositivo e mostrata una sola volta, perché tu la conservi. Dove una passkey può custodirla, viene conservata una copia sigillata per ogni passkey che registri, così puoi votare da più dispositivi senza digitare nulla. Quelle copie sono scritte sulla blockchain e non sul nostro server, quindi perderci non ti chiude fuori, e ciò che le apre non lascia mai il tuo autenticatore. Noi non vediamo mai la frase e non possiamo recuperarla per te.", "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 tuo browser prima dell'invio, con una chiave custodita dall'organizzatore. Le schede vengono sommate mentre sono ancora cifrate e solo la somma viene decifrata, così nessun voto singolo viene mai rivelato a nessuno, nemmeno all'organizzatore.", - "ballot_2": "Puoi rivotare quante volte vuoi; conta solo l'ultima scheda. Le precedenti restano memorizzate ma sono indistinguibili da essa, ed è questo che ti permette di sostituire senza darlo a vedere un voto espresso sotto pressione.", + "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 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." @@ -881,13 +895,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 64d9230..2cb5542 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": "賛成", @@ -201,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": "基盤技術", @@ -218,11 +220,12 @@ "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": "この選挙は、これらの集計がすべての投票の合計を復号したものであるというゼロ知識証明があって初めて受理しました。さらに、チェーン上の{{ballots}}票をここで改めて合計し、同じ合計になることを確認しました。投票者は{{voters}}です。誰でも鍵なし・信頼なしでこの確認を再現できます。", + "voters_count_other": "{{count}}人", + "check_failed_title": "チェーン上の投票用紙と一致しません", + "check_failed_body": "公開された集計をチェーン上に記録された投票用紙と照合できませんでした。この結果を信頼しないでください。" }, "verify_receipt": { "title": "投票を確認する", @@ -238,7 +241,6 @@ "reference": "参照", "nullifier": "投票の識別子", "date": "日付", - "ballots": "投じられた票", "choice_never_shown": "公開記録に残るのは、投票があったことと、その時刻だけです。内容は残りません。受領証から選択を読み取ることは誰にもできません。私たちにもです。", "your_receipts": "あなたの受領証", "unlock_hint": "このブラウザはまだどの票も把握していません。別の端末から投票した場合は、パスキーの操作一度でそれらを見つけ、ここに記憶します。" @@ -276,7 +278,8 @@ "reference": "参照番号", "view_tx": "公開記録を見る", "back_elections": "選挙一覧に戻る", - "check_receipt": "受領証を確認する" + "check_receipt": "受領証を確認する", + "demo_reference": "デモデータ:投票用紙は記録されていないため、確認できる参照番号はありません。" }, "change_vote": { "title": "投票を変更", @@ -462,7 +465,9 @@ "open_voting_early": "投票を今すぐ開始", "open_voting_title": "予定より早く投票を始めますか?", "open_voting_desc": "登録は締め切られ、投票はまだ先です。登録済みの全員が今から投票できます。締切日は変わりません。", - "voting_opened_done": "投票を開始しました" + "voting_opened_done": "投票を開始しました", + "void_refused": "この選挙は中止しないと約束しており、結果を公開できるだけの人数が投票したため、無効にすることはできません。結果を公開するには復号鍵をインポートしてください。", + "key_device_only": "この選挙の復号鍵はウォレットから導出できなかったため、この端末にのみ存在します。今すぐエクスポートして安全に保管してください。鍵がなければ集計できません。" }, "gas": { "title": "ガス管理", @@ -595,7 +600,13 @@ "wallet_request_rejected": "ウォレットでリクエストをキャンセルしました。何も実行されていません。", "passkey_cancelled": "パスキーの要求を閉じました。何も作成されていません。もう一度お試しください。", "list_unreadable": "この一覧をチェーンから読み取れませんでした。空だったわけではありません。接続を確かめてやり直してください。", - "passkey_unproven": "パスキーは作成されましたが、復号に使えるかまだ確認できていません。もう一度押してください。同じパスキーに尋ねます。新しいものは作成されません。" + "election_unreadable": "この選挙をチェーンから読み取れませんでした。存在しないわけではありません。接続を確かめてやり直してください。", + "passkey_unproven": "パスキーは作成されましたが、復号に使えるかまだ確認できていません。もう一度押してください。同じパスキーに尋ねます。新しいものは作成されません。", + "epoch_already_cast": "つい先ほど投票を変更したばかりです。最新の投票が有効です。1時間以内に再び変更できます。", + "epoch_already_cast_until": "つい先ほど投票を変更したばかりです。最新の投票が有効です。{{time}} から再び変更できます。", + "epoch_wait_title": "最新の投票はすでに有効です", + "ballot_already_cast": "この投票はすでに記録されています。これ以上の操作は不要です。あなたの票は届いています。", + "ballot_expired": "この投票は準備されてから時間が経ちすぎたため受け付けられません。もう一度投票してください。新しい投票は数秒で作成できます。" }, "demo": { "banner": "デモデータ:コントラクト未デプロイ。ここにあるものはオンチェーンではありません。" @@ -645,7 +656,7 @@ }, "terms": { "title": "利用規約", - "updated": "最終更新:2026年9月3日", + "updated": "最終更新:2026年9月28日", "what_title": "Votain とは", "what_1": "Votain は学士論文のプロジェクトです。Polygon Amoy テストネット上に構築された、エンドツーエンドで検証可能な匿名投票アプリケーションであり、研究用のプロトタイプであって商用サービスではありません。", "what_2": "テストネット上で動作します。使用するトークンに金銭的価値はなく、ネットワークの存続も保証されません。テストネットは時折リセットまたは終了され、ここで行われた選挙もそれとともに失われることがあります。", @@ -657,7 +668,7 @@ "identity_2": "フレーズを持つ人は、パスキーがなくても当社の助けがなくても、どの端末でもアイデンティティを復元できます。フレーズとそれを保管していたパスキーをすべて失った場合は、World IDで再度認証すればやり直せます。やり直しは意図的に制限されています。まだ登録していなかった選挙には参加できますが、すでに登録していた選挙に戻ることはできません。そこで投票済みかどうかを、票の匿名性を壊さずに知ることはできないからです。", "organizer_title": "選挙を主催する場合", "organizer_1": "チェーンに公開した内容については、あなたが責任を負います。名称、説明、候補者一覧は公開され恒久的に保存され、公開後は編集も削除もできません。第三者の個人データを含めないでください。", - "organizer_2": "あなたが入金するのは単一のガス残高で、これがあなたの「すべての」選挙の投票者の取引手数料を負担します。残高が尽きると、補充するまでいずれの選挙でも投票が止まります。", + "organizer_2": "各選挙は投票者の手数料を、まずその選挙のために予約したガス(終了まで引き出せません)から支払い、次にすべての選挙で共有する自由残高から支払います。両方が尽きると、補充するまでその選挙の投票は止まります。", "warranty_title": "無保証", "warranty_1": "Votain は現状のまま提供され、いかなる保証もありません。独立した監査も受けていません。投票の正確性、可用性、秘匿性に影響する不具合が含まれている可能性があります。", "warranty_2": "法律で認められる最大限の範囲において、作者は利用によって生じた損失や損害について責任を負いません。", @@ -666,9 +677,9 @@ }, "privacy": { "title": "プライバシー", - "updated": "最終更新:2026年9月16日", + "updated": "最終更新:2026年9月28日", "summary_title": "要約", - "summary_1": "当方があなたの氏名、顔、生体データを知ることはありません。何に投票したかも分かりませんし、主催者にも分かりません。投票は端末を離れる前に暗号化され、復号されるのは最終的な合計だけだからです。", + "summary_1": "あなたの名前、顔、生体情報を私たちが知ることはありません。あなたがどう投票したかも知りません。投票はデバイスを離れる前に暗号化され、復号されるのは最終的な合計だけです。主催者の鍵なら個々の投票を開くこともできますが、その投票が誰のものかを示す情報は何もありません。", "summary_2": "このページは、ソフトウェアが許されていることではなく実際に行っていることを説明します。設計上あなたを守れない箇所も含みます。", "worldid_title": "World ID から渡されるもの", "worldid_1": "認証のとき、World ID は証明とヌリファイア(あなたとこのアプリから導かれる数値)を送ってきます。戻ってくるたび同じ値なので、同一のアカウントが同じ選挙に二重に登録することを防げます。値はアプリごとに異なります。ただし、あなたが一人の人間であることは証明しません。それが必要な選挙では書類の確認も求め、その選挙だけで有効な二つ目の数値が作られます。", @@ -677,14 +688,15 @@ "identity_1": "あなたの投票の秘密は端末上で生成され、保管できるよう一度だけ表示されるフレーズです。パスキーが保持できる場合は、登録したパスキーごとに封をした控えが保存され、何も入力せずに複数の端末から投票できます。その控えは当社のサーバーではなくブロックチェーンに書き込まれるため、当社が失われてもあなたが締め出されることはなく、それを開くものが認証器から出ることもありません。当社はフレーズを見ることも、代わりに復元することもできません。", "identity_2": "これは形式的な話ではありません。もし復号できてしまえば、あなたの投票が持つ識別子を計算し、どの票を誰が投じたかを結び付けられてしまいます。暗号化は当方にそれを禁じているだけでなく、不可能にしているのです。", "ballot_title": "あなたの投票", - "ballot_1": "あなたの選択は送信前にブラウザ上で、主催者が保持する鍵によって暗号化されます。投票は暗号化されたまま加算され、復号されるのは合計だけなので、個々の票が誰かに明かされることはありません。主催者に対しても同じです。", - "ballot_2": "何度でも投票し直せます。数えられるのは最後の一票だけです。それ以前の票も保存されますが、最後の票と区別できないため、圧力の下で投じた票をそれと分からせずに置き換えられます。", + "ballot_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 8232937..efc5a14 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": "찬성", @@ -201,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": "기반 기술", @@ -218,11 +220,12 @@ "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": "이 선거는 이 집계가 모든 투표지의 합을 복호화한 것이라는 영지식 증명이 있을 때만 이를 받아들였고, 체인에 있는 {{ballots}}장의 투표지를 여기서 다시 더해 같은 합을 얻었습니다: 투표자 {{voters}}. 누구나 키 없이, 신뢰 없이 이 확인을 반복할 수 있습니다.", + "voters_count_other": "{{count}}명", + "check_failed_title": "체인상의 투표지와 일치하지 않음", + "check_failed_body": "공개된 집계를 체인에 기록된 투표지와 대조해 검증할 수 없었습니다. 이 결과를 신뢰하지 마세요." }, "verify_receipt": { "title": "투표 확인하기", @@ -238,7 +241,6 @@ "reference": "참조", "nullifier": "투표 식별자", "date": "날짜", - "ballots": "투표한 횟수", "choice_never_shown": "공개 기록에는 투표가 이루어졌다는 사실과 시각만 남으며, 내용은 남지 않습니다. 영수증으로 선택을 읽어낼 수 있는 사람은 아무도 없고, 저희도 마찬가지입니다.", "your_receipts": "내 영수증", "unlock_hint": "이 브라우저는 아직 어떤 투표도 알지 못합니다. 다른 기기에서 투표했다면 패스키를 한 번 누르면 찾아서 여기에 기억합니다." @@ -276,7 +278,8 @@ "reference": "참조 번호", "view_tx": "공개 기록 보기", "back_elections": "선거 목록으로 돌아가기", - "check_receipt": "내 영수증 확인" + "check_receipt": "내 영수증 확인", + "demo_reference": "데모 데이터: 기록된 투표지가 없으므로 확인할 참조 번호가 없습니다." }, "change_vote": { "title": "투표 변경", @@ -462,7 +465,9 @@ "open_voting_early": "지금 투표 열기", "open_voting_title": "예정보다 먼저 투표를 시작하시겠습니까?", "open_voting_desc": "등록은 이미 마감되었고 투표는 아직 시작 전입니다. 등록한 모든 사람이 지금부터 투표할 수 있습니다. 마감일은 바뀌지 않습니다.", - "voting_opened_done": "투표가 열렸습니다" + "voting_opened_done": "투표가 열렸습니다", + "void_refused": "이 선거는 취소하지 않겠다고 약속했고 결과를 공개할 만큼 충분한 사람이 투표했으므로 무효로 할 수 없습니다. 결과를 공개하려면 복호화 키를 가져오세요.", + "key_device_only": "이 선거의 복호화 키를 지갑에서 도출할 수 없어 이 기기에만 있습니다. 지금 내보내 안전하게 보관하세요. 키가 없으면 선거를 집계할 수 없습니다." }, "gas": { "title": "가스 관리", @@ -595,7 +600,13 @@ "wallet_request_rejected": "지갑에서 요청을 취소했습니다. 아무 작업도 수행되지 않았습니다.", "passkey_cancelled": "패스키 요청을 닫았습니다. 아무것도 생성되지 않았습니다. 다시 시도할 수 있습니다.", "list_unreadable": "이 목록을 체인에서 읽지 못했습니다. 비어 있는 것과는 다릅니다. 연결을 확인하고 다시 시도하세요.", - "passkey_unproven": "패스키는 생성되었지만 복호화에 쓸 수 있는지 아직 확인하지 못했습니다. 다시 누르세요. 같은 패스키에 물어보며, 새로 만들지 않습니다." + "election_unreadable": "이 선거를 체인에서 읽지 못했습니다. 존재하지 않는다는 뜻은 아닙니다. 연결을 확인하고 다시 시도하세요.", + "passkey_unproven": "패스키는 생성되었지만 복호화에 쓸 수 있는지 아직 확인하지 못했습니다. 다시 누르세요. 같은 패스키에 물어보며, 새로 만들지 않습니다.", + "epoch_already_cast": "방금 투표를 바꾸셨습니다. 가장 최근 투표지가 반영되며, 한 시간 안에 다시 바꿀 수 있습니다.", + "epoch_already_cast_until": "방금 투표를 바꾸셨습니다. 가장 최근 투표지가 반영되며, {{time}}부터 다시 바꿀 수 있습니다.", + "epoch_wait_title": "가장 최근 투표가 이미 반영되었습니다", + "ballot_already_cast": "이 투표지는 이미 기록되었습니다. 더 할 일은 없습니다. 투표가 반영되었습니다.", + "ballot_expired": "이 투표지는 너무 오래전에 준비되어 받아들여질 수 없습니다. 다시 투표해 주세요. 새 투표지는 몇 초면 만들어집니다." }, "demo": { "banner": "데모 데이터: 배포된 컨트랙트 없음. 여기 있는 것은 온체인이 아닙니다." @@ -645,7 +656,7 @@ }, "terms": { "title": "이용 약관", - "updated": "최종 업데이트: 2026년 9월 3일", + "updated": "최종 업데이트: 2026년 9월 28일", "what_title": "Votain이란", "what_1": "Votain은 학사 졸업 프로젝트입니다. Polygon Amoy 테스트넷 위에 구축된 종단 간 검증 가능한 익명 투표 애플리케이션이며, 상용 서비스가 아니라 연구용 프로토타입입니다.", "what_2": "테스트넷에서 동작합니다. 사용하는 토큰에는 금전적 가치가 없고 네트워크의 존속도 보장되지 않습니다. 테스트넷은 이따금 초기화되거나 종료되며, 여기서 진행한 선거도 그와 함께 사라질 수 있습니다.", @@ -657,7 +668,7 @@ "identity_2": "문구를 가진 사람은 패스키 없이도, 저희 도움 없이도 어느 기기에서나 신원을 복원할 수 있습니다. 문구와 그것을 보관하던 패스키를 모두 잃으면 World ID로 다시 인증해 처음부터 시작할 수 있습니다. 다시 시작하는 것은 의도적으로 제한됩니다. 아직 등록하지 않은 선거에는 참여할 수 있지만, 이미 등록했던 선거에는 다시 들어갈 수 없습니다. 표의 익명성을 깨지 않고서는 거기서 이미 투표했는지 시스템이 알 수 없기 때문입니다.", "organizer_title": "선거를 주최하는 경우", "organizer_1": "체인에 게시하는 내용에 대한 책임은 귀하에게 있습니다. 이름, 설명, 후보 목록은 공개적이고 영구적으로 저장되며 게시 후에는 수정하거나 삭제할 수 없습니다. 제3자의 개인정보를 포함하지 마십시오.", - "organizer_2": "귀하가 충전하는 것은 하나의 가스 잔액이며, 이는 귀하의 모든 선거에 참여하는 투표자의 거래 수수료를 지불합니다. 잔액이 바닥나면 다시 충전할 때까지 모든 선거에서 투표가 멈춥니다.", + "organizer_2": "각 선거는 투표자의 수수료를 먼저 그 선거를 위해 예약한 가스(선거가 끝날 때까지 인출할 수 없음)로 내고, 그다음 모든 선거가 함께 쓰는 자유 잔액으로 냅니다. 둘 다 바닥나면 충전할 때까지 그 선거의 투표가 멈춥니다.", "warranty_title": "보증 없음", "warranty_1": "Votain은 있는 그대로 제공되며 어떠한 보증도 하지 않습니다. 독립적인 감사를 받지 않았습니다. 투표의 정확성, 가용성, 비밀성에 영향을 주는 결함이 있을 수 있습니다.", "warranty_2": "법이 허용하는 최대 범위에서, 저자는 사용으로 인해 발생한 손실이나 손해에 대해 책임지지 않습니다.", @@ -666,9 +677,9 @@ }, "privacy": { "title": "개인정보", - "updated": "최종 업데이트: 2026년 9월 16일", + "updated": "최종 업데이트: 2026년 9월 28일", "summary_title": "요약", - "summary_1": "저희는 귀하의 이름, 얼굴, 생체 정보를 결코 알지 못합니다. 무엇에 투표했는지도 알 수 없고 주최자도 알 수 없습니다. 투표는 기기를 떠나기 전에 암호화되고 최종 합계만 복호화되기 때문입니다.", + "summary_1": "우리는 당신의 이름, 얼굴, 생체 정보를 결코 알지 못합니다. 당신이 어떻게 투표했는지도 모릅니다. 투표지는 기기를 떠나기 전에 암호화되고 최종 합계만 복호화됩니다. 주최자의 키로 투표지 한 장을 열 수는 있지만, 그 투표지가 누구의 것인지 알려 주는 정보는 없습니다.", "summary_2": "이 페이지는 소프트웨어가 허용되는 일이 아니라 실제로 하는 일을 설명하며, 설계상 귀하를 보호하지 못하는 지점도 포함합니다.", "worldid_title": "World ID가 저희에게 전달하는 것", "worldid_1": "인증할 때 World ID는 증명과 널리파이어를 보냅니다. 당신과 이 애플리케이션에서 파생된 숫자로, 돌아올 때마다 같기 때문에 한 계정이 같은 선거에 두 번 등록하는 것을 막습니다. 애플리케이션마다 값은 다릅니다. 다만 당신이 한 사람임을 증명하지는 않습니다. 그것이 필요한 선거는 서류 확인도 요구하며, 그 선거에서만 유효한 두 번째 숫자가 만들어집니다.", @@ -677,14 +688,15 @@ "identity_1": "투표 비밀은 기기에서 생성되어 보관할 수 있도록 한 번만 표시되는 문구입니다. 패스키가 보관할 수 있는 곳에서는 등록한 패스키마다 봉인된 사본을 저장해, 아무것도 입력하지 않고 여러 기기에서 투표할 수 있습니다. 그 사본은 저희 서버가 아니라 블록체인에 기록되므로 저희가 사라져도 갇히지 않으며, 사본을 여는 것은 인증기를 절대 벗어나지 않습니다. 저희는 문구를 보지도, 대신 복구하지도 못합니다.", "identity_2": "형식적인 이야기가 아닙니다. 저희가 복호화할 수 있다면 귀하의 투표가 지닌 식별자를 계산해 각 표를 누가 던졌는지 연결할 수 있습니다. 암호화는 저희에게 그것을 금지하는 데 그치지 않고 불가능하게 만듭니다.", "ballot_title": "귀하의 투표", - "ballot_1": "귀하의 선택은 전송 전에 브라우저에서 주최자가 보유한 키로 암호화됩니다. 투표는 암호화된 상태로 더해지고 합계만 복호화되므로, 개별 표가 누구에게도 공개되지 않습니다. 주최자에게도 마찬가지입니다.", - "ballot_2": "원하는 만큼 다시 투표할 수 있으며 마지막 표만 집계됩니다. 이전 표도 저장되지만 마지막 표와 구별되지 않으며, 바로 그 때문에 압력 아래 던진 표를 티 내지 않고 바꿀 수 있습니다.", + "ballot_1": "당신의 선택은 제출되기 전에 브라우저에서 주최자가 보관하는 키로 암호화됩니다. 선거는 모든 투표지를 암호화된 채로 더하고, 그 합만 복호화하며 컨트랙트가 검증하는 증명이 함께 제출됩니다. 이 키는 투표지 한 장을 열 수도 있기 때문에, 투표지에는 누가 제출했는지, 당신의 어느 투표지가 그 앞에 있었는지 알려 주는 정보가 전혀 없습니다.", + "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 194ea62..1358e9d 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", @@ -203,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", @@ -220,11 +222,13 @@ "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 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." }, "verify_receipt": { "title": "Een stem verifiëren", @@ -240,7 +244,6 @@ "reference": "Referentie", "nullifier": "Stemkenmerk", "date": "Datum", - "ballots": "Uitgebrachte stemmen", "choice_never_shown": "Het openbare register houdt bij dat er een stem is uitgebracht en wanneer, nooit wat erin stond. Niemand kan uit een bewijs een keuze aflezen, wij ook niet.", "your_receipts": "Jouw bewijzen", "unlock_hint": "Deze browser kent nog geen stem. Als je vanaf een ander apparaat hebt gestemd, vindt één passkey-tik ze en onthoudt ze hier." @@ -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,9 @@ "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." }, "gas": { "title": "Gasbeheer", @@ -600,7 +606,13 @@ "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." + "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 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." }, "demo": { "banner": "Demogegevens: geen contracten uitgerold. Niets hiervan staat on-chain." @@ -650,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.", @@ -662,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.", @@ -671,9 +683,9 @@ }, "privacy": { "title": "Privacy", - "updated": "Laatst bijgewerkt: 16 september 2026", + "updated": "Laatst bijgewerkt: 28 september 2026", "summary_title": "Kort gezegd", - "summary_1": "Wij kennen nooit uw naam, uw gezicht of uw biometrische gegevens. Wij weten niet wat u hebt gestemd, en de organisator evenmin, want stemmen worden versleuteld voordat ze uw apparaat verlaten en alleen het eindtotaal wordt ontsleuteld.", + "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.", "worldid_title": "Wat World ID aan ons doorgeeft", "worldid_1": "Bij het verifiëren stuurt World ID ons een bewijs en een nullifier: een getal afgeleid van jou en van deze applicatie. Het is elke keer hetzelfde, en zo wordt voorkomen dat één account zich twee keer inschrijft voor dezelfde verkiezing; het verschilt bovendien per applicatie. Het bewijst niet dat je één persoon bent: een verkiezing die dat eist vraagt ook een documentcontrole, die een tweede getal oplevert dat alleen voor die verkiezing geldt.", @@ -682,14 +694,15 @@ "identity_1": "Je stemgeheim is een zin die op je apparaat wordt gemaakt en je één keer wordt getoond, zodat je hem kunt bewaren. Waar een passkey hem kan bewaren, wordt voor elke passkey die je registreert een verzegelde kopie opgeslagen, zodat je vanaf meerdere apparaten kunt stemmen zonder iets te typen. Die kopieën komen op de blockchain te staan en niet op onze server, dus ons kwijtraken sluit je niet buiten, en wat ze opent verlaat je authenticator nooit. Wij zien de zin nooit en kunnen hem niet voor je terughalen.", "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": "Uw keuze wordt in uw browser versleuteld voordat zij wordt verzonden, met een sleutel die de organisator beheert. Stemmen worden versleuteld bij elkaar opgeteld en alleen de som wordt ontsleuteld, zodat geen enkele afzonderlijke stem ooit aan iemand wordt onthuld, ook niet aan de organisator.", - "ballot_2": "U mag zo vaak opnieuw stemmen als u wilt; alleen de laatste stem telt. Eerdere stemmen blijven opgeslagen maar zijn niet van die laatste te onderscheiden, en juist daardoor kunt u een onder druk uitgebrachte stem ongemerkt vervangen.", + "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 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 2b3f9ee..c9c257d 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", @@ -205,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", @@ -222,11 +224,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": "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." }, "verify_receipt": { "title": "Verifica um voto", @@ -242,7 +247,6 @@ "reference": "Referência", "nullifier": "Identificador do voto", "date": "Data", - "ballots": "Votos depositados", "choice_never_shown": "O registo público guarda que um voto foi expresso e quando, nunca o que dizia. Ninguém consegue ler uma escolha a partir de um recibo, nós também não.", "your_receipts": "Os teus recibos", "unlock_hint": "Este navegador ainda não conhece nenhum voto. Se votaste noutro dispositivo, um toque na passkey encontra-os e guarda-os aqui." @@ -282,7 +286,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 +475,9 @@ "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." }, "gas": { "title": "Gestão de Gas", @@ -605,7 +612,13 @@ "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." + "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": "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." }, "demo": { "banner": "Dados de demonstração: sem contratos implantados. Nada aqui está on-chain." @@ -655,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.", @@ -667,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.", @@ -676,9 +689,9 @@ }, "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 sabemos o seu nome, o seu rosto nem os seus dados biométricos. Não sabemos em que votou, e o organizador também não, porque os boletins são cifrados antes de sair do seu dispositivo e só o total final é decifrado.", + "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.", "worldid_title": "O que o World ID nos entrega", "worldid_1": "Quando te verificas, o World ID envia-nos uma prova e um nulificador: um número derivado de ti e desta aplicação. É o mesmo sempre que voltas, e é assim que se impede que uma mesma conta se inscreva duas vezes na mesma eleição; além disso é diferente para cada aplicação. Não prova que és uma só pessoa: a eleição que precise disso pede também uma verificação de documento, que produz um segundo número, válido apenas para essa eleição.", @@ -687,14 +700,15 @@ "identity_1": "O seu segredo de voto é uma frase gerada no seu dispositivo e mostrada uma única vez, para que a guarde. Onde uma passkey a possa guardar, é armazenada uma cópia selada por cada passkey que registar, para poder votar a partir de vários dispositivos sem escrever nada. Essas cópias são escritas na blockchain e não no nosso servidor, por isso perder-nos não o deixa de fora, e o que as abre nunca sai do seu autenticador. Nunca vemos a frase nem a podemos recuperar por si.", "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 seu navegador antes de ser enviada, com uma chave à guarda do organizador. Os boletins são somados ainda cifrados e só a soma é decifrada, pelo que nenhum voto individual chega a ser revelado a ninguém, nem ao organizador.", - "ballot_2": "Pode votar de novo as vezes que quiser; só conta o último boletim. Os anteriores permanecem guardados mas são indistinguíveis dele, e é isso que lhe permite substituir discretamente um voto emitido sob pressão.", + "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 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." @@ -873,21 +887,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 a8f31d8..bc786be 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": "Да", @@ -207,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": "Построено на", @@ -224,11 +226,15 @@ "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": "Выборы приняли эти итоги только вместе с доказательством с нулевым разглашением того, что они являются расшифровкой суммы всех бюллетеней, а {{ballots}} бюллетеней в блокчейне были заново сложены здесь в ту же сумму: {{voters}}. Любой может повторить эту проверку без ключа и без доверия.", + "voters_count_many": "{{count}} избирателей", + "voters_count_one": "{{count}} избиратель", + "voters_count_few": "{{count}} избирателя", + "voters_count_other": "{{count}} избирателя", + "check_failed_title": "Не совпадает с бюллетенями в блокчейне", + "check_failed_body": "Опубликованные итоги не удалось проверить по бюллетеням, записанным в блокчейне. Не полагайтесь на этот результат." }, "verify_receipt": { "title": "Проверить голос", @@ -244,7 +250,6 @@ "reference": "Референс", "nullifier": "Идентификатор голоса", "date": "Дата", - "ballots": "Поданных голосов", "choice_never_shown": "Публичная запись хранит, что голос был подан и когда, но никогда — что в нём было. Никто не может прочитать выбор по квитанции, мы тоже.", "your_receipts": "Ваши квитанции", "unlock_hint": "Этому браузеру пока не известен ни один голос. Если вы голосовали с другого устройства, одно подтверждение ключом доступа найдёт их и запомнит здесь." @@ -285,7 +290,8 @@ "reference": "Референс-номер", "view_tx": "Посмотреть публичную запись", "back_elections": "Вернуться к выборам", - "check_receipt": "Проверить мою квитанцию" + "check_receipt": "Проверить мою квитанцию", + "demo_reference": "Демонстрационные данные: бюллетень не был записан, поэтому проверять нечего." }, "change_vote": { "title": "Изменить ваш голос", @@ -474,7 +480,9 @@ "open_voting_early": "Открыть голосование", "open_voting_title": "Начать голосование раньше срока?", "open_voting_desc": "Регистрация уже закрыта, а голосование ещё не началось. Все зарегистрировавшиеся смогут голосовать прямо сейчас. Дата закрытия не меняется.", - "voting_opened_done": "Голосование открыто" + "voting_opened_done": "Голосование открыто", + "void_refused": "Эти выборы обещали, что не будут отменены, и проголосовало достаточно людей для публикации результата, поэтому их нельзя аннулировать. Импортируйте ключ расшифровки, чтобы опубликовать результат.", + "key_device_only": "Ключ расшифровки этих выборов не удалось получить из вашего кошелька, поэтому он хранится только на этом устройстве. Экспортируйте его сейчас и сохраните: без него подсчёт голосов невозможен." }, "gas": { "title": "Управление газом", @@ -610,7 +618,13 @@ "wallet_request_rejected": "Вы отменили запрос в кошельке. Ничего не выполнено.", "passkey_cancelled": "Вы отклонили запрос passkey. Ничего не создано: можно попробовать снова.", "list_unreadable": "Этот список не удалось прочитать из сети, а это не то же самое, что пустой список. Проверьте соединение и повторите.", - "passkey_unproven": "Passkey создан, но мы ещё не смогли убедиться, что он умеет расшифровывать. Нажмите ещё раз: спросят тот же самый, новый создаваться не будет." + "election_unreadable": "Эти выборы не удалось прочитать из сети, но это не значит, что их нет. Проверьте подключение и попробуйте ещё раз.", + "passkey_unproven": "Passkey создан, но мы ещё не смогли убедиться, что он умеет расшифровывать. Нажмите ещё раз: спросят тот же самый, новый создаваться не будет.", + "epoch_already_cast": "Вы только что изменили свой голос. Учитывается ваш последний бюллетень; изменить его снова можно в течение часа.", + "epoch_already_cast_until": "Вы только что изменили свой голос. Учитывается ваш последний бюллетень; изменить его снова можно с {{time}}.", + "epoch_wait_title": "Ваш последний голос уже учтён", + "ballot_already_cast": "Этот бюллетень уже был записан. Больше ничего делать не нужно: ваш голос учтён.", + "ballot_expired": "Этот бюллетень был подготовлен слишком давно, чтобы его приняли. Проголосуйте ещё раз; новый бюллетень готовится за несколько секунд." }, "demo": { "banner": "Демонстрационные данные: контракты не развёрнуты. Ничего из этого не в блокчейне." @@ -660,7 +674,7 @@ }, "terms": { "title": "Условия использования", - "updated": "Последнее обновление: 3 сентября 2026 г.", + "updated": "Последнее обновление: 28 сентября 2026 г.", "what_title": "Что такое Votain", "what_1": "Votain представляет собой дипломный проект: приложение для анонимного голосования с полной проверяемостью, построенное на тестовой сети Polygon Amoy. Это исследовательский прототип, а не коммерческий сервис.", "what_2": "Оно работает в ТЕСТОВОЙ сети. Используемые токены не имеют денежной ценности, а сеть не гарантирует сохранности: тестовые сети время от времени сбрасывают или закрывают, и любое проведённое здесь голосование может исчезнуть вместе с ними.", @@ -672,7 +686,7 @@ "identity_2": "Тот, у кого есть фраза, восстановит личность на любом устройстве, без ключа доступа и без нашей помощи. Если вы потеряли и фразу, и все хранившие её ключи доступа, повторная проверка World ID позволяет начать заново. Начало заново намеренно ограничено: вы сможете присоединиться к голосованиям, куда ещё не записывались, но никогда не вернётесь в те, где уже были, потому что система не может узнать, голосовали ли вы там, не нарушив анонимность бюллетеня.", "organizer_title": "Если вы организуете голосование", "organizer_1": "Вы отвечаете за то, что публикуете в блокчейне. Названия, описания и списки кандидатов хранятся публично и навсегда, и после публикации их нельзя изменить или удалить. Не включайте в них персональные данные других людей.", - "organizer_2": "Вы пополняете единый запас газа, который оплачивает комиссии за транзакции избирателей ВСЕХ ваших голосований. Если он опустеет, голосование остановится в каждом из них, пока вы его не пополните.", + "organizer_2": "Каждые выборы оплачивают комиссии своих избирателей сначала из газа, который вы для них зарезервировали и не можете вывести до их окончания, а затем из вашего свободного баланса, общего для всех ваших выборов. Если закончится и то и другое, голосование на этих выборах остановится, пока вы не пополните баланс.", "warranty_title": "Без гарантий", "warranty_1": "Votain предоставляется как есть, без каких-либо гарантий. Он не проходил независимый аудит. Он может содержать дефекты, влияющие на правильность, доступность или конфиденциальность голосования.", "warranty_2": "В максимальной степени, допускаемой законом, автор не несёт ответственности за убытки или ущерб, возникшие вследствие использования.", @@ -681,9 +695,9 @@ }, "privacy": { "title": "Конфиденциальность", - "updated": "Последнее обновление: 16 сентября 2026 г.", + "updated": "Последнее обновление: 28 сентября 2026 г.", "summary_title": "Коротко", - "summary_1": "Мы никогда не знаем ваше имя, ваше лицо или ваши биометрические данные. Мы не знаем, за что вы проголосовали, и организатор тоже не знает, потому что бюллетени шифруются до того, как покинут ваше устройство, и расшифровывается только итоговая сумма.", + "summary_1": "Мы никогда не узнаём ваше имя, лицо или биометрические данные. Мы не знаем, как вы проголосовали. Бюллетени шифруются до того, как покидают ваше устройство, и расшифровывается только итоговая сумма; ключ организатора мог бы открыть отдельный бюллетень, но ничто в нём не говорит, чей он.", "summary_2": "На этой странице описано, что программа делает на самом деле, а не что ей было бы позволено делать, включая те места, где архитектура вас не защищает.", "worldid_title": "Что World ID передаёт нам", "worldid_1": "При проверке World ID присылает нам доказательство и нулификатор: число, производное от вас и от этого приложения. Оно одинаково при каждом возвращении, и именно так одна учётная запись не может записаться дважды на одно голосование; для каждого приложения оно своё. Оно не доказывает, что вы один человек: голосование, которому это нужно, дополнительно просит проверку документа, дающую второе число, действующее только для этого голосования.", @@ -692,14 +706,15 @@ "identity_1": "Ваш избирательный секрет это фраза, созданная на вашем устройстве и показанная один раз, чтобы вы её сохранили. Там, где её может хранить ключ доступа, для каждого зарегистрированного ключа сохраняется запечатанная копия, чтобы вы голосовали с нескольких устройств, ничего не набирая. Эти копии пишутся в блокчейн, а не на наш сервер, поэтому наша пропажа вас не запрёт, а то, что их открывает, никогда не покидает ваш аутентификатор. Мы не видим фразу и не можем её восстановить.", "identity_2": "Это не формальность. Если бы мы могли его расшифровать, мы могли бы вычислить идентификатор, который несёт ваш бюллетень, и связать каждый голос с тем, кто его подал. Шифрование делает это для нас невозможным, а не просто запрещает.", "ballot_title": "Ваш бюллетень", - "ballot_1": "Ваш выбор шифруется в браузере до отправки ключом, которым владеет организатор. Бюллетени складываются в зашифрованном виде, и расшифровывается только сумма, поэтому ни один отдельный голос никому не раскрывается, в том числе организатору.", - "ballot_2": "Вы можете переголосовать сколько угодно раз; учитывается только последний бюллетень. Предыдущие остаются сохранёнными, но неотличимы от него, и именно это позволяет вам незаметно заменить голос, поданный под давлением.", + "ballot_1": "Ваш выбор шифруется в браузере до отправки ключами, которые хранит организатор. Выборы складывают все бюллетени в зашифрованном виде, и расшифровывается только эта сумма — с доказательством, которое проверяет контракт. Эти ключи могли бы открыть любой отдельный бюллетень, поэтому ничто в бюллетене не говорит, кто его подал и какой из ваших бюллетеней был перед ним.", + "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 7fee909..57a6999 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": "同意", @@ -201,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": "基于", @@ -218,11 +220,12 @@ "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": "本次选举只有在附带零知识证明、证明这些计数是全部选票之和的解密结果时才予以接受;链上的 {{ballots}} 张选票也在此处重新相加,得到同样的总和:共 {{voters}}。任何人都可以在没有密钥、无需信任的情况下重复这一检查。", + "voters_count_other": "{{count}} 名投票人", + "check_failed_title": "与链上的选票不符", + "check_failed_body": "无法根据链上记录的选票核实公布的计票结果。请勿依赖此结果。" }, "verify_receipt": { "title": "验证一笔投票", @@ -238,7 +241,6 @@ "reference": "参考编号", "nullifier": "投票标识", "date": "日期", - "ballots": "已投票次数", "choice_never_shown": "公开记录只保留投票发生过以及发生的时间,从不保留内容。没有人能从回执读出选择,我们也不能。", "your_receipts": "你的回执", "unlock_hint": "此浏览器尚不知道任何投票。如果你在其他设备上投过票,一次通行密钥操作即可找到并记在这里。" @@ -276,7 +278,8 @@ "reference": "参考编号", "view_tx": "查看公开记录", "back_elections": "返回选举列表", - "check_receipt": "查看我的回执" + "check_receipt": "查看我的回执", + "demo_reference": "演示数据:未记录任何选票,因此没有可核对的参考编号。" }, "change_vote": { "title": "更改您的投票", @@ -462,7 +465,9 @@ "open_voting_early": "立即开放投票", "open_voting_title": "提前开始投票?", "open_voting_desc": "登记已结束,投票尚未开始。所有已登记的人从现在起即可投票。截止日期不变。", - "voting_opened_done": "投票已开放" + "voting_opened_done": "投票已开放", + "void_refused": "此选举承诺不会被取消,且投票人数已足以公布结果,因此不能作废。请导入解密密钥以公布结果。", + "key_device_only": "无法从你的钱包派生此选举的解密密钥,因此它只存在于此设备上。请立即导出并妥善保管:没有它就无法计票。" }, "gas": { "title": "Gas 管理", @@ -595,7 +600,13 @@ "wallet_request_rejected": "你在钱包中取消了请求,未执行任何操作。", "passkey_cancelled": "你关闭了通行密钥请求。没有创建任何内容,可以再试一次。", "list_unreadable": "无法从链上读取此列表,这并不等于列表为空。请检查连接后重试。", - "passkey_unproven": "通行密钥已创建,但还无法确认它能用于解密。请再按一次:会向同一个密钥提问,不会创建新的。" + "election_unreadable": "无法从链上读取这场选举,这并不表示它不存在。请检查连接后重试。", + "passkey_unproven": "通行密钥已创建,但还无法确认它能用于解密。请再按一次:会向同一个密钥提问,不会创建新的。", + "epoch_already_cast": "你刚刚更改过投票。以你最新的选票为准;一小时内可以再次更改。", + "epoch_already_cast_until": "你刚刚更改过投票。以你最新的选票为准;从 {{time}} 起可以再次更改。", + "epoch_wait_title": "你最新的选票已经生效", + "ballot_already_cast": "这张选票已经记录在案。无需再做任何操作:你的投票已被计入。", + "ballot_expired": "这张选票准备得太久,已无法被接受。请重新投票;生成新选票只需几秒钟。" }, "demo": { "banner": "演示数据:未部署合约。此处内容均不在链上。" @@ -645,7 +656,7 @@ }, "terms": { "title": "使用条款", - "updated": "最后更新:2026年9月3日", + "updated": "最后更新:2026年9月28日", "what_title": "Votain 是什么", "what_1": "Votain 是一个本科毕业设计项目:一个端到端可验证的匿名投票应用,构建在 Polygon Amoy 测试网上。它是研究原型,而非商业服务。", "what_2": "它运行在测试网上。所用代币没有货币价值,网络也不保证长期存续:测试网会不时被重置或停用,在这里举行的任何选举都可能随之消失。", @@ -657,7 +668,7 @@ "identity_2": "拿到助记词的人可以在任何设备上恢复该身份,不需要通行密钥,也不需要我们帮忙。如果助记词和保管它的所有通行密钥都丢失,重新用 World ID 验证可以从头开始。从头开始是有意受限的:你可以参加尚未报名的选举,但永远无法重新进入已报名的选举,因为系统无法在不破坏选票匿名性的前提下判断你是否已在那里投过票。", "organizer_title": "如果您组织一场选举", "organizer_1": "您对发布到链上的内容负责。名称、说明和候选人名单以公开且永久的方式存储,发布后无法修改或删除。请勿在其中包含他人的个人数据。", - "organizer_2": "您充值的是一个统一的 gas 储备,它为您全部选举的投票者支付交易费用。储备耗尽时,每一场选举的投票都会停止,直到您重新充值。", + "organizer_2": "每场选举先用你为它预留的 gas 支付其投票人的手续费(选举结束前不能取回),再用你所有选举共用的可用余额支付。两者都用完时,该选举的投票会暂停,直到你充值。", "warranty_title": "不提供任何担保", "warranty_1": "Votain 按现状提供,不附带任何形式的担保。它未经过独立审计,可能存在影响表决正确性、可用性或私密性的缺陷。", "warranty_2": "在法律允许的最大范围内,作者不对因使用本软件而产生的损失或损害承担责任。", @@ -666,9 +677,9 @@ }, "privacy": { "title": "隐私", - "updated": "最后更新:2026年9月16日", + "updated": "最后更新:2026年9月28日", "summary_title": "简而言之", - "summary_1": "我们从不知道您的姓名、面容或生物特征数据。我们不知道您投了什么,组织者同样不知道,因为选票在离开您的设备之前就已加密,只有最终的合计结果会被解密。", + "summary_1": "我们从不知道你的姓名、面孔或生物特征。我们不知道你投了什么。选票在离开你的设备之前就已加密,最终只有总数会被解密;组织者的密钥可以打开单张选票,但选票上没有任何信息表明它属于谁。", "summary_2": "本页描述软件实际所做的事,而不是它被允许做的事,其中也包括这一设计无法保护您的地方。", "worldid_title": "World ID 交给我们的内容", "worldid_1": "验证时,World ID 会发给我们一个证明和一个作废标识:由你和本应用共同导出的数字。你每次回来它都相同,因此可以阻止同一个账户在同一场选举中登记两次;对每个使用 World ID 的应用它都不同。它并不能证明你是同一个人:需要这一点的选举还会要求证件核验,从而产生仅在该场选举内有效的第二个数字。", @@ -677,14 +688,15 @@ "identity_1": "你的投票秘密是一组在你设备上生成、只向你展示一次的助记词,供你自行保管。在通行密钥能够保管它的地方,会为你注册的每把通行密钥保存一份封存副本,这样你无需输入任何内容就能在多台设备上投票。这些副本写入区块链而不是我们的服务器,所以我们消失也不会把你挡在门外,而打开它们的东西永远不会离开你的验证器。我们看不到助记词,也无法替你恢复。", "identity_2": "这不是形式上的说法。如果我们能解密它,我们就能算出您选票所携带的标识符,把每一票和投票的人对应起来。是加密让我们做不到,而不仅仅是规则不许我们这么做。", "ballot_title": "您的选票", - "ballot_1": "您的选择在发送前于浏览器中加密,使用的是组织者持有的密钥。选票在保持加密的状态下相加,只有总和会被解密,因此任何一张单独的选票都不会向任何人揭示,组织者也不例外。", - "ballot_2": "您可以任意多次重新投票;只有最后一张选票计入结果。此前的选票仍然保存,但与它无法区分,正因如此,您可以不动声色地替换掉在压力下投出的一票。", + "ballot_1": "你的选择在提交前就在浏览器中用组织者持有的密钥加密。选举在加密状态下把所有选票相加,只有这个总和会被解密,并附带合约会检查的证明。这些密钥可以打开任意一张选票,因此选票上没有任何信息表明是谁投的,或者你的哪一张选票在它之前。", + "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/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/adoptRecoveryPhrase.test.ts b/frontend/src/lib/adoptRecoveryPhrase.test.ts index 3c6b700..ebd63ba 100644 --- a/frontend/src/lib/adoptRecoveryPhrase.test.ts +++ b/frontend/src/lib/adoptRecoveryPhrase.test.ts @@ -73,7 +73,7 @@ vi.mock('./identityVault', async () => { }); describe('adoptRecoveryPhrase', () => { - const frase = 'amber anchor apple arrow autumn bamboo beacon berry bishop bottle branch bridge'; + const frase = 'radar acorn salad bagel tulip vapor wafer yeast zebra blimp cider dwarf'; beforeEach(() => { localStorage.clear(); @@ -130,7 +130,7 @@ describe('a phrase that rebuilds nobody', () => { * the voter a brand new identity enrolled in nothing, and every election * then told them they were not a member with no way back. */ - const frase = 'amber anchor apple arrow autumn bamboo beacon berry bishop bottle branch bridge'; + const frase = 'radar acorn salad bagel tulip vapor wafer yeast zebra blimp cider dwarf'; beforeEach(() => { localStorage.clear(); @@ -191,7 +191,7 @@ describe('where a recovered phrase ends up', () => { * The second step, which is where this decision lives now. Same outcomes as * before, asked for out loud instead of behind a dialog nobody expected. */ - const frase = 'amber anchor apple arrow autumn bamboo beacon berry bishop bottle branch bridge'; + const frase = 'radar acorn salad bagel tulip vapor wafer yeast zebra blimp cider dwarf'; beforeEach(() => { localStorage.clear(); 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 new file mode 100644 index 0000000..e666483 --- /dev/null +++ b/frontend/src/lib/ballot.ts @@ -0,0 +1,275 @@ +/** + * A voter's ballots in one election: reading their chain, building the next + * ballot and proving it. + * + * WHAT A VOTER'S CHAIN IS. Every ballot is filed on chain under a tag, + * Poseidon(TAG, secret, scope, k), for k = 0, 1, 2... Only the voter can + * compute those tags, so only the voter can tell which ballots are theirs; + * everyone else sees a list of unrelated ballots. Finding one's place in the + * chain is therefore a walk: compute tag 0, look for it, then tag 1, and so on + * until a tag is missing. That tag is the next ballot's, and the ballot found + * under the tag before it is the one the next ballot cancels. + * + * WHAT IS PROVED, in the browser, with the ballot circuit (circuits/src): that + * the voter is on the roll, that the ballot is one option, that it cancels the + * voter's own previous ballot, and that the tags are theirs. See ballotCrypto + * for the arithmetic and the circuit for the statement. + */ +import type { Identity } from "@semaphore-protocol/identity"; +import { LeanIMT } from "@zk-kit/lean-imt"; +// One subpath per arity, never the package root: its CommonJS index loads the +// round constants of all sixteen arities, 580 kB the bundler cannot drop. +import { poseidon2 } from "poseidon-lite/poseidon2"; +import { poseidon3 } from "poseidon-lite/poseidon3"; +import { poseidon4 } from "poseidon-lite/poseidon4"; + +import { + ballotCalldata, + ballotCircuitInputs, + ballotTag, + epochTag, + solidityProof, + unflattenPoints, + type CastBallot, + type Point, + type SolidityProof, +} from "./ballotCrypto"; +import { getElection, getReadProvider } from "./contracts"; +import { eventArgs, queryLogsFrom } from "./logs"; +import { fetchElectionGroup } from "./semaphore"; + +/** A ballot as the chain shows it. B lists hold the slots the election uses. */ +export interface BallotEvent { + tag: bigint; + index: number; + leaf: bigint; + voteA: Point; + voteB: Point[]; + timestamp: Date; + txHash: string; +} + +/** The public parameters every ballot in an election is built against. */ +export interface BallotContext { + address: string; + circuitSlots: number; + keys: Point[]; + scope: bigint; +} + +export async function readBallotContext(address: string): Promise { + const election = getElection(address); + const [slots, keys, scope] = await Promise.all([ + election.circuitSlots() as Promise, + election.tallyKeys() as Promise, + election.scope() as Promise, + ]); + return { address, circuitSlots: Number(slots), keys: unflattenPoints(keys), scope }; +} + +/** Every ballot cast in an election, in the order they joined the ballots tree. */ +export async function fetchBallots(address: string, slotsInUse?: number): Promise { + const election = getElection(address); + const events = await queryLogsFrom(election, election.filters.BallotCast()); + return events.map(e => { + const args = eventArgs<{ + tag: bigint; + index: bigint; + leaf: bigint; + voteA: bigint[]; + voteB: bigint[]; + timestamp: bigint; + }>(e); + const voteB = unflattenPoints(args.voteB); + return { + tag: args.tag, + index: Number(args.index), + leaf: args.leaf, + voteA: [args.voteA[0], args.voteA[1]], + voteB: slotsInUse === undefined ? voteB : voteB.slice(0, slotsInUse), + timestamp: new Date(Number(args.timestamp) * 1000), + txHash: e.transactionHash, + }; + }); +} + +/** + * Where a voter stands: their ballots so far, oldest first, and the step the + * next one takes. Needs the voter's secret and nothing else from them. + */ +export function voterChain( + secret: bigint, + scope: bigint, + ballots: readonly BallotEvent[], +): { mine: BallotEvent[]; next: bigint } { + const byTag = new Map(ballots.map(b => [b.tag, b])); + const mine: BallotEvent[] = []; + for (let k = 0n; ; k++) { + const found = byTag.get(ballotTag(poseidon4, secret, scope, k)); + if (!found) return { mine, next: k }; + mine.push(found); + } +} + +/** This voter already cast a ballot in the current epoch; the next one opens at `nextAt`. */ +export class EpochAlreadyUsedError extends Error { + constructor(readonly nextAt: Date) { + super("You already voted in this hour. You can change your vote again after " + nextAt.toISOString()); + this.name = "EpochAlreadyUsedError"; + } +} + +/** + * 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. + * + * VITE_CIRCUITS_URL in a deployment: the files belong to the ceremony that + * produced the deployed verifiers and are far too large for the repository, so + * they are published with the deployment (an IPFS pin, say) rather than built + * with the site. Locally, `circuits/scripts/build.mjs` copies them into + * `public/circuits/`. A wrong file is harmless beyond wasting the voter's time: + * its proofs simply fail on chain. + */ +export function circuitFiles(kind: "ballot" | "tally", slots: number): { wasm: string; zkey: string } { + const root = (import.meta.env.VITE_CIRCUITS_URL as string | undefined) ?? `${import.meta.env.BASE_URL ?? "/"}circuits`; + const base = `${root.replace(/\/$/, "")}/${kind}_s${slots}`; + return { wasm: `${base}.wasm`, zkey: `${base}.zkey` }; +} + +export interface PreparedBallot { + ballot: CastBallot; + calldata: ReturnType; + proof: SolidityProof; + /** The step of the voter's chain this ballot is. Zero on a first ballot. */ + k: bigint; +} + +/** + * Builds and proves the voter's next ballot for `choice` (blank = numOptions). + * + * Proving takes seconds and a few hundred megabytes, and the artefacts are + * fetched the first time: the caller shows progress. + */ +export async function prepareBallot( + address: string, + identity: Identity, + choice: number, +): Promise { + const election = getElection(address); + const context = await readBallotContext(address); + const secret = identity.secretScalar; + + 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. + 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"); + const votersPath = group.generateMerkleProof(memberIndex); + + const { mine, next } = voterChain(secret, context.scope, ballots); + const previousBallot = mine.at(-1) ?? null; + + // The ballots tree as it stands, rebuilt from the events. A first ballot + // proves nothing against it, so it takes the chain's own current root, which + // stays valid however many ballots land before this one does. + let ballotsRoot: bigint = await election.ballotsRoot(); + let previous: Parameters[0]["previous"] = null; + if (previousBallot) { + const tree = new LeanIMT((a, b) => poseidon2([a, b]), ballots.map(b => b.leaf)); + const path = tree.generateProof(previousBallot.index); + ballotsRoot = path.root; + previous = { a: previousBallot.voteA, b: previousBallot.voteB, path }; + } + + const { inputs, ballot } = ballotCircuitInputs({ + poseidon3, + poseidon4, + circuitSlots: context.circuitSlots, + secret, + scope: context.scope, + epoch, + keys: context.keys, + choice, + k: next, + voters: { root: votersPath.root, index: votersPath.index, siblings: votersPath.siblings }, + ballotsRoot, + previous, + }); + + const { groth16 } = await import("snarkjs"); + const files = circuitFiles("ballot", context.circuitSlots); + const { proof } = await groth16.fullProve(inputs, files.wasm, files.zkey); + + return { + ballot, + calldata: ballotCalldata(ballot, { votersRoot: votersPath.root, ballotsRoot, epoch }), + proof: solidityProof(proof), + k: next, + }; +} diff --git a/frontend/src/lib/ballotCrypto.test.ts b/frontend/src/lib/ballotCrypto.test.ts new file mode 100644 index 0000000..31917fa --- /dev/null +++ b/frontend/src/lib/ballotCrypto.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { Base8, addPoint, mulPointEscalar } from "@zk-kit/baby-jubjub"; + +import { + BASE, + IDENTITY, + add, + aggregate, + deriveTallyKeys, + decryptTally, + discreteLog, + encryptCancellation, + encryptVote, + equals, + isInSubgroup, + multiply, + randomScalar, + type Ballot, + type Point, +} from "./ballotCrypto"; + +const SLOTS = 4; // three options and the blank vote +const keysFor = (seed: string) => deriveTallyKeys(new TextEncoder().encode(seed.padEnd(32, "x")), "0x01", SLOTS); + +/** A voter's chain of ballots, exactly as a client builds it. */ +function chain(keys: readonly Point[], choices: number[]): Ballot[] { + const out: Ballot[] = []; + let previous: { a: Point; b: Point[] } | null = null; + choices.forEach((choice, k) => { + const vote = encryptVote(keys, choice, randomScalar()); + const cancel = encryptCancellation(keys, randomScalar(), previous); + out.push({ tag: BigInt(k), voteA: vote.a, voteB: vote.b, cancelA: cancel.a, cancelB: cancel.b }); + previous = vote; + }); + return out; +} + +describe("Baby Jubjub arithmetic", () => { + it("agrees with the reference implementation the circuit is checked against", () => { + expect(BASE).toEqual(Base8); + const k = 123456789n; + expect(multiply(BASE, k)).toEqual(mulPointEscalar(Base8, k)); + const p = multiply(BASE, 7n); + const q = multiply(BASE, 11n); + expect(add(p, q)).toEqual(addPoint(p as [bigint, bigint], q as [bigint, bigint])); + }); + + it("has the identity as a neutral element and a generator of the prime subgroup", () => { + expect(add(BASE, IDENTITY)).toEqual(BASE); + expect(isInSubgroup(BASE)).toBe(true); + }); + + it("recovers small discrete logs and refuses ones past the bound", () => { + expect(discreteLog(multiply(BASE, 0n), 10)).toBe(0n); + expect(discreteLog(multiply(BASE, 37n), 100)).toBe(37n); + expect(discreteLog(multiply(BASE, 101n), 100)).toBeNull(); + }); +}); + +describe("ballots that cancel their predecessor", () => { + it("add up to each voter's LAST choice, with nothing linking the steps", async () => { + const { secrets, keys } = await keysFor("organizer"); + const ballots = [ + ...chain(keys, [0]), // one vote for option 0 + ...chain(keys, [2, 1]), // voted 2, then changed to 1 + ...chain(keys, [1, 1, 3]), // 1, 1, then the blank vote + ]; + expect(decryptTally(secrets, aggregate(ballots, SLOTS), ballots.length)).toEqual([1n, 1n, 0n, 1n]); + }); + + it("makes a first ballot and a re-vote look alike: both carry a cancellation", async () => { + const { keys } = await keysFor("organizer"); + const [first] = chain(keys, [0]); + const [, second] = chain(keys, [0, 1]); + for (const ballot of [first, second]) { + expect(equals(ballot.cancelA, IDENTITY)).toBe(false); + expect(ballot.cancelB.every(p => !equals(p, IDENTITY))).toBe(true); + } + }); + + it("does not let a cancellation be found by negating a previous vote", async () => { + const { keys } = await keysFor("organizer"); + const [first, second] = chain(keys, [0, 1]); + // Re-randomised: -A_prev alone is not what the chain shows. + expect(equals(add(second.cancelA, first.voteA), IDENTITY)).toBe(false); + }); +}); + +describe("decrypting the tally", () => { + it("recovers the counts from the aggregate", async () => { + const { secrets, keys } = await keysFor("organizer"); + const ballots = [...chain(keys, [0]), ...chain(keys, [2, 0]), ...chain(keys, [3])]; + expect(decryptTally(secrets, aggregate(ballots, SLOTS), ballots.length)).toEqual([2n, 0n, 0n, 1n]); + }); + + it("counts an election nobody voted in as zero everywhere", async () => { + const { secrets } = await keysFor("organizer"); + expect(decryptTally(secrets, aggregate([], SLOTS), 0)).toEqual([0n, 0n, 0n, 0n]); + }); + + it("refuses a key that does not belong to the ballots", async () => { + const { keys } = await keysFor("organizer"); + const other = await keysFor("somebody-else"); + const ballots = chain(keys, [1]); + expect(() => decryptTally(other.secrets, aggregate(ballots, SLOTS), ballots.length)).toThrow(); + }); +}); + +describe("tally keys", () => { + it("are the same every time from the same secret and nonce", async () => { + const a = await keysFor("organizer"); + const b = await keysFor("organizer"); + expect(a.keys).toEqual(b.keys); + expect(a.keys.every(isInSubgroup)).toBe(true); + expect(new Set(a.secrets).size).toBe(SLOTS); + }); + + it("differ for another organizer or another election", async () => { + const a = await keysFor("organizer"); + const b = await keysFor("somebody-else"); + const c = await deriveTallyKeys(new TextEncoder().encode("organizer".padEnd(32, "x")), "0x02", SLOTS); + expect(a.keys[0]).not.toEqual(b.keys[0]); + expect(a.keys[0]).not.toEqual(c.keys[0]); + }); + + it("aggregate what they are given, slot by slot", async () => { + const { keys } = await keysFor("organizer"); + const ballots = chain(keys, [1]); + const total = aggregate(ballots, SLOTS); + expect(total.b).toHaveLength(SLOTS); + }); +}); diff --git a/frontend/src/lib/ballotCrypto.ts b/frontend/src/lib/ballotCrypto.ts new file mode 100644 index 0000000..4229991 --- /dev/null +++ b/frontend/src/lib/ballotCrypto.ts @@ -0,0 +1,524 @@ +/** + * The arithmetic of a Votain ballot, and the proof that a tally is correct. + * + * WHY THIS SCHEME. Ballots are exponential ElGamal over Baby Jubjub, the + * curve the ballot circuit (circuits/src/ballot.circom) can compute on, which + * is what lets a voter prove a ballot well formed AND cancel their previous one + * without revealing which. See the circuit for the construction. A ballot is: + * + * 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 + * + * with one tally key H_i = x_i·G per slot (options plus the blank vote). + * Adding every vote and every cancellation on chain telescopes each voter's + * chain to their LAST vote, so the aggregate decrypts straight to the counts: + * + * M_i = ΣB_i - x_i·ΣA = count_i·G + * + * and count_i is recovered by a baby-step giant-step search, bounded by the + * number of ballots. + * + * THE PROOF that a tally is right is the tally circuit's + * (circuits/src/tally.circom), checked by the election on chain against the + * aggregate it accumulated itself; nothing here needs to be trusted for it. + * + * Deliberately free of dependencies, like the module it replaces: the browser, + * the auditor's CLI and the contract tests import this one file. Poseidon is + * passed in where it is needed, since each of them already has one. + */ + +// ──────────────────────────────────────────────── +// The curve +// ──────────────────────────────────────────────── + +/** The BN254 scalar field: Baby Jubjub's base field and the circuit's field. */ +export const FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617n; +/** Order of the prime subgroup generated by `BASE`. */ +export const SUBGROUP_ORDER = 2736030358979909402780800718157159386076813972158567259200215660948447373041n; + +const A_COEFF = 168700n; +const D_COEFF = 168696n; + +export type Point = readonly [bigint, bigint]; + +export const IDENTITY: Point = [0n, 1n]; +/** Base8, the generator the circuit and Semaphore use. */ +export const BASE: Point = [ + 5299619240641551281634865583518297030282874472190772894086521144482721001553n, + 16950150798460657717958625567821834550301663161624707787222815936182638968203n, +]; + +const mod = (a: bigint, m: bigint = FIELD): bigint => ((a % m) + m) % m; + + +/** Modular inverse by extended Euclid, far cheaper than a Fermat power here. */ +function inverse(a: bigint): bigint { + let [oldR, r] = [mod(a), FIELD]; + let [oldS, s] = [1n, 0n]; + while (r !== 0n) { + const q = oldR / r; + [oldR, r] = [r, oldR - q * r]; + [oldS, s] = [s, oldS - q * s]; + } + return mod(oldS); +} + +/** + * Extended twisted Edwards coordinates (X:Y:Z:T), x = X/Z, y = Y/Z, xy = T/Z. + * + * Additions need no inversion here, so a scalar multiplication pays for one, + * at the end, instead of one per step: the difference between a tally that + * takes seconds and one that takes minutes. + */ +type Extended = [bigint, bigint, bigint, bigint]; + +const toExtended = ([x, y]: Point): Extended => [x, y, 1n, mod(x * y)]; + +function toAffine([x, y, z]: Extended): Point { + const zi = inverse(z); + return [mod(x * zi), mod(y * zi)]; +} + +/** add-2008-hwcd: complete on Baby Jubjub, whose d is not a square. */ +function addExtended(p: Extended, q: Extended): Extended { + const [x1, y1, z1, t1] = p; + const [x2, y2, z2, t2] = q; + const a = mod(x1 * x2); + const b = mod(y1 * y2); + const c = mod(D_COEFF * t1 * t2); + const d = mod(z1 * z2); + const e = mod((x1 + y1) * (x2 + y2) - a - b); + const f = mod(d - c); + const g = mod(d + c); + const h = mod(b - A_COEFF * a); + return [mod(e * f), mod(g * h), mod(f * g), mod(e * h)]; +} + +/** Twisted Edwards addition, complete on Baby Jubjub. */ +export function add(p: Point, q: Point): Point { + return toAffine(addExtended(toExtended(p), toExtended(q))); +} + +export const negate = (p: Point): Point => [mod(-p[0]), p[1]]; + +function multiplyExtended(p: Point, scalar: bigint): Point { + let result: Extended = [0n, 1n, 1n, 0n]; + let addend = toExtended(p); + let e = scalar; + while (e > 0n) { + if (e & 1n) result = addExtended(result, addend); + addend = addExtended(addend, addend); + e >>= 1n; + } + return toAffine(result); +} + +export const multiply = (p: Point, scalar: bigint): Point => + multiplyExtended(p, mod(scalar, SUBGROUP_ORDER)); + +export const equals = (p: Point, q: Point): boolean => p[0] === q[0] && p[1] === q[1]; + +export function isOnCurve(p: Point): boolean { + const [x, y] = p; + if (x < 0n || x >= FIELD || y < 0n || y >= FIELD) return false; + const xx = mod(x * x); + const yy = mod(y * y); + return mod(A_COEFF * xx + yy) === mod(1n + D_COEFF * xx * yy); +} + +/** A point of the prime subgroup: on the curve, and killed by its order. */ +export const isInSubgroup = (p: Point): boolean => + isOnCurve(p) && equals(multiplyExtended(p, SUBGROUP_ORDER), IDENTITY); + +/** A uniformly random scalar in [1, l). */ +export function randomScalar(): bigint { + for (;;) { + const bytes = crypto.getRandomValues(new Uint8Array(40)); + const value = bytesToBigInt(bytes) % SUBGROUP_ORDER; + if (value !== 0n) return value; + } +} + +function bytesToBigInt(bytes: Uint8Array): bigint { + let x = 0n; + for (const b of bytes) x = (x << 8n) | BigInt(b); + return x; +} + +// ──────────────────────────────────────────────── +// Ballots +// ──────────────────────────────────────────────── + +/** One ballot as the chain records it: a vote and a cancellation. */ +export interface Ballot { + tag: bigint; + voteA: Point; + voteB: Point[]; + cancelA: Point; + cancelB: Point[]; +} + +/** The vote part of a fresh ballot, for `slots` slots, voting `choice`. */ +export function encryptVote(keys: readonly Point[], choice: number, r: bigint): { a: Point; b: Point[] } { + if (!Number.isInteger(choice) || choice < 0 || choice >= keys.length) { + throw new Error(`not an option index: ${choice}`); + } + return { + a: multiply(BASE, r), + b: keys.map((h, i) => add(i === choice ? BASE : IDENTITY, multiply(h, r))), + }; +} + +/** The cancellation of `previous` (or of nothing), re-randomised with `s`. */ +export function encryptCancellation( + keys: readonly Point[], + s: bigint, + previous: { a: Point; b: readonly Point[] } | null, +): { a: Point; b: Point[] } { + const prevA = previous?.a ?? IDENTITY; + return { + a: add(negate(prevA), multiply(BASE, s)), + b: keys.map((h, i) => add(negate(previous?.b[i] ?? IDENTITY), multiply(h, s))), + }; +} + +/** Everything cast, added up: one A and one B per slot. */ +export function aggregate(ballots: readonly Ballot[], slots: number): { a: Point; b: Point[] } { + let a: Point = IDENTITY; + const b: Point[] = Array.from({ length: slots }, () => IDENTITY); + for (const ballot of ballots) { + a = add(add(a, ballot.voteA), ballot.cancelA); + for (let i = 0; i < slots; i++) b[i] = add(add(b[i], ballot.voteB[i]), ballot.cancelB[i]); + } + return { a, b }; +} + +/** + * The m with m·G = target, for 0 <= m <= bound, by baby-step giant-step. + * Null when there is none, which on an honest chain cannot happen. + */ +export function discreteLog(target: Point, bound: number): bigint | null { + const step = Math.max(1, Math.ceil(Math.sqrt(bound + 1))); + const table = new Map(); + let p: Point = IDENTITY; + for (let j = 0; j < step; j++) { + table.set(`${p[0]}:${p[1]}`, j); + p = add(p, BASE); + } + const giant = negate(multiply(BASE, BigInt(step))); + let gamma = target; + for (let i = 0; i <= step; i++) { + const j = table.get(`${gamma[0]}:${gamma[1]}`); + if (j !== undefined) { + const m = BigInt(i * step + j); + return m <= BigInt(bound) ? m : null; + } + gamma = add(gamma, giant); + } + return null; +} + +// ──────────────────────────────────────────────── +// Hashes the circuit also computes (Poseidon passed in) +// ──────────────────────────────────────────────── + +export type Poseidon = (inputs: bigint[]) => bigint; + +/** Domain separators, as in the circuit. */ +export const TAG_DOMAIN = 1953718116n; +export const EPOCH_DOMAIN = 1701867371n; + +/** Poseidon chain over points, seeded with `seed`: HashPoints in the circuit. */ +export function hashPoints(poseidon3: Poseidon, seed: bigint, points: readonly Point[]): bigint { + return points.reduce((h, p) => poseidon3([h, p[0], p[1]]), seed); +} + +/** What the circuit takes as `keysHash`: the keys, padded with the identity. */ +export function keysHash(poseidon3: Poseidon, keys: readonly Point[], circuitSlots: number): bigint { + return hashPoints(poseidon3, 0n, padPoints(keys, circuitSlots)); +} + +/** A ballot's leaf in the ballots tree: BallotLeaf in the circuit. */ +export function ballotLeaf(poseidon3: Poseidon, tag: bigint, a: Point, b: readonly Point[]): bigint { + return hashPoints(poseidon3, poseidon3([tag, a[0], a[1]]), b); +} + +/** The voter's tag at step k of their chain in one election. */ +export const ballotTag = (poseidon4: Poseidon, secret: bigint, scope: bigint, k: bigint): bigint => + poseidon4([TAG_DOMAIN, secret, scope, k]); + +/** The voter's tag for one epoch: at most one ballot per voter per epoch. */ +export const epochTag = (poseidon4: Poseidon, secret: bigint, scope: bigint, epoch: bigint): bigint => + poseidon4([EPOCH_DOMAIN, secret, scope, epoch]); + +export const padPoints = (points: readonly Point[], length: number): Point[] => + Array.from({ length }, (_, i) => points[i] ?? IDENTITY); + +// ──────────────────────────────────────────────── +// Tally keys +// ──────────────────────────────────────────────── + +/** + * One key per slot, derived from a 32-byte secret and a per-election nonce. + * + * 48 bytes of HMAC output per key, reduced modulo l, so the reduction bias is + * below 2^-128. The public keys are what the election stores. + */ +export async function deriveTallyKeys( + secret: Uint8Array, + keyNonce: string, + slots: number, +): Promise<{ secrets: bigint[]; keys: Point[] }> { + const key = await crypto.subtle.importKey( + "raw", + // A copy: a plain ArrayBuffer-backed view is what every WebCrypto accepts, + // the browser's and Node's, whose type declarations differ. + new Uint8Array(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const secrets: bigint[] = []; + for (let i = 0; i < slots; i++) { + const label = (part: number) => new TextEncoder().encode(`votain:tally-key:v2:${keyNonce}:${i}:${part}`); + const one = new Uint8Array(await crypto.subtle.sign("HMAC", key, label(0))); + const two = new Uint8Array(await crypto.subtle.sign("HMAC", key, label(1))); + const x = bytesToBigInt(new Uint8Array([...one, ...two.slice(0, 16)])) % SUBGROUP_ORDER; + secrets.push(x === 0n ? 1n : x); + } + return { secrets, keys: secrets.map(x => multiply(BASE, x)) }; +} + +// ──────────────────────────────────────────────── +// The tally +// ──────────────────────────────────────────────── + +/** + * The counts an aggregate decrypts to. Needs the keys. + * + * `bound` is the most any count can be, the number of ballots cast, which is + * what keeps the discrete-log search short. Whether these counts reach the + * chain is decided by the tally circuit, which proves them against the + * aggregate the election itself accumulated. + */ +export function decryptTally( + secrets: readonly bigint[], + total: { a: Point; b: readonly Point[] }, + bound: number, +): bigint[] { + return secrets.map((x, i) => { + const m = add(total.b[i], negate(multiply(total.a, x))); + const count = discreteLog(m, bound); + if (count === null) throw new Error(`option ${i + 1} does not decrypt to a count of at most ${bound}`); + return count; + }); +} + +/** A key list as the contract stores it: x0, y0, x1, y1, ... */ +export const flattenPoints = (points: readonly Point[]): bigint[] => points.flatMap(p => [p[0], p[1]]); + +export function unflattenPoints(values: readonly bigint[]): Point[] { + const out: Point[] = []; + for (let i = 0; i + 1 < values.length; i += 2) out.push([values[i], values[i + 1]]); + return out; +} + +// ──────────────────────────────────────────────── +// Circuit inputs +// ──────────────────────────────────────────────── + +/** A LeanIMT membership proof, as @zk-kit/lean-imt produces it. */ +export interface MerklePath { + root: bigint; + index: number | bigint; + siblings: readonly bigint[]; +} + +/** Depth the ballot circuit was compiled for, for both of its trees. */ +export const CIRCUIT_MAX_DEPTH = 20; + +const toStrings = (values: readonly bigint[]): string[] => values.map(v => v.toString()); +const pointStrings = (p: Point): string[] => [p[0].toString(), p[1].toString()]; + +function pathInputs(path: MerklePath | null, maxDepth: number) { + if (!path) return { depth: "0", index: "0", siblings: Array.from({ length: maxDepth }, () => "0") }; + if (path.siblings.length > maxDepth) throw new Error("tree deeper than the circuit supports"); + return { + depth: String(path.siblings.length), + index: BigInt(path.index).toString(), + siblings: toStrings([...path.siblings, ...Array.from({ length: maxDepth - path.siblings.length }, () => 0n)]), + }; +} + +/** What a cast ballot carries on chain, padded to the circuit's slots. */ +export interface CastBallot { + tag: bigint; + epochTag: bigint; + leaf: bigint; + voteA: Point; + voteB: Point[]; + cancelA: Point; + cancelB: Point[]; +} + +/** + * Everything the ballot circuit needs to prove one ballot, and the ballot. + * + * `keys` are the election's own slots; the circuit's extra slots are padded + * with the identity, which is what `keysHash` hashes too. `previous` is the + * voter's own last ballot and its path in the ballots tree, or null for their + * first. + */ +export function ballotCircuitInputs(p: { + poseidon3: Poseidon; + poseidon4: Poseidon; + circuitSlots: number; + maxDepth?: number; + secret: bigint; + scope: bigint; + epoch: bigint; + keys: readonly Point[]; + choice: number; + k: bigint; + voters: MerklePath; + ballotsRoot: bigint; + previous: { a: Point; b: readonly Point[]; path: MerklePath } | null; + r?: bigint; + s?: bigint; +}): { inputs: Record; ballot: CastBallot } { + const maxDepth = p.maxDepth ?? CIRCUIT_MAX_DEPTH; + const keys = padPoints(p.keys, p.circuitSlots); + const r = p.r ?? randomScalar(); + const s = p.s ?? randomScalar(); + + // The encryption runs over the padded keys: identity keys give identity + // components, which is what the circuit computes for unused slots. + const vote = encryptVote(keys, p.choice, r); + const previous = p.previous ? { a: p.previous.a, b: padPoints(p.previous.b, p.circuitSlots) } : null; + const cancel = encryptCancellation(keys, s, previous); + + const tag = ballotTag(p.poseidon4, p.secret, p.scope, p.k); + const ballot: CastBallot = { + tag, + epochTag: epochTag(p.poseidon4, p.secret, p.scope, p.epoch), + leaf: ballotLeaf(p.poseidon3, tag, vote.a, vote.b), + voteA: vote.a, + voteB: vote.b, + cancelA: cancel.a, + cancelB: cancel.b, + }; + + const voters = pathInputs(p.voters, maxDepth); + const ballots = pathInputs(p.previous?.path ?? null, maxDepth); + const prevA = previous?.a ?? IDENTITY; + const prevB = previous?.b ?? padPoints([], p.circuitSlots); + + return { + ballot, + inputs: { + votersRoot: p.voters.root.toString(), + ballotsRoot: p.ballotsRoot.toString(), + scope: p.scope.toString(), + keysHash: keysHash(p.poseidon3, p.keys, p.circuitSlots).toString(), + slots: String(p.keys.length), + epoch: p.epoch.toString(), + voteA: pointStrings(vote.a), + voteB: vote.b.map(pointStrings), + cancelA: pointStrings(cancel.a), + cancelB: cancel.b.map(pointStrings), + secret: p.secret.toString(), + votersDepth: voters.depth, + votersIndex: voters.index, + votersSiblings: voters.siblings, + keys: keys.map(pointStrings), + choice: Array.from({ length: p.circuitSlots }, (_, i) => (i === p.choice ? "1" : "0")), + r: r.toString(), + s: s.toString(), + k: p.k.toString(), + prevA: pointStrings(prevA), + prevB: prevB.map(pointStrings), + ballotsDepth: ballots.depth, + ballotsIndex: ballots.index, + ballotsSiblings: ballots.siblings, + }, + }; +} + +/** + * The inputs of the tally circuit, for publishing (`publish`) or for voiding + * below the quorum. `aggregate` is the election's on-chain sum, padded. + */ +export function tallyCircuitInputs(p: { + poseidon3: Poseidon; + circuitSlots: number; + keys: readonly Point[]; + secrets: readonly bigint[]; + counts: readonly bigint[]; + aggregate: { a: Point; b: readonly Point[] }; + quorum: bigint; + publish: boolean; +}): Record { + const pad = (values: readonly T[], filler: T) => + Array.from({ length: p.circuitSlots }, (_, i) => values[i] ?? filler); + return { + keysHash: keysHash(p.poseidon3, p.keys, p.circuitSlots).toString(), + slots: String(p.keys.length), + aggA: pointStrings(p.aggregate.a), + aggB: padPoints(p.aggregate.b, p.circuitSlots).map(pointStrings), + quorum: p.quorum.toString(), + publish: p.publish ? "1" : "0", + // An unused slot needs a valid scalar all the same; its key is forced to + // the identity whatever this is. + secrets: toStrings(pad(p.secrets, 1n)), + count: toStrings(pad(p.counts, 0n)), + }; +} + +// ──────────────────────────────────────────────── +// Proofs, as the verifiers take them +// ──────────────────────────────────────────────── + +/** A Groth16 proof as snarkjs returns it. */ +export interface SnarkProof { + pi_a: readonly (string | bigint)[]; + pi_b: readonly (readonly (string | bigint)[])[]; + pi_c: readonly (string | bigint)[]; +} + +/** A Groth16 proof in the verifiers' calldata order: b's coordinates are swapped. */ +export interface SolidityProof { + a: [bigint, bigint]; + b: [[bigint, bigint], [bigint, bigint]]; + c: [bigint, bigint]; +} + +export function solidityProof(proof: SnarkProof): SolidityProof { + const n = (v: string | bigint) => BigInt(v); + return { + a: [n(proof.pi_a[0]), n(proof.pi_a[1])], + b: [ + [n(proof.pi_b[0][1]), n(proof.pi_b[0][0])], + [n(proof.pi_b[1][1]), n(proof.pi_b[1][0])], + ], + c: [n(proof.pi_c[0]), n(proof.pi_c[1])], + }; +} + +/** A cast ballot as `castVote` and `relayVote` take it. */ +export function ballotCalldata( + ballot: CastBallot, + roots: { votersRoot: bigint; ballotsRoot: bigint; epoch: bigint }, +) { + return { + votersRoot: roots.votersRoot, + ballotsRoot: roots.ballotsRoot, + epoch: roots.epoch, + tag: ballot.tag, + epochTag: ballot.epochTag, + leaf: ballot.leaf, + voteA: [...ballot.voteA] as [bigint, bigint], + voteB: flattenPoints(ballot.voteB), + cancelA: [...ballot.cancelA] as [bigint, bigint], + cancelB: flattenPoints(ballot.cancelB), + }; +} diff --git a/frontend/src/lib/chainElections.ts b/frontend/src/lib/chainElections.ts index 735fc51..b283df1 100644 --- a/frontend/src/lib/chainElections.ts +++ b/frontend/src/lib/chainElections.ts @@ -14,7 +14,7 @@ import { withDistinctNames } from "./ballotNames"; // retranslate an already-fetched election until it is refetched, which every // navigation does. import i18n from "../i18n/config"; -import { getStoredCommitment, getStoredIdentity, getStoredVoteNullifier } from "./semaphore"; +import { getStoredCommitment, getStoredIdentity, getStoredBallotTag } from "./semaphore"; import type { Identity } from "@semaphore-protocol/identity"; import { commitmentsForElections, @@ -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, @@ -99,7 +103,7 @@ export async function fetchElection(address: string): Promise { phase, memberCount, voteCount, - distinctVoters, + provenVoters, resultsPublished, metadataJson, policyHashOnChain, @@ -107,6 +111,7 @@ export async function fetchElection(address: string): Promise { cancellable, createdAt, platformAttester, + privacyQuorumOnChain, ] = await Promise.all([ c.name(), c.organizer(), @@ -120,7 +125,7 @@ export async function fetchElection(address: string): Promise { c.phase(), c.memberCount(), c.voteCount(), - c.distinctVoters(), + c.voters() as Promise, c.resultsPublished(), c.metadataJson(), c.eligibilityPolicyHash() as Promise, @@ -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; @@ -260,11 +249,11 @@ export async function fetchElection(address: string): Promise { isEnrolled = await c.hasMember(commitment); } - // "Already voted": verify the device's remembered vote nullifier on-chain. + // "Already voted": verify the device's remembered ballot tag on chain. let hasVoted: boolean | undefined; - const votedNullifier = getStoredVoteNullifier(address); - if (votedNullifier !== null) { - hasVoted = (await c.nullifierNonces(votedNullifier)) > 0n; + const votedTag = getStoredBallotTag(address); + if (votedTag !== null) { + hasVoted = (await c.usedTags(votedTag)) as boolean; } const basePhase = PHASE_MAP[Number(phase)] ?? "upcoming"; @@ -310,22 +299,24 @@ export async function fetchElection(address: string): Promise { requiresOrb: personhood === "orb", totalEnrolled: Number(memberCount), castVotes: Number(voteCount), - distinctVoters: Number(distinctVoters), - tallyCheck, + // Known only once the tally proves it: before that, nothing public says + // how many of the ballots are re-votes, which is the point. + distinctVoters: resultsPublished || provenVoters > 0n ? Number(provenVoters) : undefined, 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), keyNonce: meta.keyNonce, isEnrolled, hasVoted, - // The vote's anonymous on-chain identifier, shown to the voter as their own - // receipt. Distinct from the transaction hash the history calls a reference. - voteNullifier: hasVoted && votedNullifier !== null ? "0x" + votedNullifier.toString(16) : undefined, + // The ballot's anonymous on-chain identifier, shown to the voter as their + // own receipt. Distinct from the transaction hash the history calls a reference. + ballotTag: hasVoted && votedTag !== null ? "0x" + votedTag.toString(16) : undefined, tags: meta.tags, }; } @@ -351,8 +342,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..ed643c4 100644 --- a/frontend/src/lib/contracts.ts +++ b/frontend/src/lib/contracts.ts @@ -13,10 +13,10 @@ import { addresses, chainInfo } from "./deployments"; // ──────────────────────────────────────────────── export const ELECTION_FACTORY_ABI = [ - "function createElection((string name, uint8 votingType, uint256 thresholdValue, uint256 numOptions, uint256 enrollStart, uint256 enrollEnd, uint256 voteStart, uint256 voteEnd, uint256 scope, string paillierPublicKey, string metadataJson, address eligibilityAttester, bytes32 eligibilityPolicyHash, uint8 personhood, uint256 privacyQuorum, bool fixedSchedule, bool cancellable) cfg, uint256 fromBalance) payable returns (address)", + "function createElection((string name, uint8 votingType, uint256 thresholdValue, uint256 numOptions, uint256 enrollStart, uint256 enrollEnd, uint256 voteStart, uint256 voteEnd, uint256[] tallyKeys, string metadataJson, address eligibilityAttester, bytes32 eligibilityPolicyHash, uint8 personhood, uint256 privacyQuorum, bool fixedSchedule, bool cancellable) cfg, uint256 fromBalance) payable returns (address)", "function electionsCount() view returns (uint256)", "function getElections(uint256 offset, uint256 limit) view returns (address[])", - "event ElectionCreated(address indexed electionAddress, address indexed organizer, string name, uint8 votingType, uint256 scope)", + "event ElectionCreated(address indexed electionAddress, address indexed organizer, string name, uint8 votingType)", ] as const; export const ELECTION_ABI = [ @@ -31,7 +31,9 @@ export const ELECTION_ABI = [ "function voteStart() view returns (uint256)", "function voteEnd() view returns (uint256)", "function scope() view returns (uint256)", - "function paillierPublicKey() view returns (string)", + "function tallyKeys() view returns (uint256[])", + "function keysHash() view returns (uint256)", + "function circuitSlots() view returns (uint256)", "function metadataJson() view returns (string)", // state "function phase() view returns (uint8)", @@ -39,9 +41,16 @@ export const ELECTION_ABI = [ "function merkleTreeRoot() view returns (uint256)", "function merkleTreeDepth() view returns (uint256)", "function hasMember(uint256 identityCommitment) view returns (bool)", - "function nullifierNonces(uint256 nullifier) view returns (uint256)", + "function ballotsRoot() view returns (uint256)", + "function currentEpoch() view returns (uint256)", + "function EPOCH_LENGTH() view returns (uint256)", + "function usedTags(uint256 tag) view returns (bool)", + "function usedEpochTags(uint256 epochTag) view returns (bool)", + "function aggregate() view returns (uint256[2] a, uint256[] b)", "function voteCount() view returns (uint256)", - "function distinctVoters() view returns (uint256)", + /// How many voters the result rests on: zero until it is published or voided + /// below the quorum, because nothing before the tally can say. + "function voters() view returns (uint256)", "function privacyQuorum() view returns (uint256)", "function fixedSchedule() view returns (bool)", "function cancellable() view returns (bool)", @@ -53,8 +62,6 @@ export const ELECTION_ABI = [ "function winnerIndex() view returns (uint256)", // actions "function enroll(uint256 identityCommitment)", - "function enrollAttested(uint256 identityCommitment, uint256 deadline, bytes signature)", - "function enrollPrivate(uint256 identityCommitment, uint256 humanTag, uint256 deadline, bytes platformSignature, bytes eligibilitySignature)", /// Zero on elections deployed before private enrolment existed, which still /// take the public paths. Anything the current factory deploys answers with /// the platform's key and refuses those paths. @@ -62,18 +69,19 @@ export const ELECTION_ABI = [ "function eligibilityAttester() view returns (address)", "function eligibilityPolicyHash() view returns (bytes32)", "function personhood() view returns (uint8)", - "function castVote(bytes voteCiphertext, uint256 nullifier, uint256 merkleRoot, uint256 merkleDepth, uint256[2] _pA, uint256[2][2] _pB, uint256[2] _pC)", "function cancelElection()", "function openEnrollmentEarly()", "function openVotingEarly()", "function closeEnrollmentEarly()", "function closeVotingEarly()", "function markVoided()", - "function publishResults(string ipfsCid, uint256[] tallyResults)", + "function publishResults(string ipfsCid, uint256[] tallyResults, (uint256[2] a, uint256[2][2] b, uint256[2] c) proof)", + "function voidBelowQuorum(uint256 votersBelow, (uint256[2] a, uint256[2][2] b, uint256[2] c) proof)", // events "event MemberEnrolled(uint256 indexed identityCommitment, uint256 index, uint256 merkleTreeRoot)", - "event VoteCast(uint256 indexed nullifier, bytes voteCiphertext, uint256 nonce, uint256 timestamp)", + "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)", + "event VoidedBelowQuorum(uint256 voters)", ] as const; export const PAYMASTER_ABI = [ @@ -89,7 +97,7 @@ export const PAYMASTER_ABI = [ // when it pays, so an estimate cannot promise more than a relay would get. "function maxGasPrice() view returns (uint256)", "function maxRelayGas() view returns (uint256)", - "function relayVote(address election, bytes voteCiphertext, uint256 nullifier, uint256 merkleRoot, uint256 merkleDepth, uint256[2] pA, uint256[2][2] pB, uint256[2] pC)", + "function relayVote(address election, (uint256 votersRoot, uint256 ballotsRoot, uint256 epoch, uint256 tag, uint256 epochTag, uint256 leaf, uint256[2] voteA, uint256[] voteB, uint256[2] cancelA, uint256[] cancelB) ballot, (uint256[2] a, uint256[2][2] b, uint256[2] c) proof)", "event Deposited(address indexed organizer, uint256 amount)", "event ElectionFunded(address indexed election, address indexed from, uint256 amount)", "event ReserveReleased(address indexed election, address indexed organizer, uint256 amount)", 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/enrollAnother.test.ts b/frontend/src/lib/enrollAnother.test.ts index 3ca148c..f2d5a54 100644 --- a/frontend/src/lib/enrollAnother.test.ts +++ b/frontend/src/lib/enrollAnother.test.ts @@ -65,7 +65,7 @@ describe('adding another passkey when this browser already registered one', () = localStorage.setItem('votain_identity_mode', 'local'); localStorage.setItem( 'votain_recovery_phrase', - 'shelter drift canvas rosemary marble linen emerald eagle lilac glacier mineral hollow', + 'salad acorn radar bagel tulip vapor wafer yeast zebra blimp cider dwarf', ); const { enrollThisDevice } = await import('./semaphore'); diff --git a/frontend/src/lib/gasNeeds.ts b/frontend/src/lib/gasNeeds.ts index 1269609..bbb4cbb 100644 --- a/frontend/src/lib/gasNeeds.ts +++ b/frontend/src/lib/gasNeeds.ts @@ -90,6 +90,9 @@ export interface ElectionNeed { * `distinctVoters` counts PEOPLE, not ballots, which is what makes it the right * figure here: a voter who changed their mind cast two ballots and is one person * who no longer needs paying for. `castVotes` would undercount who is left. + * + * While voting runs it is unknown, since re-votes cannot be told apart, so the + * whole roll is counted: an estimate of gas still needed errs towards enough. */ export function remainingVoters(election: Election): number { const voted = election.distinctVoters ?? 0; @@ -121,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/organizer.ts b/frontend/src/lib/organizer.ts index a86375d..0d8b86c 100644 --- a/frontend/src/lib/organizer.ts +++ b/frontend/src/lib/organizer.ts @@ -2,17 +2,24 @@ * Organizer actions: create elections and drive their lifecycle. * * Uses the organizer's injected EOA signer (MetaMask). Election deployment - * generates a fresh Paillier keypair: the public key is stored on-chain (voters - * encrypt with it), the private key is kept in localStorage so the organizer can - * run the tally later. In production the private key would be sealed to a - * passkey: see docs/dev/architecture.md. + * fixes the tally keys: the public keys are stored on chain (voters encrypt + * with them), the secrets are re-derived from the organizer's wallet whenever a + * tally is run (`tallyKey.ts`), or kept here when the wallet cannot derive. */ import { type EventLog, type Log, type Signer } from "ethers"; import { getFactory, getElection, getPaymaster, getReadProvider } from "./contracts"; import { eventArgs, queryLogsFrom } from "./logs"; import { fetchOrganizerElectionAddresses } from "./chainElections"; -import { COUNTER_BASE, generateElectionKeys, type SerializedKeyPair } from "./paillier"; -import { deriveElectionKeys, newKeyNonce } from "./tallyKey"; +import { flattenPoints, type SolidityProof } from "./ballotCrypto"; +import { + deriveElectionKeys, + newKeyNonce, + parseTallyKeys, + randomTallyKeys, + serializeTallyKeys, + type TallyKeys, +} from "./tallyKey"; +import { signsDeterministically } from "./organizerKey"; import { effectivePersonhood, isCoherentPolicy, @@ -28,7 +35,8 @@ import { splitFunding } from "./gasNeeds"; /** Mirrors `ElectionV4.PersonhoodLevel`. */ const PERSONHOOD_ENUM: Record = { device: 0, document: 1, orb: 2 }; -const PRIVKEY_STORAGE_PREFIX = "votain_paillier_sk_"; +/** Tally key files kept on this device. Keys of the old Paillier scheme lived under `votain_paillier_sk_`. */ +const PRIVKEY_STORAGE_PREFIX = "votain_tally_keys_"; const ORGANIZER_NAME_KEY = "votain_organizer_name"; /** Default label until the organizer sets a display name in their profile. */ @@ -138,25 +146,31 @@ export interface CreateElectionInput { const toUnix = (d: Date): number => Math.floor(d.getTime() / 1000); /** - * Persists a Paillier private key locally, keyed by election address. + * Persists an election's tally keys locally, keyed by election address. * - * Only used as a fallback for devices with no PRF passkey: the preferred path - * derives the key on demand from the passkey and stores nothing at rest. + * Only for keys that cannot be re-derived (a wallet without deterministic + * signatures) or were imported: the preferred path derives them on demand from + * the wallet and stores nothing at rest. */ -export function storeElectionPrivateKey(electionAddress: string, keys: SerializedKeyPair): void { - localStorage.setItem(PRIVKEY_STORAGE_PREFIX + electionAddress.toLowerCase(), JSON.stringify(keys)); +export function storeElectionPrivateKey(electionAddress: string, keys: TallyKeys): void { + localStorage.setItem(PRIVKEY_STORAGE_PREFIX + electionAddress.toLowerCase(), serializeTallyKeys(keys)); } -export function loadElectionPrivateKey(electionAddress: string): SerializedKeyPair | null { +export function loadElectionPrivateKey(electionAddress: string): TallyKeys | null { const raw = localStorage.getItem(PRIVKEY_STORAGE_PREFIX + electionAddress.toLowerCase()); - return raw ? (JSON.parse(raw) as SerializedKeyPair) : null; + if (!raw) return null; + try { + return parseTallyKeys(raw); + } catch { + return null; + } } export interface CreatedElection { address: string; txHash: string; - paillierKeys: SerializedKeyPair; - /** True when the key is re-derivable from the passkey (nothing stored at rest). */ + tallyKeys: TallyKeys; + /** True when the keys are re-derivable from the wallet (nothing stored at rest). */ keyDerivable: boolean; } @@ -172,10 +186,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 @@ -184,10 +199,14 @@ export async function createElection( throw new Error("two candidates would appear on the ballot as the same option"); } + // One key per option and one for the blank vote. + const slots = input.candidates.length + 1; const keyNonce = newKeyNonce(); - const derived = await deriveElectionKeys(keyNonce, signer); + const derived = (await signsDeterministically(signer)) + ? await deriveElectionKeys(keyNonce, slots, signer) + : null; const keyDerivable = derived !== null; - const paillierKeys = derived ?? (await generateElectionKeys()); + const tallyKeys = derived ?? (await randomTallyKeys(slots)); // 2. Metadata bundle stored on-chain (kept small; IPFS on mainnet). // keyNonce is public (a salt), included only when the key is derivable. @@ -201,11 +220,6 @@ export async function createElection( ...(input.organizerDomain ? { organizerDomain: input.organizerDomain } : {}), candidates: input.candidates, privacyQuorum: input.privacyQuorum, - // Recorded, not assumed. The base is fixed into every ballot at encryption - // time, so an election tallied against a different one decodes to nonsense. - // Writing it here is what lets the constant be raised later without - // stranding the elections already running under the old value. - counterBase: COUNTER_BASE.toString(), ...(keyDerivable ? { keyNonce } : {}), // The personhood level travels inside `eligibility`, where the on-chain // policy hash covers it. The old top-level `requireOrb` flag sat outside @@ -230,9 +244,6 @@ export async function createElection( ); } - // 3. Random scope (external nullifier): unique per election - const scope = BigInt(ethers.hexlify(ethers.randomBytes(31))); - const cfg = { name: input.name, votingType: VOTING_TYPE_ENUM[input.votingType], @@ -242,8 +253,9 @@ export async function createElection( enrollEnd: toUnix(input.enrollEnd), voteStart: toUnix(input.voteStart), voteEnd: toUnix(input.voteEnd), - scope, - paillierPublicKey: JSON.stringify(paillierKeys.publicKey), + // The election derives its own tag scope from its address, so there is + // nothing to choose here that two elections could end up sharing. + tallyKeys: flattenPoints(tallyKeys.keys), metadataJson: JSON.stringify(metadata), eligibilityAttester: gated ? (input.eligibilityAttester as string) : ZERO_ADDRESS, eligibilityPolicyHash: gated ? await policyHash(input.eligibility as EligibilityPolicy) : ZERO_HASH, @@ -292,8 +304,8 @@ export async function createElection( if (!address) throw new Error("ElectionCreated event not found in receipt"); // Only persist when the key cannot be re-derived: otherwise store nothing. - if (!keyDerivable) storeElectionPrivateKey(address, paillierKeys); - return { address, txHash: receipt.hash, paillierKeys, keyDerivable }; + if (!keyDerivable) storeElectionPrivateKey(address, tallyKeys); + return { address, txHash: receipt.hash, tallyKeys, keyDerivable }; } // ──────────────────────────────────────────────── @@ -362,18 +374,32 @@ 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), tally.proof); + return waitForLifecycleTx(tx); +} + +/** + * Voids an election that fewer voted in than its privacy quorum, with the proof + * of it. Reveals how many voted and nothing about how. + */ +export async function voidBelowQuorum( + signer: Signer, + address: string, + tally: { voters: number; proof: SolidityProof }, +): Promise { + const tx = await getElection(address, signer).voidBelowQuorum(BigInt(tally.voters), 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 deleted file mode 100644 index 2a50dd9..0000000 --- a/frontend/src/lib/paillier.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, it, expect, beforeAll } from "vitest"; -import { - COUNTER_BASE, - LEGACY_COUNTER_BASE, - counterBaseFor, - decryptTally, - encryptBallot, -} from "./paillier"; -import { generateRandomKeys } from "paillier-bigint"; -import { isHexString, solidityPackedKeccak256 } from "ethers"; - -/** - * The ballot ciphertext is handed to Solidity as `bytes`. `BigInt.toString(16)` - * drops the leading zero nibble, so roughly half of all ciphertexts came out an - * odd number of hex digits: which ethers rejects as BytesLike. The vote failed - * client side, for some voters only, with an opaque error. Caught by the - * contracts E2E suite; locked down here. - */ -describe("encryptBallot, ciphertext is valid Solidity bytes", () => { - // Small deterministic key: we only care about the hex encoding, not security. - const n = 0xfa1n * 0x10007n; - const pkJson = JSON.stringify({ n: "0x" + n.toString(16), g: "0x" + (n + 1n).toString(16) }); - - it("always produces an even number of hex digits", () => { - // Many samples: encryption is randomised, so the leading nibble varies. - for (let i = 0; i < 200; i++) { - const ciphertext = encryptBallot(pkJson, i % 3); - expect((ciphertext.length - 2) % 2, `odd-length ciphertext: ${ciphertext}`).toBe(0); - } - }); - - it("produces values ethers accepts as BytesLike", () => { - for (let i = 0; i < 100; i++) { - const ciphertext = encryptBallot(pkJson, i % 3); - expect(isHexString(ciphertext)).toBe(true); - // This is the exact call the vote path makes; it threw before the fix. - expect(() => solidityPackedKeccak256(["bytes", "uint256"], [ciphertext, 0n])).not.toThrow(); - } - }); - - it("still decodes back to the encrypted counter", () => { - const ciphertext = encryptBallot(pkJson, 2); - // Padding must not change the numeric value. - expect(BigInt(ciphertext)).toBeGreaterThan(0n); - expect(COUNTER_BASE).toBe(1_000_000_000_000n); - }); -}); - -/** - * The base is fixed into every ballot when it is encrypted, so raising the - * constant may not change how an election already under way is read. - */ -describe("counterBaseFor, an election is decoded in its own base", () => { - it("uses what the election recorded", () => { - expect(counterBaseFor(JSON.stringify({ counterBase: "1000000000000" }))).toBe(COUNTER_BASE); - }); - - it("falls back to the old million when nothing was recorded", () => { - // Elections created before the field existed. Defaulting to today's - // constant would decode every one of them to nonsense. - expect(counterBaseFor(JSON.stringify({ privacyQuorum: 3 }))).toBe(LEGACY_COUNTER_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 deleted file mode 100644 index dbc3bce..0000000 --- a/frontend/src/lib/paillier.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Paillier homomorphic ballot encryption. - * - * Encoding: a ballot for option `i` is the plaintext B^i. Adding ciphertexts - * therefore adds per-option counters in base B, so the organizer decrypts ONE - * aggregated value and unpacks every option's total without ever seeing an - * individual vote. Blank vote = last option index. - * - * THE BASE IS PART OF THE BALLOT. It is fixed when a ballot is encrypted, so it - * cannot be changed for an election that already has votes in it. Elections - * record theirs in `metadataJson.counterBase`; the ones created before that - * field existed have none and are read with `LEGACY_COUNTER_BASE`. - */ -import { generateRandomKeys, PublicKey, PrivateKey } from "paillier-bigint"; - -/** - * Votes representable per option, for elections created from now on. - * - * A trillion, where it used to be a million. A million is more than any real - * election needs, but "more than anyone needs" is the kind of bound that gets - * tested by something nobody predicted, and the cost of raising it is nothing: - * the plaintext has about 616 decimal digits to spend and 51 counters of 12 - * digits fit inside it. See `ElectionV4.MAX_OPTIONS`, which enforces the - * matching ceiling on options so the two can never disagree. - */ -export const COUNTER_BASE = 1_000_000_000_000n; - -/** What elections created before `counterBase` was recorded were encoded with. */ -export const LEGACY_COUNTER_BASE = 1_000_000n; - -export const PAILLIER_KEY_BITS = 2048; - -export interface SerializedPublicKey { - n: string; // hex - g: string; // hex -} - -export interface SerializedKeyPair { - publicKey: SerializedPublicKey; - privateKey: { - lambda: string; // hex - mu: string; // hex - }; -} - -/** - * Hex for values consumed as numbers (key material). Odd length is fine here: - * BigInt() parses it either way. - */ -const toHex = (x: bigint): string => "0x" + x.toString(16); -const fromHex = (s: string): bigint => BigInt(s); - -/** - * Hex for values consumed as Solidity `bytes` (the ballot ciphertext). - * - * MUST be zero-padded to an even number of digits. `toString(16)` drops the - * leading zero nibble, so roughly half of all ciphertexts come out an odd number - * of characters, which ethers rejects as BytesLike: the vote would fail client - * side before it ever reached the chain, and only for some voters, which is a - * miserable bug to chase. - */ -const toBytesHex = (x: bigint): string => { - const digits = x.toString(16); - return "0x" + (digits.length % 2 === 0 ? digits : "0" + digits); -}; - -// ──────────────────────────────────────────────── -// Key management -// ──────────────────────────────────────────────── - -export async function generateElectionKeys(): Promise { - const { publicKey, privateKey } = await generateRandomKeys(PAILLIER_KEY_BITS); - return { - publicKey: { n: toHex(publicKey.n), g: toHex(publicKey.g) }, - privateKey: { lambda: toHex(privateKey.lambda), mu: toHex(privateKey.mu) }, - }; -} - -export function parsePublicKey(json: string): PublicKey { - const { n, g } = JSON.parse(json) as SerializedPublicKey; - return new PublicKey(fromHex(n), fromHex(g)); -} - -export function restoreKeyPair(serialized: SerializedKeyPair): { publicKey: PublicKey; privateKey: PrivateKey } { - const publicKey = new PublicKey(fromHex(serialized.publicKey.n), fromHex(serialized.publicKey.g)); - const privateKey = new PrivateKey( - fromHex(serialized.privateKey.lambda), - fromHex(serialized.privateKey.mu), - publicKey, - ); - return { publicKey, privateKey }; -} - -// ──────────────────────────────────────────────── -// Ballots -// ──────────────────────────────────────────────── - -/** - * The counter base one election was encoded with. - * - * Elections created before the base was recorded carry no `counterBase`, and - * they were all encoded with the old million. Defaulting to the current - * constant instead would decode every one of them to nonsense, so absence has - * to mean the legacy value rather than "whatever we use today". - */ -export function counterBaseFor(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; - } -} - -/** Encrypts a ballot for `optionIndex` (blank = numOptions). Returns 0x-hex ciphertext. */ -export function encryptBallot( - publicKeyJson: string, - optionIndex: number, - base: bigint = COUNTER_BASE, -): string { - 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/passkeyPrf.ts b/frontend/src/lib/passkeyPrf.ts index 94e60d8..562259f 100644 --- a/frontend/src/lib/passkeyPrf.ts +++ b/frontend/src/lib/passkeyPrf.ts @@ -885,7 +885,7 @@ export async function derivePrfSecret( // cache, and the authenticator may still hold the credential (the // organizer cleared it, moved browser, or wiped site data). Ask for it // before minting a new one, because for the organizer a new credential is - // a new PRF, which is a new Paillier key, which makes every election they + // a new PRF, which is a new tally key, which makes every election they // created on this device impossible to decrypt. const recovered = await assertPrf([], salt); if (recovered) return recovered.secret; diff --git a/frontend/src/lib/recoveryPhrase.test.ts b/frontend/src/lib/recoveryPhrase.test.ts index 87a85e5..ba99786 100644 --- a/frontend/src/lib/recoveryPhrase.test.ts +++ b/frontend/src/lib/recoveryPhrase.test.ts @@ -29,11 +29,23 @@ describe('recovery phrase', () => { expect(seen.size).toBe(50); }); - it('has a word list that is a power of two, so the draw is uniform', () => { - // The generator masks a random byte with 0x7f. Any other length would make - // some words likelier than others and quietly cost entropy. - expect(phraseWordList()).toHaveLength(128); - expect(new Set(phraseWordList()).size).toBe(128); + it('draws from 1295 distinct words, about 124 bits over twelve', () => { + expect(phraseWordList()).toHaveLength(1295); + expect(new Set(phraseWordList()).size).toBe(1295); + expect(12 * Math.log2(phraseWordList().length)).toBeGreaterThan(124); + }); + + it('draws every word evenly, with no modulo bias toward the start of the list', () => { + // 60k words: each of 1295 expected ~46 times. A modulo draw over 16 bits + // would skew the first 776 words by 1/50; rejection sampling does not. + const counts = new Map(); + for (let i = 0; i < 5000; i++) { + for (const w of generateRecoveryPhrase().split(' ')) counts.set(w, (counts.get(w) ?? 0) + 1); + } + const list = phraseWordList(); + const firstHalf = list.slice(0, 647).reduce((a, w) => a + (counts.get(w) ?? 0), 0); + const secondHalf = list.slice(647, 1294).reduce((a, w) => a + (counts.get(w) ?? 0), 0); + expect(Math.abs(firstHalf - secondHalf) / (firstHalf + secondHalf)).toBeLessThan(0.02); }); }); diff --git a/frontend/src/lib/recoveryPhrase.ts b/frontend/src/lib/recoveryPhrase.ts index 6c02721..364a236 100644 --- a/frontend/src/lib/recoveryPhrase.ts +++ b/frontend/src/lib/recoveryPhrase.ts @@ -31,48 +31,58 @@ */ import { Identity } from "@semaphore-protocol/identity"; +import { EFF_SHORT_WORDS } from "./wordlist"; + /** - * Words the phrase is built from. + * Words the phrase is built from: see `wordlist.ts`. * - * 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. + * A plain list rather than BIP-39, on purpose. These words are read off a + * screen and typed back by someone who may never have seen a seed phrase, so + * they are short, common and unambiguous; and BIP-39 exists to be compatible + * with wallets, so borrowing it would invite someone to type this phrase into + * one. * - * 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. + * LONGER THAN IT WAS. The first list had 128 words, so twelve of them carried + * 84 bits. The commitments they protect are public and permanent, and a + * guessing attack runs against every voter at once, so the margin mattered. + * Twelve words from 1295 carry 124 bits, the same number of words to write + * down, and on top of that every guess still pays `STRETCH_ITERATIONS`. */ -const WORDS = [ - "amber", "anchor", "apple", "arrow", "autumn", "bamboo", "beacon", "berry", - "bishop", "bottle", "branch", "bridge", "bronze", "butter", "cactus", "candle", - "canvas", "carbon", "castle", "cedar", "cherry", "chisel", "cinder", "circle", - "citrus", "clever", "cliff", "clover", "cobalt", "comet", "copper", "coral", - "cotton", "crater", "crimson", "crystal", "cyclone", "dahlia", "damson", "dawn", - "denim", "desert", "diamond", "dolphin", "dragon", "drift", "eagle", "ember", - "emerald", "falcon", "fennel", "fern", "fiddle", "flint", "forest", "fossil", - "galaxy", "garden", "ginger", "glacier", "granite", "gravel", "harbor", "harvest", - "hazel", "hollow", "indigo", "ivory", "jasmine", "jungle", "kettle", "lagoon", - "lantern", "laurel", "lemon", "lichen", "lilac", "linen", "lunar", "magnet", - "mango", "maple", "marble", "meadow", "mercury", "meteor", "mimosa", "mineral", - "mirror", "mosaic", "nectar", "nickel", "nutmeg", "oasis", "obsidian", "olive", - "onyx", "orbit", "orchid", "otter", "oyster", "pebble", "pepper", "pewter", - "pigeon", "pillar", "pine", "planet", "pollen", "poppy", "prairie", "prism", - "pumpkin", "quartz", "quiver", "raven", "ribbon", "river", "rocket", "rosemary", - "saffron", "sage", "salmon", "sapphire", "satin", "shadow", "shelter", "silver", -]; +const WORDS = EFF_SHORT_WORDS; +const WORD_SET: ReadonlySet = new Set(WORDS); -/** Words per phrase. Twelve of 128 is 84 bits. */ +/** Words per phrase. Twelve of 1295 is about 124 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. 124 bits is out of reach already; 600,000 + * rounds of PBKDF2-SHA256 (the current OWASP figure) multiply every guess by + * about 2^19 more, so a weakness found in the list or the generator later does + * not become a practical attack. It 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 { - // One rejection-free draw per word: 128 is a power of two, so the low seven - // bits of a random byte are uniform over the list with nothing to discard. - const bytes = crypto.getRandomValues(new Uint8Array(WORD_COUNT)); - return Array.from(bytes, b => WORDS[b & 0x7f]).join(" "); + // Rejection sampling over 16-bit draws: 1295 does not divide 65536, so a + // plain modulo would make the first words of the list slightly likelier. + // Draws at or above the largest multiple of the list size are thrown away. + const limit = Math.floor(0x10000 / WORDS.length) * WORDS.length; + const words: string[] = []; + while (words.length < WORD_COUNT) { + for (const draw of crypto.getRandomValues(new Uint16Array(WORD_COUNT))) { + if (draw < limit && words.length < WORD_COUNT) words.push(WORDS[draw % WORDS.length]); + } + } + return words.join(" "); } /** @@ -89,23 +99,24 @@ export function normalizePhrase(phrase: string): string { /** Whether a phrase could have come from here: right length, known words. */ export function isValidPhrase(phrase: string): boolean { const words = normalizePhrase(phrase).split(" "); - return words.length === WORD_COUNT && words.every(w => WORDS.includes(w)); + return words.length === WORD_COUNT && words.every(w => WORD_SET.has(w)); } /** The words a phrase uses that this list does not contain, for pointing at typos. */ export function unknownWords(phrase: string): string[] { return normalizePhrase(phrase) .split(" ") - .filter(w => w.length > 0 && !WORDS.includes(w)); + .filter(w => w.length > 0 && !WORD_SET.has(w)); } /** * 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 +127,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..5526e75 100644 --- a/frontend/src/lib/relay.ts +++ b/frontend/src/lib/relay.ts @@ -115,8 +115,8 @@ async function localRelay(functionName: string, args: unknown[]): Promise<{ txHa [ "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 relayVote(address election, bytes voteCiphertext, uint256 nullifier, uint256 merkleRoot, uint256 merkleDepth, uint256[2] pA, uint256[2][2] pB, uint256[2] pC)", + "function relayEnrollPrivate(address election, uint256 identityCommitment, uint256 humanTag, uint256 documentTag, uint256 deadline, bytes platformSignature, bytes eligibilitySignature)", + "function relayVote(address election, (uint256 votersRoot, uint256 ballotsRoot, uint256 epoch, uint256 tag, uint256 epochTag, uint256 leaf, uint256[2] voteA, uint256[] voteB, uint256[2] cancelA, uint256[] cancelB) ballot, (uint256[2] a, uint256[2][2] b, uint256[2] c) proof)", ...RELAY_ERROR_ABI, ], new Wallet(localSigner(LOCAL_RELAY_KEY, 'relay'), provider), @@ -177,6 +177,12 @@ const RELAY_ERROR_ABI = [ "error UnknownOrExpiredRoot()", "error InvalidProof()", "error WrongPhase()", + "error TagAlreadyCast()", + "error EpochAlreadyCast()", + "error WrongEpoch()", + "error TreeFull()", + "error MissingDocumentTag()", + "error UnexpectedDocumentTag()", ]; /** @@ -197,6 +203,12 @@ const SELECTOR_NAMES: Record = { "0x7a651f34": "UnexpectedAttestation", "0x716dcc39": "AttestationExpired", "0x342bd384": "BadAttestation", + "0xd775f1c5": "TagAlreadyCast", + "0x01988a97": "EpochAlreadyCast", + "0x254f6ab2": "WrongEpoch", + "0xb48f2cf8": "TreeFull", + "0x23e148c2": "MissingDocumentTag", + "0xb48b3460": "UnexpectedDocumentTag", }; /** What the chain actually refused with, by whichever route the error kept it. */ @@ -239,6 +251,13 @@ const ERROR_MESSAGE_KEY: Record = { NotPlatformVerified: "errors.not_platform_verified", AttestationRequired: "errors.attestation_required", AttestationExpired: "errors.attestation_expired", + // A second ballot in the same hour: one per voter per epoch bounds how fast + // anyone can spend the organizer's gas. + EpochAlreadyCast: "errors.epoch_already_cast", + // The same ballot submitted twice, usually a retried request that had landed. + TagAlreadyCast: "errors.ballot_already_cast", + // A ballot proved for an epoch that has since passed: prepared, then left. + WrongEpoch: "errors.ballot_expired", }; /** @@ -257,6 +276,19 @@ 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") { + // 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"); + } const key = ERROR_MESSAGE_KEY[revertNameOf(error) ?? ""]; if (key) return i18n.t(key); @@ -326,6 +358,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 +409,7 @@ export async function relayEnrollPrivate( election, identityCommitment, BigInt(voucher.humanTag), + BigInt(voucher.documentTag), BigInt(voucher.deadline), voucher.signature, voucher.eligibilitySignature, @@ -417,7 +452,7 @@ export async function relayEnroll( { election, identityCommitment: identityCommitment.toString(), - ...(attestation ?? {}), + ...attestation, }, "include", ); @@ -425,44 +460,36 @@ export async function relayEnroll( export interface RelayVoteParams { election: string; - voteCiphertext: string; - nullifier: bigint; - merkleRoot: bigint; - merkleDepth: bigint; - pA: [bigint, bigint]; - pB: [[bigint, bigint], [bigint, bigint]]; - pC: [bigint, bigint]; + ballot: { + votersRoot: bigint; + ballotsRoot: bigint; + epoch: bigint; + tag: bigint; + epochTag: bigint; + leaf: bigint; + voteA: [bigint, bigint]; + voteB: bigint[]; + cancelA: [bigint, bigint]; + cancelB: bigint[]; + }; + proof: { a: [bigint, bigint]; b: [[bigint, bigint], [bigint, bigint]]; c: [bigint, bigint] }; } +/** Deep-converts bigints to decimal strings, for a JSON body. */ +const decimal = (value: unknown): unknown => + typeof value === "bigint" + ? value.toString() + : Array.isArray(value) + ? value.map(decimal) + : value && typeof value === "object" + ? Object.fromEntries(Object.entries(value).map(([k, v]) => [k, decimal(v)])) + : value; + export async function relayVote(p: RelayVoteParams): Promise<{ txHash: string }> { - if (isLocalChain()) { - return localRelay("relayVote", [ - p.election, - p.voteCiphertext, - p.nullifier, - p.merkleRoot, - p.merkleDepth, - p.pA, - p.pB, - p.pC, - ]); - } + if (isLocalChain()) return localRelay("relayVote", [p.election, p.ballot, p.proof]); // "omit", not "include": see post(). The ZK proof authorises the ballot, so no // cookie is needed, and sending one would defeat the anonymity this path exists // to provide. - return post( - "/api/relay/vote", - { - election: p.election, - voteCiphertext: p.voteCiphertext, - nullifier: p.nullifier.toString(), - merkleRoot: p.merkleRoot.toString(), - merkleDepth: p.merkleDepth.toString(), - pA: p.pA.map(String), - pB: p.pB.map(pair => pair.map(String)), - pC: p.pC.map(String), - }, - "omit", - ); + return post("/api/relay/vote", decimal(p), "omit"); } diff --git a/frontend/src/lib/relayErrors.test.ts b/frontend/src/lib/relayErrors.test.ts index 26c2fd7..730df8c 100644 --- a/frontend/src/lib/relayErrors.test.ts +++ b/frontend/src/lib/relayErrors.test.ts @@ -36,6 +36,9 @@ describe("relayErrorMessage", () => { ["NotPlatformVerified()", "errors.not_platform_verified"], ["AttestationRequired()", "errors.attestation_required"], ["AttestationExpired()", "errors.attestation_expired"], + ["EpochAlreadyCast()", "errors.epoch_already_cast"], + ["TagAlreadyCast()", "errors.ballot_already_cast"], + ["WrongEpoch()", "errors.ballot_expired"], ]; for (const [raw, key] of cases) { expect(relayErrorMessage(new Error(raw))).toBe(i18n.t(key)); @@ -105,12 +108,31 @@ describe("relayErrorMessage", () => { "UnexpectedAttestation", "AttestationExpired", "BadAttestation", + "MissingDocumentTag", + "UnexpectedDocumentTag", + "TagAlreadyCast", + "EpochAlreadyCast", + "WrongEpoch", + "TreeFull", ]; for (const name of names) { expect(revertNameOf({ data: id(`${name}()`).slice(0, 10) })).toBe(name); } }); + it("explains a second ballot within the hour, and says when the next one is allowed", async () => { + const { EpochAlreadyUsedError } = await import("./ballot"); + 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", () => { // 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/frontend/src/lib/rotateRecovery.test.ts b/frontend/src/lib/rotateRecovery.test.ts index 22ea3b7..b9f4507 100644 --- a/frontend/src/lib/rotateRecovery.test.ts +++ b/frontend/src/lib/rotateRecovery.test.ts @@ -37,7 +37,7 @@ vi.mock('./passkeyPrf', async () => { }); const VIEJA = - 'shelter drift canvas rosemary marble linen emerald eagle lilac glacier mineral hollow'; + 'salad acorn radar bagel tulip vapor wafer yeast zebra blimp cider dwarf'; describe('rotating to a new identity', () => { beforeEach(() => { diff --git a/frontend/src/lib/semaphore.ts b/frontend/src/lib/semaphore.ts index 0f5d131..52780c2 100644 --- a/frontend/src/lib/semaphore.ts +++ b/frontend/src/lib/semaphore.ts @@ -27,9 +27,6 @@ import { import { getRegistry } from "./contracts"; import { isChainConfigured } from "./deployments"; import { Group } from "@semaphore-protocol/group"; -import { generateProof, type SemaphoreProof } from "@semaphore-protocol/proof"; -import { poseidon2 } from "poseidon-lite/poseidon2"; -import { solidityPackedKeccak256, keccak256, zeroPadValue, toBeHex } from "ethers"; import { getElection } from "./contracts"; import { eventArgs, queryLogsFrom } from "./logs"; import { @@ -213,6 +210,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; @@ -956,17 +958,24 @@ export function getStoredCommitment(): bigint | null { return c ? BigInt(c) : null; } -// Per-election vote nullifier (PUBLIC, emitted in VoteCast). Remembered on this -// device after voting so "already voted" shows without a passkey prompt; the -// truth is still verified on-chain via nullifierNonces. -const VOTE_NULLIFIER_PREFIX = "votain_vote_"; +/** + * The tag of this device's latest ballot per election (PUBLIC, the `BallotCast` + * event is indexed by it). Remembered after voting so "already voted" shows + * without a passkey prompt; the truth is still read from the chain. + * + * A tag links to no other ballot, so this records only what this device did, + * which it already knows. Ballots under the old scheme were remembered by + * nullifier under `votain_vote_`, cleared with the rest on sign-out. + */ +const BALLOT_TAG_PREFIX = "votain_ballot_"; +const LEGACY_VOTE_PREFIX = "votain_vote_"; -export function rememberVote(electionAddress: string, nullifier: bigint): void { - localStorage.setItem(VOTE_NULLIFIER_PREFIX + electionAddress.toLowerCase(), nullifier.toString()); +export function rememberVote(electionAddress: string, tag: bigint): void { + localStorage.setItem(BALLOT_TAG_PREFIX + electionAddress.toLowerCase(), tag.toString()); } -export function getStoredVoteNullifier(electionAddress: string): bigint | null { - const v = localStorage.getItem(VOTE_NULLIFIER_PREFIX + electionAddress.toLowerCase()); +export function getStoredBallotTag(electionAddress: string): bigint | null { + const v = localStorage.getItem(BALLOT_TAG_PREFIX + electionAddress.toLowerCase()); return v ? BigInt(v) : null; } @@ -1006,7 +1015,7 @@ export function clearIdentity(): void { localStorage.removeItem(AVATAR_SEED_KEY); // Drop this device's per-election vote records too. for (const k of Object.keys(localStorage)) { - if (k.startsWith(VOTE_NULLIFIER_PREFIX)) localStorage.removeItem(k); + if (k.startsWith(BALLOT_TAG_PREFIX) || k.startsWith(LEGACY_VOTE_PREFIX)) localStorage.removeItem(k); } if (hasPrfCredential()) clearPrfCredential(); } @@ -1025,66 +1034,3 @@ export async function fetchElectionGroup(electionAddress: string): Promise> 8n; -} - -/** - * Computes the Semaphore nullifier for a voter+scope WITHOUT generating a proof. - * nullifier = Poseidon2(hash(scope), identity.secretScalar). Lets us read the - * on-chain nonce before doing the expensive proof generation. - */ -export function computeNullifier(identity: Identity, scope: bigint): bigint { - return poseidon2([hashToField(scope), identity.secretScalar]); -} - -export interface VoteProof { - merkleTreeDepth: bigint; - merkleTreeRoot: bigint; - nullifier: bigint; - pA: [bigint, bigint]; - pB: [[bigint, bigint], [bigint, bigint]]; - pC: [bigint, bigint]; -} - -/** - * Generates the Semaphore membership proof for a vote. - * @param voteCiphertext 0x-hex Paillier ciphertext of the encoded ballot. - * @param nonce Current nullifierNonce for this voter (re-vote support). - * @param scope Election scope (external nullifier). - */ -export async function generateVoteProof( - identity: Identity, - group: Group, - voteCiphertext: string, - nonce: bigint, - scope: bigint, -): Promise { - const message = voteMessage(voteCiphertext, nonce); - const proof: SemaphoreProof = await generateProof(identity, group, message, scope); - - // points order (packGroth16Proof) is already Solidity calldata order. - const p = proof.points.map(BigInt); - return { - merkleTreeDepth: BigInt(proof.merkleTreeDepth), - merkleTreeRoot: BigInt(proof.merkleTreeRoot), - nullifier: BigInt(proof.nullifier), - pA: [p[0], p[1]], - pB: [ - [p[2], p[3]], - [p[4], p[5]], - ], - pC: [p[6], p[7]], - }; -} diff --git a/frontend/src/lib/semaphoreReadback.test.ts b/frontend/src/lib/semaphoreReadback.test.ts index 0e843b4..2c15137 100644 --- a/frontend/src/lib/semaphoreReadback.test.ts +++ b/frontend/src/lib/semaphoreReadback.test.ts @@ -123,7 +123,7 @@ describe('a device that can seal but not read back', () => { localStorage.setItem(READBACK_KEY, 'failed'); localStorage.setItem( PHRASE_KEY, - 'shelter drift canvas rosemary marble linen emerald eagle lilac glacier mineral hollow', + 'salad acorn radar bagel tulip vapor wafer yeast zebra blimp cider dwarf', ); const sem = await freshSemaphore(); @@ -167,7 +167,7 @@ describe('a device that can seal but not read back', () => { it('hands the phrase straight back when it is held locally', async () => { const words = - 'shelter drift canvas rosemary marble linen emerald eagle lilac glacier mineral hollow'; + 'salad acorn radar bagel tulip vapor wafer yeast zebra blimp cider dwarf'; localStorage.setItem(MODE_KEY, 'prf'); localStorage.setItem(READBACK_KEY, 'failed'); localStorage.setItem(PHRASE_KEY, words); diff --git a/frontend/src/lib/tally.ts b/frontend/src/lib/tally.ts index 380b724..cb7da83 100644 --- a/frontend/src/lib/tally.ts +++ b/frontend/src/lib/tally.ts @@ -1,65 +1,83 @@ /** * In-browser tally, run by the organizer from the election screen. * - * 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. + * The election adds every ballot, votes and cancellations, into one running + * aggregate as they arrive. Each re-vote cancels the one before it, so the + * aggregate encrypts each voter's LAST vote, and decrypting it gives the + * counts directly: there is no deduplication to run and no ballot is ever + * decrypted on its own. * - * 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 - * recomputes the same result independently and pins the audit trail to IPFS. + * WHAT THE KEY IS USED FOR, AND WHAT IT COULD DO. This decrypts the aggregate + * only, the counts and nothing about any one ballot. 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. What they could not learn is whose ballot it is or + * which earlier ballot it cancels, since nothing on chain says either. Removing + * the power itself needs the keys split between several parties (threshold + * decryption); see the README's known limits. + * + * THE PROOF. The tally circuit proves that the counts are the decryption of + * the election's own aggregate under its own keys; the contract checks it and + * refuses to publish anything else. Below the privacy quorum the same circuit, + * in its other mode, proves only that too few voted, and the election is voided + * without the counts ever leaving this device. */ import type { Signer } from "ethers"; +import { poseidon3 } from "poseidon-lite/poseidon3"; -import { getElection } from "./contracts"; -import { eventArgs, queryLogsFrom } from "./logs"; -import { loadElectionPrivateKey, storeElectionPrivateKey } from "./organizer"; +import { circuitFiles } from "./ballot"; import { - addCiphertexts, - counterBaseFor, decryptTally, - restoreKeyPair, - type SerializedKeyPair, -} from "./paillier"; -import { deriveElectionKeys } from "./tallyKey"; - + solidityProof, + tallyCircuitInputs, + unflattenPoints, + type SolidityProof, +} from "./ballotCrypto"; +import { getElection } from "./contracts"; +import { loadElectionPrivateKey, storeElectionPrivateKey } from "./organizer"; +import { deriveElectionKeys, keysMatch, parseTallyKeys, type TallyKeys } from "./tallyKey"; export interface TallyResult { /** Vote counts per option; the LAST entry is the blank vote. */ counts: number[]; - /** Distinct voters after coercion-resistance dedup. */ + /** How many voted, which the counts add up to: each voter's chain counts once. */ voters: number; - /** Raw VoteCast events, including superseded re-votes. */ + /** Ballots cast, re-votes included. */ ballotsCast: number; - /** True when `voters` is below the election's privacy quorum. */ + /** True when `voters` reaches the election's privacy quorum. */ quorumMet: boolean; privacyQuorum: number; + /** + * The tally circuit's proof: of the counts when the quorum is met, for + * `publishResults`; of "fewer than the quorum voted" otherwise, for + * `voidBelowQuorum`. + */ + proof: SolidityProof; } /** - * Resolves the decryption keypair. A key stored locally, either the fallback - * random key or one IMPORTED from an exported file, always wins, so a device - * that cannot reach the organizer's wallet can still tally after importing. - * Otherwise a `keyNonce` election re-derives it from the wallet signature. + * Resolves the tally keys. Keys stored locally, either random ones kept at + * creation or ones IMPORTED from an exported file, always win, so a device that + * cannot reach the organizer's wallet can still tally after importing. + * Otherwise a `keyNonce` election re-derives them from the wallet signature. * Null when unavailable. */ export async function resolveTallyKey( address: string, + slots: number, keyNonce?: string, /** Signs the deterministic payload the tally master is derived from. */ signer?: Signer, -): Promise { +): Promise { const stored = loadElectionPrivateKey(address); if (stored) return stored; - return keyNonce ? deriveElectionKeys(keyNonce, signer) : null; + return keyNonce ? deriveElectionKeys(keyNonce, slots, signer) : null; } /** - * Best-effort check of whether this device can obtain the decryption key: an - * imported or stored key, or a `keyNonce` election, whose key any organizer - * signed in here can derive, since deriving it needs only their wallet. + * Best-effort check of whether this device can obtain the keys: stored or + * imported ones, or a `keyNonce` election, whose keys any organizer signed in + * here can derive, since deriving them needs only their wallet. * * Not a guarantee, and deliberately not a prompt: enough to warn up front * instead of failing on the button press. @@ -75,97 +93,84 @@ export class MissingTallyKeyError extends Error { } } +/** The election's own keys, as stored on chain. */ +async function onChainKeys(address: string) { + return unflattenPoints(await getElection(address).tallyKeys()); +} + /** - * Imports a decryption key exported from another device, after validating that - * it is well-formed AND belongs to this election (matches the on-chain public - * key). Stored locally so `computeTally` can then use it. + * Imports a key file exported from another device, after checking it is well + * formed AND belongs to this election (its keys are the ones on chain). + * Stored locally so `computeTally` can then use it. */ export async function importTallyKey(address: string, fileText: string): Promise { - let keys: SerializedKeyPair; - try { - keys = JSON.parse(fileText) as SerializedKeyPair; - } catch { - throw new Error("Not a valid key file (invalid JSON)"); - } - if (!keys?.publicKey?.n || !keys?.publicKey?.g || !keys?.privateKey?.lambda || !keys?.privateKey?.mu) { - throw new Error("Not a valid Votain tally key file"); - } - const pkJson = (await getElection(address).paillierPublicKey()) as string; - if (keys.publicKey.n !== (JSON.parse(pkJson) as { n: string }).n) { + const keys = parseTallyKeys(fileText); + if (!keysMatch(keys.keys, await onChainKeys(address))) { throw new Error("This key does not belong to this election"); } storeElectionPrivateKey(address, keys); } /** - * Computes the tally without publishing it, so the organizer can see the result - * (and whether the privacy quorum held) before committing it on-chain. + * Computes the tally and its proof without publishing, so the organizer can + * see the result (and whether the privacy quorum held) before committing it. + * The signer is what reaches the wallet the keys derive from; optional so a + * device holding an imported key needs none. */ -// The signer is what reaches the organizer vault, and therefore what lets a -// second passkey derive the same key as the first. Optional so a caller that -// 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([ - election.numOptions(), - election.paillierPublicKey(), - election.metadataJson(), + const [slotsBn, metadataJson, quorumBn, ballotsBn, aggregate, keys] = await Promise.all([ + election.circuitSlots() as Promise, + election.metadataJson() as Promise, + election.privacyQuorum() as Promise, + election.voteCount() as Promise, + election.aggregate() as Promise<[bigint[], bigint[]]>, + onChainKeys(address), ]); - const totalSlots = Number(numOptionsBn) + 1; // + blank vote + // The contract's own floor, which is the one it enforces. The copy in the + // metadata is for display and could disagree with it. + const privacyQuorum = Number(quorumBn); + const ballotsCast = Number(ballotsBn); - 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 */ } - - // Imported/stored key first, else re-derive (prompts for a wallet signature). - const keys = await resolveTallyKey(address, keyNonce, signer); - if (!keys) throw new MissingTallyKeyError(); - - const { publicKey, privateKey } = restoreKeyPair(keys); - // The stored key must belong to this election, or the decryption is garbage. - if ("0x" + publicKey.n.toString(16) !== (JSON.parse(pkJson) as { n: string }).n) { - throw new Error("The stored key does not match this election's public key"); + keyNonce = (JSON.parse(metadataJson) as { keyNonce?: string }).keyNonce; + } catch { /* no metadata: no derivable key */ } + + // Imported/stored keys first, else re-derive (asks the wallet for a signature). + const tallyKeys = await resolveTallyKey(address, keys.length, keyNonce, signer); + if (!tallyKeys) throw new MissingTallyKeyError(); + // Keys that are not this election's decrypt to nothing, and the search for + // the counts would say so only after running to its bound. + if (!keysMatch(tallyKeys.keys, keys)) { + throw new Error("The stored key does not match this election's public keys"); } - const events = await queryLogsFrom(election, election.filters.VoteCast()); + const total = { a: [aggregate[0][0], aggregate[0][1]] as const, b: unflattenPoints(aggregate[1]) }; + const counts = decryptTally(tallyKeys.secrets, total, ballotsCast); + const voters = counts.reduce((sum, n) => sum + n, 0n); + const quorumMet = voters >= quorumBn; - // Coercion resistance: only the highest nonce per nullifier survives. - const latest = new Map(); - 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 inputs = tallyCircuitInputs({ + poseidon3, + circuitSlots: Number(slotsBn), + keys, + secrets: tallyKeys.secrets, + counts, + aggregate: total, + quorum: quorumBn, + publish: quorumMet, + }); + const { groth16 } = await import("snarkjs"); + const files = circuitFiles("tally", Number(slotsBn)); + const { proof } = await groth16.fullProve(inputs, files.wasm, files.zkey); return { - counts, - voters: finalVotes.length, - ballotsCast: events.length, + counts: counts.map(Number), + voters: Number(voters), + ballotsCast, quorumMet, privacyQuorum, + proof: solidityProof(proof), }; } diff --git a/frontend/src/lib/tallyAudit.ts b/frontend/src/lib/tallyAudit.ts new file mode 100644 index 0000000..187e0f9 --- /dev/null +++ b/frontend/src/lib/tallyAudit.ts @@ -0,0 +1,59 @@ +/** + * Checks a published result against the chain, with no key and no trust. + * + * TWO FACTS MAKE A RESULT CORRECT, and this establishes the second: + * + * 1. The counts decrypt the election's aggregate. The contract verified the + * tally circuit's proof of exactly that before it accepted them, and would + * have refused anything else, so there is nothing left to redo here. + * 2. The aggregate is the sum of the ballots everyone can see. The contract + * keeps it itself as ballots arrive; re-adding every `BallotCast` in the + * reader's own browser and landing on the same point shows that nothing + * was left out and nothing added. + * + * The answer is the same for every reader, which is what makes the result + * verifiable rather than merely published. + */ +import { aggregate as addBallots, equals, unflattenPoints, type Ballot } from "./ballotCrypto"; +import { getElection } from "./contracts"; +import { eventArgs, queryLogsFrom } from "./logs"; + +export type TallyAudit = + | { status: "verified"; ballots: number; voters: number } + | { status: "failed"; reason: string; voters: number }; + +export async function auditPublishedTally(address: string): Promise { + const election = getElection(address); + const [onChain, voters, slots] = await Promise.all([ + election.aggregate() as Promise<[bigint[], bigint[]]>, + election.voters() as Promise, + election.numOptions().then((n: bigint) => Number(n) + 1), + ]); + + const events = await queryLogsFrom(election, election.filters.BallotCast()); + const ballots: Ballot[] = events.map(e => { + const args = eventArgs<{ + tag: bigint; + voteA: bigint[]; + voteB: bigint[]; + cancelA: bigint[]; + cancelB: bigint[]; + }>(e); + return { + tag: args.tag, + voteA: [args.voteA[0], args.voteA[1]], + voteB: unflattenPoints(args.voteB), + cancelA: [args.cancelA[0], args.cancelA[1]], + cancelB: unflattenPoints(args.cancelB), + }; + }); + + const recomputed = addBallots(ballots, slots); + const stored = unflattenPoints(onChain[1]); + const matches = + equals(recomputed.a, [onChain[0][0], onChain[0][1]]) && recomputed.b.every((p, i) => equals(p, stored[i])); + + return matches + ? { status: "verified", ballots: ballots.length, voters: Number(voters) } + : { status: "failed", reason: "the ballots on chain do not add up to the aggregate the result was proved against", voters: Number(voters) }; +} diff --git a/frontend/src/lib/tallyKey.test.ts b/frontend/src/lib/tallyKey.test.ts index 803398f..34b02c1 100644 --- a/frontend/src/lib/tallyKey.test.ts +++ b/frontend/src/lib/tallyKey.test.ts @@ -1,46 +1,60 @@ import { describe, it, expect } from 'vitest'; -import { deriveKeysFromSecret } from './tallyKey'; + +import { deriveTallyKeys, isInSubgroup } from './ballotCrypto'; +import { keysMatch, parseTallyKeys, randomTallyKeys, serializeTallyKeys } from './tallyKey'; /** - * Regression vector for the organizer's tally key. - * - * The key is never stored: it is re-derived from the passkey PRF secret every - * time the organizer runs a tally. That makes the derivation a COMPATIBILITY - * SURFACE, not an implementation detail. Any change that yields a different - * keypair for the same (secret, keyNonce) silently destroys the ability to - * decrypt every election created before the change. + * Regression vector for the organizer's tally keys. * - * These values were produced by the implementation as it stood before the prime - * search was optimised. The optimisation only skips candidates that trial - * division proves composite, so the first prime the walk reaches is unchanged - * and this vector still holds. If it ever fails, the derivation moved and old - * elections became undecryptable: that is a breaking change, not a test to fix. + * The keys are never stored: they are re-derived from the organizer's wallet + * every time a tally is run. That makes the derivation a COMPATIBILITY SURFACE, + * not an implementation detail. Any change that yields different keys for the + * same (secret, keyNonce) silently destroys the ability to count every + * election created before the change. If this fails, the derivation moved: + * that is a breaking change, not a test to fix. */ const SECRET = new Uint8Array(32).fill(7); const KEY_NONCE = '0xdeadbeefdeadbeefdeadbeefdeadbeef'; -const EXPECTED_N = '0xc9e24745a8169fee95c36f418afa9892c8301677b7f3090fc3e7934038d9b202f2f0c5245b484868788f5323512856c6fc0c0f0f5b107c86a4f1d042414cb0884c3463bd64fda1d1538a156d9014afca0f7cbc6b5d338246d309594eb60775c9d421439e1eea3d7b7f663bd4e6aaa6fd121297302011c45a8da5ea50b48048b084adfe5be855ab73204dc0e38d088773b25de2b06ef0e92f51ddad6f3ae562899e637ecd657291fd4c5feec3ba548f84670772e898df1f328506c510c6a9410045431e8dd68561d1b9f68d20ec10d8356b2eda4df98617d33decbf43dac5e9f9adb3b97e4fb76a32d5fef36202727696dd00468cda90a677da7824b7d6585f83'; -const EXPECTED_G = '0xc9e24745a8169fee95c36f418afa9892c8301677b7f3090fc3e7934038d9b202f2f0c5245b484868788f5323512856c6fc0c0f0f5b107c86a4f1d042414cb0884c3463bd64fda1d1538a156d9014afca0f7cbc6b5d338246d309594eb60775c9d421439e1eea3d7b7f663bd4e6aaa6fd121297302011c45a8da5ea50b48048b084adfe5be855ab73204dc0e38d088773b25de2b06ef0e92f51ddad6f3ae562899e637ecd657291fd4c5feec3ba548f84670772e898df1f328506c510c6a9410045431e8dd68561d1b9f68d20ec10d8356b2eda4df98617d33decbf43dac5e9f9adb3b97e4fb76a32d5fef36202727696dd00468cda90a677da7824b7d6585f84'; -const EXPECTED_LAMBDA = '0xc9e24745a8169fee95c36f418afa9892c8301677b7f3090fc3e7934038d9b202f2f0c5245b484868788f5323512856c6fc0c0f0f5b107c86a4f1d042414cb0884c3463bd64fda1d1538a156d9014afca0f7cbc6b5d338246d309594eb60775c9d421439e1eea3d7b7f663bd4e6aaa6fd121297302011c45a8da5ea50b48048ae976e38af00c22def75e63eb524edd7f89ca3c972ca3e672082d4032363914b483794e1573aecf39c5bbae118f5d406ab38e929aa8470139b4cd99d0649dc43ce831b5f418561891f091fc0ed8ec915322e6f4d08f1998bdfcaed36fbee85afe63952c8838bc384fa7d839f2856f8e78f5e9d1d37a70e6c92244ae0cacd538d10'; -const EXPECTED_MU = '0x892e092faa6fdddcf0585fa658ace1bd2db903163b0264faf118ad8b9e20a512598d42f8c081a667bd96d50303a0b8481ad71d29d27c61124d9643100acc702cc7045371ce82930ffb185dc3ff9f06945d7183a022648630d6399da7acd3c57bdc037be483246f9fc66c94cb410b38b9d008da9d470b3e8ae0a5334cfcfb9b4d76c4c630627ba17c360c9032b4c2bf7d895e624571f26658de838aacbfe2a0d599d565446115d3bc0137338d3fd5cc7e1636aba48fe7b4a1fc158f315feea595a5700e40561b517aef5a240693e8724bd5aa8387c80e023ffa87e3ce01d8b754cf4e39a8ae1a2f2be06c88b3e74194cb4ab8216cc9c8f7e583f67417dbdd0066'; - describe('tally key derivation', () => { - it('reproduces the pinned keypair for a fixed secret and nonce', async () => { - const keys = await deriveKeysFromSecret(SECRET, KEY_NONCE); - expect(keys.publicKey.n).toBe(EXPECTED_N); - expect(keys.publicKey.g).toBe(EXPECTED_G); - expect(keys.privateKey.lambda).toBe(EXPECTED_LAMBDA); - expect(keys.privateKey.mu).toBe(EXPECTED_MU); - }, 900000); + it('reproduces the pinned keys for a fixed secret and nonce', async () => { + const { secrets } = await deriveTallyKeys(SECRET, KEY_NONCE, 3); + expect(secrets.map(x => x.toString(16))).toMatchInlineSnapshot(` + [ + "1b4142ba27b9babea471ccd7d7421970f1341f1d8154a9022ff6f2b8f4a366", + "4996cf5371c7da1167fa9531323d798ee54670be6c8d413426c4f172435ba7c", + "1023796c805efea65658495e025cf1d19613884bc0c4e3338552c4ace7115a3", + ] + `); + }); + + it('gives keys in the prime subgroup, distinct per slot and per nonce', async () => { + const a = await deriveTallyKeys(SECRET, KEY_NONCE, 4); + const b = await deriveTallyKeys(SECRET, '0x00000000000000000000000000000001', 4); + expect(a.keys.every(isInSubgroup)).toBe(true); + expect(new Set(a.secrets).size).toBe(4); + expect(a.secrets[0]).not.toBe(b.secrets[0]); + }); +}); + +describe('the key file', () => { + it('round-trips, and rebuilds the same public keys from the secrets alone', async () => { + const keys = await randomTallyKeys(3); + const back = parseTallyKeys(serializeTallyKeys(keys)); + expect(back.secrets).toEqual(keys.secrets); + expect(keysMatch(back.keys, keys.keys)).toBe(true); + }); - it('produces a modulus of exactly the configured size', async () => { - const keys = await deriveKeysFromSecret(SECRET, KEY_NONCE); - expect(BigInt(keys.publicKey.n).toString(2).length).toBe(2048); - }, 900000); + it('refuses what is not a key file', () => { + expect(() => parseTallyKeys('not json')).toThrow(/invalid JSON/); + expect(() => parseTallyKeys('{"n":"0x1","g":"0x2"}')).toThrow(/tally key file/); + expect(() => parseTallyKeys('{"version":2,"secrets":[]}')).toThrow(/tally key file/); + }); - it('gives different keys for different nonces', async () => { - const a = await deriveKeysFromSecret(SECRET, KEY_NONCE); - const b = await deriveKeysFromSecret(SECRET, '0x00000000000000000000000000000001'); - expect(a.publicKey.n).not.toBe(b.publicKey.n); - }, 900000); + it('tells keys of another election apart', async () => { + const a = await randomTallyKeys(3); + const b = await randomTallyKeys(3); + expect(keysMatch(a.keys, b.keys)).toBe(false); + expect(keysMatch(a.keys, a.keys.slice(1))).toBe(false); + }); }); diff --git a/frontend/src/lib/tallyKey.ts b/frontend/src/lib/tallyKey.ts index b831565..353a53d 100644 --- a/frontend/src/lib/tallyKey.ts +++ b/frontend/src/lib/tallyKey.ts @@ -1,234 +1,93 @@ /** - * Deterministic Paillier key derivation for the election tally. + * The organizer's tally keys: one ElGamal key per ballot slot, on Baby Jubjub. * - * The organizer's tally key is NOT stored anywhere: it is re-derived on demand - * from a deterministic signature by the organizer's WALLET, so it is available - * on any device they can work from at all and never sits at rest (localStorage - * / backend). The only per-election input is a public `keyNonce` kept in the - * on-chain metadata — it makes each election's key distinct and lets the key be - * re-derived before the election address even exists (the public key is a - * constructor argument). + * NOT STORED ANYWHERE by default: re-derived on demand from a deterministic + * signature by the organizer's WALLET (`organizerKey.ts`), so they are + * available on any device the organizer can work from and never sit at rest. + * The only per-election input is a public `keyNonce` kept in the on-chain + * metadata: it makes each election's keys distinct and lets them be derived + * before the election address exists, since the public keys are a constructor + * argument. * - * THE SOURCE USED TO BE A PASSKEY'S PRF SECRET, and this comment used to say - * so long after it stopped being true. It was changed because the passkey - * failed at the one thing it had to do: Chrome and Firefox on Windows return a - * PRF secret when a credential is created and refuse to evaluate it on an - * assertion, so an organizer could sign in on a second machine and then not - * open their own elections. `organizerKey.ts` has the full account, including - * what moving to the wallet costs. + * A wallet whose signatures are NOT deterministic cannot derive anything + * reproducible, so for it the keys are random and must be kept in a file; see + * `randomTallyKeys` and the key file below. * - * Only the *seeded randomness* is bespoke here: prime testing stays in - * `bigint-crypto-utils` (audited Miller-Rabin) and the key math mirrors - * `paillier-bigint`'s own `generateRandomKeys`, reusing its key classes. + * THE DERIVATION IS A COMPATIBILITY SURFACE. Change it and every election + * created before cannot be counted. `tallyKey.test.ts` pins it. */ -import { isProbablyPrime } from "bigint-crypto-utils"; -import { PublicKey, PrivateKey } from "paillier-bigint"; import type { Signer } from "ethers"; +import { deriveTallyKeys, isInSubgroup, multiply, BASE, type Point } from "./ballotCrypto"; import { organizerMasterSecret } from "./organizerKey"; -import { PAILLIER_KEY_BITS, type SerializedKeyPair } from "./paillier"; -// HKDF info for the per-election key stretch: it labels THIS derivation, and -// is not the salt used anywhere else. Distinct labels are what stop one -// secret's stretch from colliding with another's. -const TALLY_KEY_SALT = "votain:tally-key:v1"; - -// Miller-Rabin rounds. 40 gives a false-prime probability < 2^-80, the usual -// margin for RSA/Paillier-sized primes. -const MR_ROUNDS = 40; - -const toHex = (x: bigint): string => "0x" + x.toString(16); +export interface TallyKeys { + /** One secret per slot in use: the options, then the blank vote. */ + secrets: bigint[]; + /** The public keys the election stores, x·G for each secret. */ + keys: Point[]; +} -/** A fresh, public per-election nonce (hex) — safe to store on-chain. */ +/** A fresh, public per-election nonce (hex), safe to store on chain. */ export function newKeyNonce(): string { const b = new Uint8Array(16); crypto.getRandomValues(b); return "0x" + [...b].map(x => x.toString(16).padStart(2, "0")).join(""); } -// ── Deterministic byte stream: HMAC-SHA256 in counter mode (NIST SP800-108) ── - -async function hmac(key: Uint8Array, data: Uint8Array): Promise { - const k = await crypto.subtle.importKey( - "raw", key as BufferSource, { name: "HMAC", hash: "SHA-256" }, false, ["sign"], - ); - return new Uint8Array(await crypto.subtle.sign("HMAC", k, data as BufferSource)); -} - /** - * An endless, reproducible byte stream keyed by `secret` and labelled by `info`. - * block(i) = HMAC(secret, info ‖ uint32BE(i)); bytes are handed out in order. + * Re-derives an election's tally keys from the organizer's wallet. Null when + * there is no signer to ask, in which case the caller falls back to keys it + * generated at random and stored, since nothing can be re-derived. */ -class SeededStream { - private buf: Uint8Array = new Uint8Array(0); - private counter = 0; - constructor(private readonly secret: Uint8Array, private readonly info: Uint8Array) {} - - private async refill(): Promise { - const ctr = new Uint8Array(4); - new DataView(ctr.buffer).setUint32(0, this.counter++, false); - const block = await hmac(this.secret, concat(this.info, ctr)); - this.buf = concat(this.buf, block); - } - - async take(n: number): Promise { - while (this.buf.length < n) await this.refill(); - const out = this.buf.slice(0, n); - this.buf = this.buf.slice(n); - return out; - } -} - -function concat(a: Uint8Array, b: Uint8Array): Uint8Array { - const out = new Uint8Array(a.length + b.length); - out.set(a); out.set(b, a.length); - return out; -} - -function bytesToBigInt(b: Uint8Array): bigint { - let x = 0n; - for (const byte of b) x = (x << 8n) | BigInt(byte); - return x; +export async function deriveElectionKeys( + keyNonce: string, + slots: number, + signer?: Signer, +): Promise { + if (!signer) return null; + return deriveTallyKeys(await organizerMasterSecret(signer), keyNonce, slots); } -const bitLength = (x: bigint): number => x.toString(2).length; - -/** - * Odd primes below TRIAL_DIVISION_LIMIT, for cheaply discarding composites. - * - * Around one odd number in 355 is prime at this size, so a naive walk spends - * almost all of its time running full Miller-Rabin on numbers a single division - * would have rejected. Dividing by the small primes first removes roughly seven - * candidates in eight before any modular exponentiation happens. - * - * The limit is where the returns flatten: a larger sieve rejects a few percent - * more candidates while costing a proportionally larger division pass on every - * survivor. - */ -const TRIAL_DIVISION_LIMIT = 10_000; - -const SMALL_PRIMES: bigint[] = (() => { - const composite = new Uint8Array(TRIAL_DIVISION_LIMIT + 1); - const primes: bigint[] = []; - for (let i = 3; i <= TRIAL_DIVISION_LIMIT; i += 2) { - if (composite[i]) continue; - primes.push(BigInt(i)); - for (let j = i * i; j <= TRIAL_DIVISION_LIMIT; j += i) composite[j] = 1; - } - return primes; -})(); - -/** True when a small prime divides x, which proves x composite. */ -function hasSmallFactor(x: bigint): boolean { - for (const p of SMALL_PRIMES) { - if (x % p === 0n) return x !== p; - } - return false; +/** Keys from fresh randomness, for a wallet that cannot derive them. Must be kept. */ +export async function randomTallyKeys(slots: number): Promise { + const secret = crypto.getRandomValues(new Uint8Array(32)); + return deriveTallyKeys(secret, "random", slots); } -/** - * Draws a candidate of exactly `bits` bits from the stream (top bit set, odd), - * then walks upward by 2 to the next probable prime. Deterministic given the - * stream. Setting only the top bit, not the second, keeps p·q from spilling - * into an extra bit (the caller redraws q if n is still the wrong size). - * - * THE WALK IS PART OF THE KEY. This function must return the same prime it - * always has: the organizer's tally key is re-derived rather than stored, so a - * different result here makes every earlier election undecryptable. Both filters - * below are therefore chosen to be exact rather than merely likely. Trial - * division only rejects numbers a small prime divides, and the one-round test - * only rejects numbers Miller-Rabin proves composite; neither can skip a value - * the original loop would have accepted. `tallyKey.test.ts` pins the result. - */ -async function nextPrime(stream: SeededStream, bits: number): Promise { - const bytes = await stream.take(Math.ceil(bits / 8)); - let x = bytesToBigInt(bytes) & ((1n << BigInt(bits)) - 1n); - x |= (1n << BigInt(bits - 1)) | 1n; +// ──────────────────────────────────────────────── +// Key file +// ──────────────────────────────────────────────── - for (;;) { - if (!hasSmallFactor(x)) { - // One round first. A single Miller-Rabin witness rejects almost every - // composite that survived the sieve, at a fortieth of the cost, so the - // full MR_ROUNDS margin is only ever paid on a genuine candidate. - if (await isProbablyPrime(x, 1)) { - if (await isProbablyPrime(x, MR_ROUNDS)) return x; - } - } - x += 2n; - } +/** What an exported key file holds: the secrets, which are enough to rebuild the keys. */ +interface KeyFile { + version: 2; + secrets: string[]; } -/** - * Derives a Paillier keypair from a raw secret. Pure and deterministic — same - * (secret, keyNonce) always yields the same keys. Exposed for testing; - * production code should use `deriveElectionKeys`. - */ -export async function deriveKeysFromSecret( - secret: Uint8Array, - keyNonce: string, - bitlength = PAILLIER_KEY_BITS, -): Promise { - const info = new TextEncoder().encode(`${TALLY_KEY_SALT}:${keyNonce}`); - const stream = new SeededStream(secret, info); - - // Mirrors paillier-bigint: p is one bit longer than q, and n must be exactly - // `bitlength` bits — redraw q (deterministically, from the same stream) if not. - const p = await nextPrime(stream, Math.floor(bitlength / 2) + 1); - let q: bigint; - let n: bigint; - do { - q = await nextPrime(stream, Math.floor(bitlength / 2)); - n = p * q; - } while (q === p || bitLength(n) !== bitlength); - - // Simple variant (g = n + 1): a standard, valid Paillier instance. - const g = n + 1n; - const lambda = (p - 1n) * (q - 1n); - const mu = modInverse(lambda, n); - - // Build with the library's own classes to be certain the instance is well-formed. - const publicKey = new PublicKey(n, g); - void new PrivateKey(lambda, mu, publicKey, p, q); - - return { - publicKey: { n: toHex(n), g: toHex(g) }, - privateKey: { lambda: toHex(lambda), mu: toHex(mu) }, - }; +export function serializeTallyKeys(keys: TallyKeys): string { + const file: KeyFile = { version: 2, secrets: keys.secrets.map(x => "0x" + x.toString(16)) }; + return JSON.stringify(file, null, 2); } -/** - * Re-derives the election's tally keypair from the organizer's wallet. Returns - * null when there is no signer to ask, in which case the caller must fall back - * to a random keypair it exports and stores, since nothing can be re-derived. - */ -export async function deriveElectionKeys( - keyNonce: string, - signer?: Signer, -): Promise { - // No wallet, no key. The master is a function of a wallet signature, so a - // caller that cannot sign cannot derive, and returning null is how it learns - // to ask for the exported key file instead. - if (!signer) return null; - - // TWO COSTS, and it is worth knowing which is which. The signature is a round - // trip to the wallet; the derivation that follows is 2048-bit Paillier key - // generation, which searches for two thousand-bit primes in this tab. On a - // phone that is seconds, not milliseconds, and it looks exactly like a hang. - // Anything that bounds a wallet request must not bound this: a deadline over - // the whole call once reported a lost wallet answer for a signature that had - // arrived perfectly and was merely being used slowly. - return deriveKeysFromSecret(await organizerMasterSecret(signer), keyNonce, PAILLIER_KEY_BITS); +/** Reads a key file back. Throws on anything that is not one. */ +export function parseTallyKeys(text: string): TallyKeys { + let file: Partial; + try { + file = JSON.parse(text) as Partial; + } catch { + throw new Error("Not a valid key file (invalid JSON)"); + } + if (file?.version !== 2 || !Array.isArray(file.secrets) || file.secrets.length === 0) { + throw new Error("Not a valid Votain tally key file"); + } + const secrets = file.secrets.map(s => BigInt(s)); + const keys = secrets.map(x => multiply(BASE, x)); + if (!keys.every(isInSubgroup)) throw new Error("Not a valid Votain tally key file"); + return { secrets, keys }; } -/** Modular inverse via the extended Euclidean algorithm (deterministic integer math). */ -function modInverse(a: bigint, m: bigint): bigint { - let [old_r, r] = [((a % m) + m) % m, m]; - let [old_s, s] = [1n, 0n]; - while (r !== 0n) { - const quot = old_r / r; - [old_r, r] = [r, old_r - quot * r]; - [old_s, s] = [s, old_s - quot * s]; - } - if (old_r !== 1n) throw new Error("modInverse: not invertible"); - return ((old_s % m) + m) % m; +/** Whether a key set is the one an election stores, point for point. */ +export function keysMatch(keys: readonly Point[], onChain: readonly Point[]): boolean { + return keys.length === onChain.length && keys.every((k, i) => k[0] === onChain[i][0] && k[1] === onChain[i][1]); } diff --git a/frontend/src/lib/tallyTotal.test.ts b/frontend/src/lib/tallyTotal.test.ts index 2ca2b9b..0edfa35 100644 --- a/frontend/src/lib/tallyTotal.test.ts +++ b/frontend/src/lib/tallyTotal.test.ts @@ -47,9 +47,9 @@ describe('tallyTotal', () => { }); it('adds up to the number of people who voted, which is what the chain enforces', () => { - // `publishResults` is checked against `distinctVoters`, so an honest tally - // and that count are the same number. This is the relationship the two - // screens disagreed about. + // The tally proves how many voted: the counts add up to it by construction, + // and that is what `distinctVoters` is read from once a result is out. This + // is the relationship the two screens disagreed about. const e = election([2, 1, 0], { castVotes: 5, distinctVoters: 3 }); expect(tallyTotal(e)).toBe(e.distinctVoters); }); diff --git a/frontend/src/lib/turnout.ts b/frontend/src/lib/turnout.ts index 6c3e081..1d42577 100644 --- a/frontend/src/lib/turnout.ts +++ b/frontend/src/lib/turnout.ts @@ -20,9 +20,10 @@ type Counted = Pick; /** * People who have voted at least once. * - * `distinctVoters` is read off the contract for every real election. The - * fallback is for seed elections, which carry no such count: there, ballots - * are all there is, capped at the roll so the number stays sayable. + * `distinctVoters` is the tally's own count, known once a result is published. + * Before that nobody can know it, by design: re-votes are indistinguishable + * from first votes. Ballots are then all there is, an UPPER bound on voters, + * capped at the roll so the number stays sayable. */ export function votersOf(election: Counted): number { return election.distinctVoters ?? Math.min(election.castVotes, election.totalEnrolled); @@ -38,6 +39,8 @@ export function replacedBallots(election: Counted): number { * * Still capped, but only for the fallback above: an exact `distinctVoters` * can never exceed `totalEnrolled`, since nobody votes without enrolling. + * Before the tally it is the upper bound `votersOf` gives, and says so no more + * precisely than that. */ export function turnoutPct(election: Counted): number { if (election.totalEnrolled <= 0) return 0; 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/voteCost.test.ts b/frontend/src/lib/voteCost.test.ts index f13b6c6..b553aed 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,63 @@ 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('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); + 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 e8e4dbe..f0e062f 100644 --- a/frontend/src/lib/voteCost.ts +++ b/frontend/src/lib/voteCost.ts @@ -26,19 +26,32 @@ import { getPaymaster, getReadProvider } from "./contracts"; import { eventArgs, queryLogsFrom } from "./logs"; -/** Used only when the chain has no ballot to learn from. */ +/** + * Used only when the chain has no ballot to learn from. About a million gas, + * what the E2E suite measures for a relayed ballot at the default circuit size, + * at Polygon's 30 gwei floor. + */ export const VOTE_COST_FALLBACK = 0.03; /** Selector of `relayVote`, the only relay this is about. */ 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 = { @@ -47,6 +60,37 @@ 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. + * + * 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 measured = cost.bySlots ?? {}; + const sizes = Object.keys(measured).map(Number).sort((a, b) => a - b); + if (!options || sizes.length === 0) return cost.matic; + const circuit = circuitSizeFor(options); + const fits = sizes.find(size => size >= circuit); + if (fits !== undefined) return measured[fits]; + const largest = sizes[sizes.length - 1]; + return measured[largest] * (circuit / largest); +} + /** Most recent relays to look at. Enough to be steady, few enough to be cheap. */ const SAMPLE_SIZE = 12; @@ -76,6 +120,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 { @@ -84,7 +129,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. } @@ -101,15 +151,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/lib/voteHistory.test.ts b/frontend/src/lib/voteHistory.test.ts index 8c795f0..234ad74 100644 --- a/frontend/src/lib/voteHistory.test.ts +++ b/frontend/src/lib/voteHistory.test.ts @@ -1,39 +1,50 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { poseidon4 } from 'poseidon-lite/poseidon4'; + +import { ballotTag } from './ballotCrypto'; /** * What an unlock is worth after it happens. * - * The Semaphore secret is never stored at rest, so before this the passkey had + * The voter's secret is never stored at rest, so before this the passkey had * to be tapped on every reload to see anything cast from another device. A - * ballot's nullifier is public, though, and sits in the event the lookup just - * read: writing it down turns one tap into a permanent answer. + * ballot's tag is public, though, and sits in the event the lookup just read: + * writing the latest one down turns one tap into a permanent answer. * - * The rule these pin is that it records ONLY where a vote exists. Storing the - * nullifier of an election the voter skipped would put a link between them and + * The rule these pin is that it records ONLY where a vote exists. Storing a + * tag for an election the voter skipped would put a link between them and * that election on this disk, and buy nothing for it. * * Driven through `queryLogsFrom`, the boundary where the chain is actually - * read, so the real `fetchVoteReceipts` runs rather than a stand-in for it. + * read, so the real chain walk runs rather than a stand-in for it. */ -const remembered: Array<[string, string]> = []; -/** Election addresses that will report one ballot. */ -const votedIn = new Set(); +const SECRET = 5n; +const SCOPE = 7n; +const tagAt = (k: bigint) => ballotTag(poseidon4, SECRET, SCOPE, k); + +const remembered: Array<[string, bigint]> = []; +/** Election address => the tags its ballots were cast under, in order. */ +const ballotsIn = new Map(); vi.mock('./semaphore', () => ({ - computeNullifier: () => 42n, - getStoredIdentity: () => ({ commitment: 1n }), - getStoredVoteNullifier: () => null, - rememberVote: (address: string, nullifier: bigint) => { - remembered.push([address, nullifier.toString()]); + getStoredIdentity: () => ({ commitment: 1n, secretScalar: SECRET }), + getStoredBallotTag: () => null, + rememberVote: (address: string, tag: bigint) => { + remembered.push([address, tag]); }, })); vi.mock('./contracts', () => ({ getElection: (address: string) => ({ address, - scope: async () => 7n, - filters: { VoteCast: () => ({}) }, + scope: async () => SCOPE, + // An election from before private enrolment: the call REVERTS, which is + // how the chain says "the old doors", and the platform identity votes. + platformAttester: async () => { + throw Object.assign(new Error('execution reverted'), { code: 'CALL_EXCEPTION' }); + }, + filters: { BallotCast: () => ({}) }, }), getReadProvider: () => ({}), })); @@ -43,14 +54,10 @@ vi.mock('./logs', async () => ({ // decode would leave the project's only cast unexercised. ...(await vi.importActual('./logs')), queryLogsFrom: async (election: { address: string }) => - votedIn.has(election.address) - ? [ - { - args: { nullifier: 42n, voteCiphertext: '0x', nonce: 0n, timestamp: 1_700_000_000n }, - transactionHash: '0xtx', - }, - ] - : [], + (ballotsIn.get(election.address) ?? []).map((tag, index) => ({ + args: { tag, index: BigInt(index), leaf: 0n, voteA: [0n, 1n], voteB: [], timestamp: 1_700_000_000n + BigInt(index) }, + transactionHash: `0xtx${index}`, + })), })); vi.mock('./relay', () => ({ @@ -59,16 +66,14 @@ vi.mock('./relay', () => ({ relayVote: async () => ({ txHash: '0x' }), })); -vi.mock('./paillier', () => ({ encryptBallot: () => '0x' })); - beforeEach(() => { remembered.length = 0; - votedIn.clear(); + ballotsIn.clear(); }); describe('what a passkey unlock leaves behind', () => { - it('writes down the nullifier of the ballot it finds', async () => { - votedIn.add('0xvoted'); + it('writes down the tag of the latest ballot it finds', async () => { + ballotsIn.set('0xvoted', [tagAt(0n)]); const { fetchVoteHistory } = await import('./voting'); const entries = await fetchVoteHistory([ @@ -77,10 +82,11 @@ describe('what a passkey unlock leaves behind', () => { ]); expect(entries.map(e => e.electionId)).toEqual(['0xvoted']); - expect(remembered).toEqual([['0xvoted', '42']]); + expect(remembered).toEqual([['0xvoted', tagAt(0n)]]); }); it('writes nothing for an election the voter skipped', async () => { + ballotsIn.set('0xa', [999n]); // somebody else's ballot const { fetchVoteHistory } = await import('./voting'); await fetchVoteHistory([ @@ -91,3 +97,16 @@ describe('what a passkey unlock leaves behind', () => { expect(remembered).toHaveLength(0); }); }); + +describe('a voter reading their own chain', () => { + it("counts their own ballots, whoever else voted in between, and ends at the last", async () => { + ballotsIn.set('0xe', [tagAt(0n), 111n, tagAt(1n), 222n, tagAt(2n)]); + const { fetchVoteHistory } = await import('./voting'); + + const [entry] = await fetchVoteHistory([{ contractAddress: '0xe', title: 'E', phase: 'active' }]); + + expect(entry.voteCount).toBe(3); + expect(entry.referenceNumber).toBe('0xtx4'); + expect(entry.tag).toBe(`0x${tagAt(2n).toString(16)}`); + }); +}); diff --git a/frontend/src/lib/voting.ts b/frontend/src/lib/voting.ts index a284b15..783949b 100644 --- a/frontend/src/lib/voting.ts +++ b/frontend/src/lib/voting.ts @@ -1,13 +1,17 @@ /** * Voter actions: enroll and cast/change vote. * - * Pipeline for a vote: - * 1. Encrypt the ballot with the election's Paillier public key. - * 2. Read the voter's current nullifier nonce (re-vote support). - * 3. Rebuild the Semaphore group from chain events and generate the ZK - * membership proof binding keccak(ciphertext ‖ nonce) as the message. - * 4. Submit castVote through the relayer, so every ballot reaches the chain - * from the same address and the sender reveals nothing (see relay.ts). + * Pipeline for a vote (see `ballot.ts` for the construction): + * 1. Find the voter's place in their own chain of ballots, which only their + * secret can do, and the ballot the new one must cancel. + * 2. Encrypt the choice under the election's tally keys, with a cancellation + * of the previous ballot (of nothing, on a first one), and prove it all in + * zero knowledge. + * 3. Submit through the relayer, so every ballot reaches the chain from the + * same address and the sender reveals nothing (see relay.ts). + * + * 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. */ import { getElection, getReadProvider } from "./contracts"; import { eventArgs, queryLogsFrom } from "./logs"; @@ -21,30 +25,29 @@ import { import type { Identity } from "@semaphore-protocol/identity"; import { identityForElection } from "./electionIdentity"; import { claimAttestation } from "./eligibility"; -import { counterBaseFor, encryptBallot } from "./paillier"; +import { fetchBallots, prepareBallot, voterChain } from "./ballot"; +import { mapWithConcurrency } from "./utils"; import { - computeNullifier, ensureRegistered, - fetchElectionGroup, - generateVoteProof, getOrCreateIdentity, + getStoredBallotTag, getStoredIdentity, - getStoredVoteNullifier, phraseIsUnprotected, rememberVote, } from "./semaphore"; +/** Elections read at once when a screen fans out over all of them. */ +const READ_CONCURRENCY = 6; + export interface VoteResult { txHash: string; - nullifier: bigint; - nonce: bigint; + /** The ballot's tag: public, and linked to no other ballot of this voter's. */ + tag: bigint; + /** Which step of the voter's own chain this ballot is; zero for the first. */ + k: bigint; /** * What the voter is shown and can hand to anyone: the TRANSACTION HASH. - * - * Spelled out because the word "reference" used to name two different values, - * this one and the vote nullifier on `Election`, and the verifier was given - * whichever the caller happened to have. It is the transaction hash - * throughout now; the nullifier is `Election.voteNullifier`. + * The tag is `tag`; both are accepted by the receipt lookup. */ referenceNumber: string; } @@ -68,9 +71,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); @@ -149,101 +156,61 @@ export async function castVote(electionAddress: string, optionIndex: number): Pr // the election enrols privately. const identity = await votingIdentity(electionAddress); - const election = getElection(electionAddress); - const [paillierPk, scope, metadataJson] = await Promise.all([ - election.paillierPublicKey(), - election.scope(), - election.metadataJson(), - ]); - - // 1. Encrypt ballot, in the base THIS election records. Using the current - // constant instead would make every ballot cast after a base change - // unreadable to a tally that correctly follows the election's own. - const ciphertext = encryptBallot(paillierPk, optionIndex, counterBaseFor(metadataJson)); - - // 2. Read the current nonce (re-vote support) from the deterministic nullifier - const nullifier = computeNullifier(identity, BigInt(scope)); - const nonce: bigint = await election.nullifierNonces(nullifier); - - // 3. Membership proof against the on-chain group, bound to ciphertext+nonce - const group = await fetchElectionGroup(electionAddress); - const proof = await generateVoteProof(identity, group, ciphertext, nonce, BigInt(scope)); - - // 4. Relayed submission, carrying no session identifier, so nothing on chain - // ties this ballot to a voter. The issuer still sees the request's network - // origin, which is a metadata link this design does not close (see relay.ts). - const { txHash } = await relayVote({ - election: electionAddress, - voteCiphertext: ciphertext, - nullifier: proof.nullifier, - merkleRoot: proof.merkleTreeRoot, - merkleDepth: proof.merkleTreeDepth, - pA: proof.pA, - pB: proof.pB, - pC: proof.pC, - }); + const prepared = await prepareBallot(electionAddress, identity, optionIndex); + + // Relayed submission, carrying no session identifier, so nothing on chain + // ties this ballot to a voter. The issuer still sees the request's network + // origin, which is a metadata link this design does not close (see relay.ts). + const { txHash } = await relayVote({ election: electionAddress, ballot: prepared.calldata, proof: prepared.proof }); - // Remember this device voted here (public nullifier) so the UI can show - // "already voted" without a passkey prompt later. - rememberVote(electionAddress, proof.nullifier); - - return { - txHash, - nullifier: proof.nullifier, - nonce, - // Full, copyable receipt (the on-chain tx hash). The UI truncates it for - // display but copies the whole value. - referenceNumber: txHash, - }; + // Remember this device voted here (public tag) so the UI can show "already + // voted" without a passkey prompt later. + rememberVote(electionAddress, prepared.ballot.tag); + + return { txHash, tag: prepared.ballot.tag, k: prepared.k, referenceNumber: txHash }; } -/** Looks up the vote history for the locally stored identity in one election. */ -export async function fetchVoteReceipts(electionAddress: string, nullifier: bigint) { +/** One ballot as a receipt shows it. */ +interface BallotReceipt { + tag: bigint; + txHash: string; + timestamp: Date; +} + +/** The ballot filed under one tag, or null. A tag is cast at most once. */ +async function fetchBallotByTag(electionAddress: string, tag: bigint): Promise { const election = getElection(electionAddress); - const events = await queryLogsFrom(election, election.filters.VoteCast(nullifier)); - return events.map(e => { - const args = eventArgs<{ - nullifier: bigint; - voteCiphertext: string; - nonce: bigint; - timestamp: bigint; - }>(e); - return { - nullifier: args.nullifier, - nonce: args.nonce, - timestamp: new Date(Number(args.timestamp) * 1000), - txHash: e.transactionHash, - }; - }); + const [event] = await queryLogsFrom(election, election.filters.BallotCast(tag)); + if (!event) return null; + const args = eventArgs<{ tag: bigint; timestamp: bigint }>(event); + return { tag: args.tag, txHash: event.transactionHash, timestamp: new Date(Number(args.timestamp) * 1000) }; } /** * A vote receipt anyone can look up, holder or not. * - * The point of publishing a nullifier is that a THIRD PARTY can check a receipt - * somebody shows them: an auditor, a journalist, a losing candidate. So this - * takes no identity, no session and no stored secret, only the string on the - * receipt, and it reads the same public events the voter's own history reads. + * The point of a public receipt is that a THIRD PARTY can check one somebody + * shows them: an auditor, a journalist, a losing candidate. So this takes no + * identity, no session and no stored secret, only the string on the receipt, + * and reads the same public events the voter's own history reads. * * What it can confirm is that a ballot was recorded, when, and in which - * election. What it can never confirm is WHAT was voted, which is the same - * limit the voter's own history has and is the property the whole design is - * built to keep. + * election. What it can never confirm is WHAT was voted, nor whether the same + * voter cast other ballots: each ballot's tag is unrelated to the others, which + * is exactly what keeps a re-vote invisible to whoever demanded the first one. */ export interface PublicReceipt { electionId: string; electionTitle: string; phase: string; - /** Full, `0x`-prefixed. This is the value the chain indexed the vote by. */ - nullifier: string; + /** Full, `0x`-prefixed. This is the value the chain indexed the ballot by. */ + tag: string; txHash: string; timestamp: Date; - /** Every ballot this nullifier cast here. A re-vote replaces, so the last wins. */ - voteCount: number; } -/** Reads a query as a nullifier, in either of the two forms a receipt shows it. */ -function parseNullifier(query: string): bigint | null { +/** Reads a query as a tag, in either of the two forms a receipt shows it. */ +function parseTag(query: string): bigint | null { const trimmed = query.trim(); try { if (/^0x[0-9a-fA-F]+$/.test(trimmed)) return BigInt(trimmed); @@ -255,13 +222,13 @@ function parseNullifier(query: string): bigint | null { } /** - * Finds one receipt from a transaction hash or a nullifier. + * Finds one receipt from a transaction hash or a ballot tag. * - * BOTH are tried for a `0x` string of 32 bytes, because a nullifier is a field + * BOTH are tried for a `0x` string of 32 bytes, because a tag is a field * element and prints at exactly the same width as a transaction hash: there is * no way to tell them apart by looking. The transaction lookup goes first * because it is one request and settles the question; only when no such - * transaction exists is the same string tried as a nullifier. + * transaction exists is the same string tried as a tag. */ export async function findVoteReceipt( query: string, @@ -277,27 +244,31 @@ export async function findVoteReceipt( if (found) return found; } - const nullifier = parseNullifier(trimmed); - if (nullifier === null) return null; - - for (const el of elections) { - const receipts = await fetchVoteReceipts(el.contractAddress, nullifier); - if (receipts.length === 0) continue; - const last = receipts[receipts.length - 1]; + const tag = parseTag(trimmed); + if (tag === null) return null; + + // In parallel, then the first match in the order given, so the answer is + // the same as a one-at-a-time loop gives, without waiting on every miss. + const found = await mapWithConcurrency(elections, READ_CONCURRENCY, el => + fetchBallotByTag(el.contractAddress, tag), + ); + for (let i = 0; i < elections.length; i++) { + const ballot = found[i]; + if (!ballot) continue; + const el = elections[i]; return { electionId: el.contractAddress, electionTitle: el.title, phase: el.phase, - nullifier: `0x${nullifier.toString(16)}`, - txHash: last.txHash, - timestamp: last.timestamp, - voteCount: receipts.length, + tag: `0x${tag.toString(16)}`, + txHash: ballot.txHash, + timestamp: ballot.timestamp, }; } return null; } -/** The transaction path: one receipt, read for the VoteCast it emitted. */ +/** The transaction path: one receipt, read for the BallotCast it emitted. */ async function receiptFromTransaction( txHash: string, known: Map, @@ -319,18 +290,16 @@ async function receiptFromTransaction( } catch { continue; } - if (parsed?.name !== "VoteCast") continue; + if (parsed?.name !== "BallotCast") continue; - const nullifier = parsed.args.nullifier as bigint; - const all = await fetchVoteReceipts(election.contractAddress, nullifier); + const tag = parsed.args.tag as bigint; return { electionId: election.contractAddress, electionTitle: election.title, phase: election.phase, - nullifier: `0x${nullifier.toString(16)}`, + tag: `0x${tag.toString(16)}`, txHash: receipt.hash, timestamp: new Date(Number(parsed.args.timestamp) * 1000), - voteCount: all.length || 1, }; } return null; @@ -341,51 +310,53 @@ export interface VoteHistoryEntry { electionTitle: string; phase: string; lastVoteAt: Date; - voteCount: number; + /** + * Ballots this voter cast here, when known. Only the full lookup, which holds + * the secret, can count them: nothing public links one ballot to another. + */ + voteCount?: number; /** The transaction hash of the last ballot. See `VoteResult`. */ referenceNumber: string; - /** The vote nullifier, full and `0x`-prefixed. Both are accepted by the verifier. */ - nullifier: string; + /** The last ballot's tag, full and `0x`-prefixed. Both are accepted by the verifier. */ + tag: string; } /** * The votes THIS BROWSER cast, read without the voting identity. * - * `fetchVoteHistory` re-derives a nullifier per election from the Semaphore - * secret, which in PRF mode is not stored at rest: after a reload it needs a - * passkey tap before it can answer at all. But the nullifier of a ballot is a - * PUBLIC value, sitting in the `VoteCast` event, and `rememberVote` already - * writes each one down at the moment the vote is cast. So for a read there is - * nothing to derive and nothing to unlock. + * `fetchVoteHistory` walks each election's ballots with the voter's secret, + * which in PRF mode is not stored at rest: after a reload it needs a passkey + * tap before it can answer at all. But a ballot's tag is PUBLIC, sitting in the + * `BallotCast` event, and `rememberVote` writes down the latest one at the + * moment the vote is cast. So for a read there is nothing to derive and + * nothing to unlock. * - * What it cannot see is a ballot this browser has never heard of: one cast on - * another device and not yet learned through an unlock. That is the honest - * limit and the reason the full lookup still exists. After an unlock the two - * agree, until the voter uses another device again. - * - * Cheaper as well as promptless: it queries only the elections this browser has - * a record for, rather than every election in the list. + * What it cannot see is a ballot this browser has never heard of, one cast on + * another device and not yet learned through an unlock, nor how many ballots + * came before. That is the honest limit and the reason the full lookup exists. */ export async function fetchLocalVoteHistory( elections: { contractAddress: string; title: string; phase: string }[], ): Promise { + const known = elections.flatMap(el => { + const tag = getStoredBallotTag(el.contractAddress); + return tag === null ? [] : [{ el, tag }]; + }); + const found = await mapWithConcurrency(known, READ_CONCURRENCY, ({ el, tag }) => + fetchBallotByTag(el.contractAddress, tag), + ); const entries: VoteHistoryEntry[] = []; - for (const el of elections) { - const nullifier = getStoredVoteNullifier(el.contractAddress); - if (nullifier === null) continue; - - const receipts = await fetchVoteReceipts(el.contractAddress, nullifier); - if (receipts.length === 0) continue; - - const last = receipts[receipts.length - 1]; + for (let i = 0; i < known.length; i++) { + const ballot = found[i]; + if (!ballot) continue; + const { el, tag } = known[i]; entries.push({ electionId: el.contractAddress, electionTitle: el.title, phase: el.phase, - lastVoteAt: last.timestamp, - voteCount: receipts.length, - referenceNumber: last.txHash, - nullifier: `0x${nullifier.toString(16)}`, + lastVoteAt: ballot.timestamp, + referenceNumber: ballot.txHash, + tag: `0x${tag.toString(16)}`, }); } return entries.sort((a, b) => b.lastVoteAt.getTime() - a.lastVoteAt.getTime()); @@ -397,45 +368,40 @@ export async function fetchLocalVoteHistory( * only prove *that* and *when* you voted, never *what* you chose. */ export async function fetchVoteHistory( - elections: { contractAddress: string; title: string; phase: string; scope?: bigint }[], + elections: { contractAddress: string; title: string; phase: string }[], ): Promise { const master = getStoredIdentity(); if (!master) return []; - const entries: VoteHistoryEntry[] = []; - for (const el of elections) { - const election = getElection(el.contractAddress); - const scope: bigint = el.scope ?? BigInt(await election.scope()); + const looked = await mapWithConcurrency(elections, READ_CONCURRENCY, async el => { + const scope: bigint = await getElection(el.contractAddress).scope(); // Per election, because that is what the ballot was cast with wherever the - // election enrols privately. Computing this from the platform identity - // found nothing at all there, which reads as "you never voted". + // election enrols privately. Walking with the platform identity 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); - if (receipts.length === 0) continue; + const { mine } = voterChain(identity.secretScalar, scope, await fetchBallots(el.contractAddress)); + return { el, mine }; + }); + + const entries: VoteHistoryEntry[] = []; + for (const { el, mine } of looked) { + const last = mine.at(-1); + if (!last) continue; // Learned once, so this browser stops needing the passkey to answer the - // same question again. The nullifier is public, sitting in the event just - // read, and this device already records the ones for its own ballots; what - // is new is that a ballot cast elsewhere becomes visible here afterwards. - // - // Only where a vote actually exists. Writing the nullifier of an election - // the voter skipped would put a link between them and that election on this - // disk, and buy nothing for it. - rememberVote(el.contractAddress, nullifier); - - const last = receipts[receipts.length - 1]; + // same question again. Only where a vote actually exists: writing a tag for + // an election the voter skipped would put a link between them and that + // election on this disk, and buy nothing for it. + rememberVote(el.contractAddress, last.tag); + entries.push({ electionId: el.contractAddress, electionTitle: el.title, phase: el.phase, lastVoteAt: last.timestamp, - voteCount: receipts.length, + voteCount: mine.length, referenceNumber: last.txHash, - // Full, not the first 16 hex characters it used to carry: a truncated - // nullifier cannot be looked up, so exporting or copying one gave the - // voter a string that verifies nothing. - nullifier: `0x${nullifier.toString(16)}`, + tag: `0x${last.tag.toString(16)}`, }); } return entries.sort((a, b) => b.lastVoteAt.getTime() - a.lastVoteAt.getTime()); diff --git a/frontend/src/lib/wordlist.ts b/frontend/src/lib/wordlist.ts new file mode 100644 index 0000000..f4ce128 --- /dev/null +++ b/frontend/src/lib/wordlist.ts @@ -0,0 +1,123 @@ +/** + * The words a recovery phrase is built from: the EFF Short Wordlist 1. + * + * Designed by the Electronic Frontier Foundation for exactly this job: words a + * person reads off a screen and types back. Every word is three to five letters, + * common, easy to spell, and free of homophones and offensive entries. The one + * entry with a hyphen, "yo-yo", is left out, so a phrase is always plain + * letters separated by spaces. 1295 words. + * + * Source: https://www.eff.org/dice (EFF Short Wordlist 1, CC BY 3.0 US), taken + * from the @wordlist/english-eff package. Changing this list changes which + * phrases are valid, so it is never edited in place. + */ +export const EFF_SHORT_WORDS: readonly string[] = [ + "acid", "acorn", "acre", "acts", "afar", "affix", "aged", "agent", "agile", "aging", "agony", "ahead", + "aide", "aids", "aim", "ajar", "alarm", "alias", "alibi", "alien", "alike", "alive", "aloe", "aloft", + "aloha", "alone", "amend", "amino", "ample", "amuse", "angel", "anger", "angle", "ankle", "apple", "april", + "apron", "aqua", "area", "arena", "argue", "arise", "armed", "armor", "army", "aroma", "array", "arson", + "art", "ashen", "ashes", "atlas", "atom", "attic", "audio", "avert", "avoid", "awake", "award", "awoke", + "axis", "bacon", "badge", "bagel", "baggy", "baked", "baker", "balmy", "banjo", "barge", "barn", "bash", + "basil", "bask", "batch", "bath", "baton", "bats", "blade", "blank", "blast", "blaze", "bleak", "blend", + "bless", "blimp", "blink", "bloat", "blob", "blog", "blot", "blunt", "blurt", "blush", "boast", "boat", + "body", "boil", "bok", "bolt", "boned", "boney", "bonus", "bony", "book", "booth", "boots", "boss", + "botch", "both", "boxer", "breed", "bribe", "brick", "bride", "brim", "bring", "brink", "brisk", "broad", + "broil", "broke", "brook", "broom", "brush", "buck", "bud", "buggy", "bulge", "bulk", "bully", "bunch", + "bunny", "bunt", "bush", "bust", "busy", "buzz", "cable", "cache", "cadet", "cage", "cake", "calm", + "cameo", "canal", "candy", "cane", "canon", "cape", "card", "cargo", "carol", "carry", "carve", "case", + "cash", "cause", "cedar", "chain", "chair", "chant", "chaos", "charm", "chase", "cheek", "cheer", "chef", + "chess", "chest", "chew", "chief", "chili", "chill", "chip", "chomp", "chop", "chow", "chuck", "chump", + "chunk", "churn", "chute", "cider", "cinch", "city", "civic", "civil", "clad", "claim", "clamp", "clap", + "clash", "clasp", "class", "claw", "clay", "clean", "clear", "cleat", "cleft", "clerk", "click", "cling", + "clink", "clip", "cloak", "clock", "clone", "cloth", "cloud", "clump", "coach", "coast", "coat", "cod", + "coil", "coke", "cola", "cold", "colt", "coma", "come", "comic", "comma", "cone", "cope", "copy", + "coral", "cork", "cost", "cot", "couch", "cough", "cover", "cozy", "craft", "cramp", "crane", "crank", + "crate", "crave", "crawl", "crazy", "creme", "crepe", "crept", "crib", "cried", "crisp", "crook", "crop", + "cross", "crowd", "crown", "crumb", "crush", "crust", "cub", "cult", "cupid", "cure", "curl", "curry", + "curse", "curve", "curvy", "cushy", "cut", "cycle", "dab", "dad", "daily", "dairy", "daisy", "dance", + "dandy", "darn", "dart", "dash", "data", "date", "dawn", "deaf", "deal", "dean", "debit", "debt", + "debug", "decaf", "decal", "decay", "deck", "decor", "decoy", "deed", "delay", "denim", "dense", "dent", + "depth", "derby", "desk", "dial", "diary", "dice", "dig", "dill", "dime", "dimly", "diner", "dingy", + "disco", "dish", "disk", "ditch", "ditzy", "dizzy", "dock", "dodge", "doing", "doll", "dome", "donor", + "donut", "dose", "dot", "dove", "down", "dowry", "doze", "drab", "drama", "drank", "draw", "dress", + "dried", "drift", "drill", "drive", "drone", "droop", "drove", "drown", "drum", "dry", "duck", "duct", + "dude", "dug", "duke", "duo", "dusk", "dust", "duty", "dwarf", "dwell", "eagle", "early", "earth", + "easel", "east", "eaten", "eats", "ebay", "ebony", "ebook", "echo", "edge", "eel", "eject", "elbow", + "elder", "elf", "elk", "elm", "elope", "elude", "elves", "email", "emit", "empty", "emu", "enter", + "entry", "envoy", "equal", "erase", "error", "erupt", "essay", "etch", "evade", "even", "evict", "evil", + "evoke", "exact", "exit", "fable", "faced", "fact", "fade", "fall", "false", "fancy", "fang", "fax", + "feast", "feed", "femur", "fence", "fend", "ferry", "fetal", "fetch", "fever", "fiber", "fifth", "fifty", + "film", "filth", "final", "finch", "fit", "five", "flag", "flaky", "flame", "flap", "flask", "fled", + "flick", "fling", "flint", "flip", "flirt", "float", "flock", "flop", "floss", "flyer", "foam", "foe", + "fog", "foil", "folic", "folk", "food", "fool", "found", "fox", "foyer", "frail", "frame", "fray", + "fresh", "fried", "frill", "frisk", "from", "front", "frost", "froth", "frown", "froze", "fruit", "gag", + "gains", "gala", "game", "gap", "gas", "gave", "gear", "gecko", "geek", "gem", "genre", "gift", + "gig", "gills", "given", "giver", "glad", "glass", "glide", "gloss", "glove", "glow", "glue", "goal", + "going", "golf", "gong", "good", "gooey", "goofy", "gore", "gown", "grab", "grain", "grant", "grape", + "graph", "grasp", "grass", "grave", "gravy", "gray", "green", "greet", "grew", "grid", "grief", "grill", + "grip", "grit", "groom", "grope", "growl", "grub", "grunt", "guide", "gulf", "gulp", "gummy", "guru", + "gush", "gut", "guy", "habit", "half", "halo", "halt", "happy", "harm", "hash", "hasty", "hatch", + "hate", "haven", "hazel", "hazy", "heap", "heat", "heave", "hedge", "hefty", "help", "herbs", "hers", + "hub", "hug", "hula", "hull", "human", "humid", "hump", "hung", "hunk", "hunt", "hurry", "hurt", + "hush", "hut", "ice", "icing", "icon", "icy", "igloo", "image", "ion", "iron", "islam", "issue", + "item", "ivory", "ivy", "jab", "jam", "jaws", "jazz", "jeep", "jelly", "jet", "jiffy", "job", + "jog", "jolly", "jolt", "jot", "joy", "judge", "juice", "juicy", "july", "jumbo", "jump", "junky", + "juror", "jury", "keep", "keg", "kept", "kick", "kilt", "king", "kite", "kitty", "kiwi", "knee", + "knelt", "koala", "kung", "ladle", "lady", "lair", "lake", "lance", "land", "lapel", "large", "lash", + "lasso", "last", "latch", "late", "lazy", "left", "legal", "lemon", "lend", "lens", "lent", "level", + "lever", "lid", "life", "lift", "lilac", "lily", "limb", "limes", "line", "lint", "lion", "lip", + "list", "lived", "liver", "lunar", "lunch", "lung", "lurch", "lure", "lurk", "lying", "lyric", "mace", + "maker", "malt", "mama", "mango", "manor", "many", "map", "march", "mardi", "marry", "mash", "match", + "mate", "math", "moan", "mocha", "moist", "mold", "mom", "moody", "mop", "morse", "most", "motor", + "motto", "mount", "mouse", "mousy", "mouth", "move", "movie", "mower", "mud", "mug", "mulch", "mule", + "mull", "mumbo", "mummy", "mural", "muse", "music", "musky", "mute", "nacho", "nag", "nail", "name", + "nanny", "nap", "navy", "near", "neat", "neon", "nerd", "nest", "net", "next", "niece", "ninth", + "nutty", "oak", "oasis", "oat", "ocean", "oil", "old", "olive", "omen", "onion", "only", "ooze", + "opal", "open", "opera", "opt", "otter", "ouch", "ounce", "outer", "oval", "oven", "owl", "ozone", + "pace", "pagan", "pager", "palm", "panda", "panic", "pants", "panty", "paper", "park", "party", "pasta", + "patch", "path", "patio", "payer", "pecan", "penny", "pep", "perch", "perky", "perm", "pest", "petal", + "petri", "petty", "photo", "plank", "plant", "plaza", "plead", "plot", "plow", "pluck", "plug", "plus", + "poach", "pod", "poem", "poet", "pogo", "point", "poise", "poker", "polar", "polio", "polka", "polo", + "pond", "pony", "poppy", "pork", "poser", "pouch", "pound", "pout", "power", "prank", "press", "print", + "prior", "prism", "prize", "probe", "prong", "proof", "props", "prude", "prune", "pry", "pug", "pull", + "pulp", "pulse", "puma", "punch", "punk", "pupil", "puppy", "purr", "purse", "push", "putt", "quack", + "quake", "query", "quiet", "quill", "quilt", "quit", "quota", "quote", "rabid", "race", "rack", "radar", + "radio", "raft", "rage", "raid", "rail", "rake", "rally", "ramp", "ranch", "range", "rank", "rant", + "rash", "raven", "reach", "react", "ream", "rebel", "recap", "relax", "relay", "relic", "remix", "repay", + "repel", "reply", "rerun", "reset", "rhyme", "rice", "rich", "ride", "rigid", "rigor", "rinse", "riot", + "ripen", "rise", "risk", "ritzy", "rival", "river", "roast", "robe", "robin", "rock", "rogue", "roman", + "romp", "rope", "rover", "royal", "ruby", "rug", "ruin", "rule", "runny", "rush", "rust", "rut", + "sadly", "sage", "said", "saint", "salad", "salon", "salsa", "salt", "same", "sandy", "santa", "satin", + "sauna", "saved", "savor", "sax", "say", "scale", "scam", "scan", "scare", "scarf", "scary", "scoff", + "scold", "scoop", "scoot", "scope", "score", "scorn", "scout", "scowl", "scrap", "scrub", "scuba", "scuff", + "sect", "sedan", "self", "send", "sepia", "serve", "set", "seven", "shack", "shade", "shady", "shaft", + "shaky", "sham", "shape", "share", "sharp", "shed", "sheep", "sheet", "shelf", "shell", "shine", "shiny", + "ship", "shirt", "shock", "shop", "shore", "shout", "shove", "shown", "showy", "shred", "shrug", "shun", + "shush", "shut", "shy", "sift", "silk", "silly", "silo", "sip", "siren", "sixth", "size", "skate", + "skew", "skid", "skier", "skies", "skip", "skirt", "skit", "sky", "slab", "slack", "slain", "slam", + "slang", "slash", "slate", "slaw", "sled", "sleek", "sleep", "sleet", "slept", "slice", "slick", "slimy", + "sling", "slip", "slit", "slob", "slot", "slug", "slum", "slurp", "slush", "small", "smash", "smell", + "smile", "smirk", "smog", "snack", "snap", "snare", "snarl", "sneak", "sneer", "sniff", "snore", "snort", + "snout", "snowy", "snub", "snuff", "speak", "speed", "spend", "spent", "spew", "spied", "spill", "spiny", + "spoil", "spoke", "spoof", "spool", "spoon", "sport", "spot", "spout", "spray", "spree", "spur", "squad", + "squat", "squid", "stack", "staff", "stage", "stain", "stall", "stamp", "stand", "stank", "stark", "start", + "stash", "state", "stays", "steam", "steep", "stem", "step", "stew", "stick", "sting", "stir", "stock", + "stole", "stomp", "stony", "stood", "stool", "stoop", "stop", "storm", "stout", "stove", "straw", "stray", + "strut", "stuck", "stud", "stuff", "stump", "stung", "stunt", "suds", "sugar", "sulk", "surf", "sushi", + "swab", "swan", "swarm", "sway", "swear", "sweat", "sweep", "swell", "swept", "swim", "swing", "swipe", + "swirl", "swoop", "swore", "syrup", "tacky", "taco", "tag", "take", "tall", "talon", "tamer", "tank", + "taper", "taps", "tarot", "tart", "task", "taste", "tasty", "taunt", "thank", "thaw", "theft", "theme", + "thigh", "thing", "think", "thong", "thorn", "those", "throb", "thud", "thumb", "thump", "thus", "tiara", + "tidal", "tidy", "tiger", "tile", "tilt", "tint", "tiny", "trace", "track", "trade", "train", "trait", + "trap", "trash", "tray", "treat", "tree", "trek", "trend", "trial", "tribe", "trick", "trio", "trout", + "truce", "truck", "trump", "trunk", "try", "tug", "tulip", "tummy", "turf", "tusk", "tutor", "tutu", + "tux", "tweak", "tweet", "twice", "twine", "twins", "twirl", "twist", "uncle", "uncut", "undo", "unify", + "union", "unit", "untie", "upon", "upper", "urban", "used", "user", "usher", "utter", "value", "vapor", + "vegan", "venue", "verse", "vest", "veto", "vice", "video", "view", "viral", "virus", "visa", "visor", + "vixen", "vocal", "voice", "void", "volt", "voter", "vowel", "wad", "wafer", "wager", "wages", "wagon", + "wake", "walk", "wand", "wasp", "watch", "water", "wavy", "wheat", "whiff", "whole", "whoop", "wick", + "widen", "widow", "width", "wife", "wifi", "wilt", "wimp", "wind", "wing", "wink", "wipe", "wired", + "wiry", "wise", "wish", "wispy", "wok", "wolf", "womb", "wool", "woozy", "word", "work", "worry", + "wound", "woven", "wrath", "wreck", "wrist", "xerox", "yahoo", "yam", "yard", "year", "yeast", "yelp", + "yield", "yodel", "yoga", "yoyo", "yummy", "zebra", "zero", "zesty", "zippy", "zone", "zoom", +]; 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/ElectionPage.tsx b/frontend/src/pages/ElectionPage.tsx index 6adb795..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 ( @@ -367,8 +372,8 @@ export default function ElectionPage() { // Copies the FULL reference, never the shortened form on screen. const copyReference = async () => { - if (!election?.voteNullifier) return; - await navigator.clipboard.writeText(election.voteNullifier); + if (!election?.ballotTag) return; + await navigator.clipboard.writeText(election.ballotTag); setReferenceCopied(true); setTimeout(() => setReferenceCopied(false), 2000); }; @@ -570,11 +575,14 @@ 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')}:{' '} - {shortenReference(election.voteNullifier ?? '')} + {t('verify_receipt.nullifier')}:{' '} + {shortenReference(election.ballotTag ?? '')}

- {/* 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. */}
@@ -686,7 +716,12 @@ export default function ElectionManagement() {
- {!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. ) : ( )} 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); diff --git a/frontend/src/pages/public/Discover.tsx b/frontend/src/pages/public/Discover.tsx index 98d9673..67f9c86 100644 --- a/frontend/src/pages/public/Discover.tsx +++ b/frontend/src/pages/public/Discover.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useRef } from 'react'; +import { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Compass } from 'lucide-react'; import { PageLayout } from '../../components/layout/PageLayout'; @@ -90,13 +90,20 @@ export default function Discover() { * the list it is about to become is already being read. * * Written during render on purpose. An effect would set it one render late, - * which is exactly the render the flash happens in. + * which is exactly the render the flash happens in. State set during render + * is React's own pattern for "information from previous renders": it + * re-renders before committing, so nothing flashes, and unlike a ref it is + * never read stale. Compared item by item rather than by reference: the + * memo above can hand back a new array holding the same elections, and a + * reference check would then set state on every render, forever. */ - const lastDrawn = useRef([]); - if (filtered.length > 0) lastDrawn.current = filtered; + const [lastDrawn, setLastDrawn] = useState([]); + const sameAsDrawn = + filtered.length === lastDrawn.length && filtered.every((e, i) => e === lastDrawn[i]); + if (filtered.length > 0 && !sameAsDrawn) setLastDrawn(filtered); /** A reorder settling over a grid that already has something in it. */ - const settling = loading && lastDrawn.current.length > 0; - const shown = filtered.length > 0 ? filtered : lastDrawn.current; + const settling = loading && lastDrawn.length > 0; + const shown = filtered.length > 0 ? filtered : lastDrawn; /** * Whether the number below is a total or a running tally. diff --git a/frontend/src/pages/public/ElectionResults.tsx b/frontend/src/pages/public/ElectionResults.tsx index 9f4b117..1a8b4ca 100644 --- a/frontend/src/pages/public/ElectionResults.tsx +++ b/frontend/src/pages/public/ElectionResults.tsx @@ -5,6 +5,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 { Card } from '../../components/ui/Card'; import { ResultBarChart } from '../../components/ui/BarChart'; import { TallyCheck } from '../../components/ui/TallyCheck'; @@ -18,7 +19,7 @@ export default function ElectionResults() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { t } = useTranslation(); - const { election, loading } = useElection(id); + const { election, loading, unreachable, refresh } = useElection(id); if (loading) { return ( @@ -28,6 +29,10 @@ export default function ElectionResults() { ); } + if (!election && unreachable) { + return void refresh()} onBack={() => navigate(-1)} />; + } + if (!election || !election.candidates.some(c => c.votes !== undefined)) { return ( 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'] }, ]; 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/shared/SavedElections.test.tsx b/frontend/src/pages/shared/SavedElections.test.tsx index 6c9a82e..12eee37 100644 --- a/frontend/src/pages/shared/SavedElections.test.tsx +++ b/frontend/src/pages/shared/SavedElections.test.tsx @@ -129,7 +129,7 @@ describe('the saved list', () => { // see the filter that decides whether the row survives at all. This asks // the filter itself, which is the half that actually fixes the bug. const pedirFiltro = () => - (paginas.mock.calls.at(-1)?.[0] as { keep: (e: { id: string }) => boolean }).keep; + (paginas.mock.calls.at(-1)![0] as { keep: (e: { id: string }) => boolean }).keep; guardadas.mockReturnValue({ ids: ['0xabc'], isSaved: () => true, toggle: vi.fn() }); paginas.mockReturnValue({ all: [UNA], loading: false, error: null, refresh: vi.fn() }); diff --git a/frontend/src/pages/voter/ChangeVote.tsx b/frontend/src/pages/voter/ChangeVote.tsx index 7f771a3..a1f5693 100644 --- a/frontend/src/pages/voter/ChangeVote.tsx +++ b/frontend/src/pages/voter/ChangeVote.tsx @@ -6,6 +6,7 @@ import { PageLayout } from '../../components/layout/PageLayout'; import { Card } from '../../components/ui/Card'; import { Button } from '../../components/ui/Button'; import { BackButton } from '../../components/ui/BackButton'; +import { ElectionUnreadable } from '../../components/ui/ElectionUnreadable'; import { RadioGroup } from '../../components/ui/RadioCard'; import { Modal } from '../../components/ui/Modal'; import { Spinner } from '../../components/ui/Spinner'; @@ -15,7 +16,7 @@ export default function ChangeVote() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { t } = useTranslation(); - const { election, loading, live } = useElection(id); + const { election, loading, unreachable, live, refresh } = useElection(id); const [selected, setSelected] = useState(election?.userVote ?? ''); const [confirmOpen, setConfirmOpen] = useState(false); @@ -28,6 +29,10 @@ export default function ChangeVote() { ); } + if (!election && unreachable) { + return void refresh()} onBack={() => navigate(-1)} />; + } + if (!election) return null; // Reachable by its own URL, so the guard cannot live only on the button that @@ -50,7 +55,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/Onboarding.tsx b/frontend/src/pages/voter/Onboarding.tsx index 08a4739..7942e90 100644 --- a/frontend/src/pages/voter/Onboarding.tsx +++ b/frontend/src/pages/voter/Onboarding.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect, type ReactNode } from 'react'; +import { useState, useMemo, type ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { motion, AnimatePresence } from 'framer-motion'; @@ -73,9 +73,14 @@ export default function Onboarding() { * Moving the counter rather than deriving around it keeps the dots, the * heading and the panel all saying the same thing. */ - useEffect(() => { - if (isVerifying || isLoadingQr) setStep(VERIFY_STEP); - }, [isVerifying, isLoadingQr]); + // On the transition into verifying, set during render so the verify slide is + // the first thing drawn rather than one frame after slide one. + const verifyingNow = isVerifying || isLoadingQr; + const [wasVerifying, setWasVerifying] = useState(verifyingNow); + if (verifyingNow !== wasVerifying) { + setWasVerifying(verifyingNow); + if (verifyingNow) setStep(VERIFY_STEP); + } const isVerifyStepActive = step === VERIFY_STEP; const infoStepData = !isVerifyStepActive ? infoSteps[step] : null; diff --git a/frontend/src/pages/voter/VoteConfirmation.tsx b/frontend/src/pages/voter/VoteConfirmation.tsx index 52d2137..3e88db6 100644 --- a/frontend/src/pages/voter/VoteConfirmation.tsx +++ b/frontend/src/pages/voter/VoteConfirmation.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { useNavigate, useParams, useLocation } from 'react-router-dom'; +import { Navigate, useNavigate, useParams, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; import { ShieldCheck, ExternalLink, Copy, Check, SearchCheck } from 'lucide-react'; @@ -10,8 +10,6 @@ import { getElection as getSeedElection } from '../../data/seed'; import { shortenReference } from '../../lib/utils'; import { explorerTxUrl } from '../../lib/deployments'; -const FALLBACK_REF = 'VTN-2025-' + String(Math.floor(Math.random() * 90000) + 10000); - interface ConfirmationState { referenceNumber?: string; txHash?: string; @@ -28,9 +26,17 @@ export default function VoteConfirmation() { const seedElection = id && !id.startsWith('0x') ? getSeedElection(id) : undefined; const title = seedElection?.title; - const reference = state.referenceNumber ?? FALLBACK_REF; + /** + * NEVER INVENTED. This screen used to fall back to a random "VTN-2025-12345" + * whenever it had no reference, which is every reload: a voting app showing a + * receipt number for a ballot it cannot vouch for. Now a real vote shows its + * transaction hash, the demo data says plainly that nothing was recorded, and + * a reload goes back to the election, where the vote can be checked for real. + */ + const reference = state.referenceNumber ?? null; + const isDemo = Boolean(seedElection); // Copy the FULL reference; only shorten it for display. - const referenceDisplay = shortenReference(reference); + const referenceDisplay = reference ? shortenReference(reference) : ''; const txHash = state.txHash; // Null on a chain with no explorer, and then the button is not rendered at // all. It used to fall back to the Amoy homepage, so a voter on any other @@ -46,11 +52,16 @@ export default function VoteConfirmation() { }, []); const handleCopy = async () => { + if (!reference) return; await navigator.clipboard.writeText(reference); setCopied(true); setTimeout(() => setCopied(false), 1500); }; + if (!reference && !isDemo) { + return ; + } + return (
@@ -74,13 +85,17 @@ export default function VoteConfirmation() { {/* Reference number */}

{t('confirmation.reference')}

-
- {referenceDisplay} - -
+ {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 && ( + + )} 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/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/, '') || '/', }, } diff --git a/scripts-tally/README.md b/scripts-tally/README.md new file mode 100644 index 0000000..16fbc9b --- /dev/null +++ b/scripts-tally/README.md @@ -0,0 +1,52 @@ +# scripts-tally/ + +The off-chain tally, and anyone's check of a published one. The organizer can +also tally in the app; this is the same computation for an offline machine, and +the only path that pins the result JSON to IPFS. + +```bash +npm install +cp .env.example .env # RPC_URL, and for tallying TALLY_KEY_FILE, ORGANIZER_PRIVATE_KEY +npm run tally -- [--publish] [--pin] # the organizer, holding the keys +npm run tally -- --verify # anyone, no key +``` + +It proves with the same tally circuit the browser uses (`CIRCUITS_DIR`, by +default the frontend's copy) and imports the browser's own +`frontend/src/lib/ballotCrypto.ts`, so the two cannot disagree on the +arithmetic. + +## What each mode does + +```mermaid +flowchart TB + chain[("ElectionV4 on chain
aggregate, BallotCast events,
published result")] + + subgraph tallymode["tally (organizer)"] + read["read the aggregate:
each voter's last vote, encrypted"] + decrypt["decrypt with the tally keys
(exported from the app)"] + quorum{"voters at or above
the privacy quorum?"} + prove["prove the decryption
tally circuit, publish mode"] + provevoid["prove only 'below quorum'
counts never revealed"] + json["audit JSON, optionally
pinned on IPFS (Pinata)"] + end + + subgraph verifymode["--verify (anyone)"] + readall["re-add every BallotCast"] + same{"sum equals the aggregate
the result was proved against?"} + reverify["re-check the published proof
against the tally verification key"] + end + + chain --> read --> decrypt --> quorum + quorum -- "yes" --> prove --> json -- "publishResults" --> chain + quorum -- "no" --> provevoid -- "voidBelowQuorum" --> chain + chain --> readall --> same + same -- "yes" --> reverify + same -- "no" --> fail["exit non-zero"] + reverify -- "fails" --> fail + reverify -- "holds" --> ok["verified: counts and voter total"] +``` + +The contract already refuses a result whose proof does not verify; `--verify` +repeats the check independently of the on-chain verifier, from the chain data +alone. diff --git a/scripts-tally/package-lock.json b/scripts-tally/package-lock.json index 9e0d7fa..2c1012c 100644 --- a/scripts-tally/package-lock.json +++ b/scripts-tally/package-lock.json @@ -9,14 +9,19 @@ "version": "1.0.0", "license": "AGPL-3.0-only", "dependencies": { - "dotenv": "^18.0.2", + "dotenv": "^18.0.4", "ethers": "^6.17.0", - "paillier-bigint": "^3.4.3" + "poseidon-lite": "^0.3.0", + "snarkjs": "^0.7.6" }, "devDependencies": { - "@types/node": "^26.6.2", + "@types/node": "^26.6.3", "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", @@ -492,9 +513,9 @@ } }, "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": { @@ -847,19 +868,71 @@ "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": { - "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" @@ -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 2196045..3df4b9c 100644 --- a/scripts-tally/package.json +++ b/scripts-tally/package.json @@ -1,20 +1,25 @@ { "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": { + "node": ">=24", + "npm": ">=11" + }, "scripts": { "tally": "tsx tally-votes.ts", "typecheck": "tsc --noEmit" }, "dependencies": { - "dotenv": "^18.0.2", + "dotenv": "^18.0.4", "ethers": "^6.17.0", - "paillier-bigint": "^3.4.3" + "poseidon-lite": "^0.3.0", + "snarkjs": "^0.7.6" }, "devDependencies": { - "@types/node": "^26.6.2", + "@types/node": "^26.6.3", "tsx": "^4.23.15", "typescript": "^7.0.2" } diff --git a/scripts-tally/tally-votes.ts b/scripts-tally/tally-votes.ts index c3e68e2..5418d68 100644 --- a/scripts-tally/tally-votes.ts +++ b/scripts-tally/tally-votes.ts @@ -1,74 +1,83 @@ /** - * Votain off-chain tally. + * Votain off-chain tally, and the auditor's check of a published one. * - * Pipeline: - * 1. Read every VoteCast event from an election. - * 2. Coercion resistance: keep only the HIGHEST nonce per nullifier. - * 3. Homomorphically sum the surviving Paillier ciphertexts. - * 4. Decrypt the aggregate with the organizer's private key and unpack the - * per-option counters (base-B packing, blank vote = last option). - * 5. Apply the privacy quorum, determine the outcome per voting type. - * 6. Write an auditable JSON, optionally pin it to IPFS (Pinata) and publish - * the results on-chain 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): + * 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] + * npm run tally -- --verify * * Env (.env): * RPC_URL Amoy RPC - * PAILLIER_KEY_FILE 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"; - -/** - * 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 publishResults(string ipfsCid, uint256[] tallyResults)", - "event VoteCast(uint256 indexed nullifier, bytes voteCiphertext, uint256 nonce, uint256 timestamp)", + "function privacyQuorum() view returns (uint256)", + "function resultsPublished() view returns (bool)", + "function tally() view returns (uint256[])", + "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, @@ -81,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) @@ -122,19 +143,19 @@ async function pinToIpfs(json: object): Promise { const MAX_LOG_RANGE = 10_000; /** - * Reads every VoteCast event for an election. + * Reads every event matching `filter` for an election. * * Starts from FROM_BLOCK when set (the deployment block, printed by * contracts/scripts/deploy.ts into deployments/amoy.json), otherwise from * genesis. Retries in fixed windows when the endpoint rejects the range, so the * audit works on any provider rather than only on the unlimited ones. */ -async function queryAllVoteCast( +async function queryAll( election: Contract, provider: JsonRpcProvider, + filter: Parameters[0], ): Promise>> { const from = Number(process.env.FROM_BLOCK ?? 0); - const filter = election.filters.VoteCast(); try { return await election.queryFilter(filter, from); @@ -159,113 +180,195 @@ async function queryAllVoteCast( } } +/** 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. */ +async function verifyPublished(address: string, election: Contract, provider: JsonRpcProvider) { + if (!(await election.resultsPublished())) { + console.error("No results have been published for this election yet."); + process.exit(2); + } + const state = await readElection(election); + const [published, voters] = await Promise.all([ + election.tally() as Promise, + election.voters() as Promise, + ]); + + // 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 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. The ${ballots.length} ballots on chain add up to the aggregate, and the ` + + `published counts [${published.join(", ")}] are proved to be its decryption: ${voters} voter${voters === 1n ? "" : "s"}.`, + ); +} + +/** 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"); const doPin = process.argv.includes("--pin"); + 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) { + 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 [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. All votes. An auditor must see every ballot or the tally is wrong, so a - // truncated log range is never acceptable here. - const events = await queryAllVoteCast(election, provider); - console.log(`Found ${events.length} VoteCast events`); - - // 2. Coercion resistance: highest nonce per nullifier wins - const latest = new Map(); - for (const e of events) { - const args = (e as unknown as { args: { nullifier: bigint; voteCiphertext: string; nonce: bigint } }).args; - 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()]; - console.log(`${finalVotes.length} unique voters after coercion-resistance dedup`); + const state = await readElection(election); - // Read once: the quorum below and the counter base further down both come - // from it, and two round trips could disagree if the chain moved between them. - const metadataJson: string = await election.metadataJson(); - - // Privacy quorum (metadata): never reveal a tally computed from too few voters - let privacyQuorum = 0; - try { - const meta = JSON.parse(metadataJson) as { privacyQuorum?: number }; - privacyQuorum = meta.privacyQuorum ?? 0; - } catch { /* no metadata */ } - - if (finalVotes.length < privacyQuorum) { - console.error(`Privacy quorum not met (${finalVotes.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); - // 3. Homomorphic sum - const { publicKey, privateKey } = restoreKeyPair( - JSON.parse(readFileSync(process.env.PAILLIER_KEY_FILE ?? "paillier-key.json", "utf-8")) as SerializedKeyPair, - ); - // Sanity: the key file must match the on-chain public key - if ("0x" + publicKey.n.toString(16) !== JSON.parse(pkJson).n) { - console.warn("WARNING: key file public key does not match the on-chain Paillier 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; + }; - let counts: bigint[]; - if (finalVotes.length === 0) { - counts = new Array(totalSlots).fill(0n); - } else { - const aggregate = finalVotes.map(v => BigInt(v.ciphertext)).reduce((acc, c) => publicKey.addition(acc, c)); - // 4. Decrypt + unpack, in the base this election recorded - const base = counterBaseOf(metadataJson); - let remaining = privateKey.decrypt(aggregate); - counts = []; - for (let i = 0; i < totalSlots; i++) { - counts.push(remaining % base); - remaining /= base; - } - if (remaining !== 0n) throw new Error("tally overflow: a counter exceeded the counter base"); - - // Every ballot adds one to exactly one counter, so the counters have to - // account for all of them. This is the check that sees a carry BETWEEN - // counters; the leftover above only sees one past the last. - const declared = counts.reduce((a, c) => a + c, 0n); - if (declared !== BigInt(finalVotes.length)) { - throw new Error( - `tally does not match the ballots counted: unpacked ${declared}, expected ${finalVotes.length}`, - ); + 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: events.length, - uniqueVoters: finalVotes.length, + ballotsCast: state.ballots, + voters: Number(voters), tally: counts.map(c => Number(c)), + proof: { a: proof.a.map(String), b: proof.b.map(p => p.map(String)), c: proof.c.map(String) }, outcome, winnerIndex, }; @@ -275,24 +378,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[]) => Promise<{ wait: () => Promise<{ hash: string }> }>; - }; - const tx = await writer.publishResults(cid, counts); - 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"] }