Skip to content

Final review before the Amoy deploy: hidden re-votes, a tally proved on chain, security and quality - #1

Merged
CodeInIA merged 33 commits into
devfrom
final-revision-before-amoy-deploy
Sep 28, 2026
Merged

CodeInIA merged 33 commits into
devfrom
final-revision-before-amoy-deploy

Conversation

@CodeInIA

@CodeInIA CodeInIA commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

Resumen

Revisión completa del proyecto (contratos, backend, frontend, CLI de recuento y CI) antes de desplegar en Polygon Amoy, corrección de todo lo encontrado que tenía arreglo en código y, sobre eso, re-votos invisibles: nadie que observe la cadena puede saber que un votante ha cambiado su voto. Lo más importante:

  • Re-votos invisibles. Las papeletas pasan de Paillier a ElGamal exponencial sobre Baby Jubjub con pruebas de conocimiento cero (dos circuitos circom nuevos). Una primera papeleta y un re-voto son idénticos en la cadena y no comparten ningún valor público.
  • El recuento se verifica en el propio contrato. La elección suma todas las papeletas ella misma y solo acepta un resultado con una prueba Groth16 de que es el descifrado de esa suma. Ya no hay forma de publicar un recuento falso.
  • Se cierran cuatro agujeros de seguridad graves: suplantación de organizadores, identidades Sybil en World ID, varias inscripciones con un mismo pasaporte, y un votante capaz de vaciar el depósito de gas.

⚠️ Cambian la ABI de los contratos, el formato de las papeletas y la derivación de la frase de recuperación. Hace falta un despliegue nuevo y una ceremonia de trusted setup (ver Despliegue en Amoy). Nada de esto afecta a Amoy, donde todavía no hay nada desplegado.

Verificación (checkout limpio del último commit, como en CI):

  • Circuitos: 8/8 tests de testigos, y compilación + setup de los dos circuitos en los dos tamaños.
  • Contratos: 222/222, incluida la suite E2E con pruebas Groth16 reales contra los verificadores generados (antes 185).
  • Backend: 160/160 (antes 144).
  • Frontend: 597/597.
  • Playwright: 75 e2e (escritorio, tablet y móvil).
  • oxlint, typechecks y build de producción en limpio.

Además, contra un nodo Hardhat local:

  • El seed de demo creó las 38 elecciones con ~50 papeletas probadas de verdad, re-votos incluidos, y publicó las cerradas con pruebas de recuento.
  • tally --verify verifica todas las publicadas desde la cadena, sin clave: vuelve a sumar las papeletas y vuelve a comprobar la prueba contra la clave de verificación.
  • El CLI descifró, probó y publicó la elección que quedaba en recuento.
  • En Chromium real (Vite + Playwright), un votante sembrado re-votó desde el navegador: encontró su papeleta anterior, generó la prueba en ~10 s y el contrato la aceptó. Una papeleta de más dentro del límite de ritmo se rechaza antes de probar, diciendo a qué hora se abre la siguiente.

🕶️ Re-votos invisibles

Antes: VoteCast publicaba el nullifier del votante y un nonce, así que quien conociera el nullifier veía que había re-votado. A un coaccionador le basta con eso.

Ahora (circuits/src/ballot.circom, ElectionV4.castVote):

  • Papeleta = voto cifrado + cancelación de la papeleta anterior del votante, re-aleatorizada (de nada, si es la primera):
    • voto A = r·G, B_i = v_i·G + r·H_i, una clave por opción;
    • cancelación A' = −A_prev + s·G, B'_i = −B_prev_i + s·H_i.
  • La prueba demuestra, sin revelar quién ni qué papeleta cancela:
    • que el votante está en el censo (identidad Semaphore, árbol de miembros);
    • que el voto es exactamente una opción válida (se acabó el relleno de urna);
    • que la cancelación deshace su propia papeleta anterior, probado por pertenencia al árbol de papeletas, no señalándola;
    • que el tag = Poseidon(TAG, secreto, scope, k) es suyo, con k privado: cada paso de la cadena del votante se emite una vez y los pasos no se pueden enlazar.
  • El contrato suma votos y cancelaciones en un agregado (coordenadas extendidas de Baby Jubjub, BabyJubJub.sol). La suma se telescopa al último voto de cada votante: no hay que deduplicar nada.
  • Límite de ritmo sin enlazar nada: un epochTag por votante y epoch de una hora sustituye al enfriamiento por nullifier del paymaster, que era en sí mismo un vínculo público entre las papeletas de un votante. La prueba puede nombrar el epoch actual o el anterior, cada tag una vez: un reemplazo inmediato y después uno por hora. La app elige al azar entre los dos cuando ambos están libres, para que nombrar el anterior no delate un re-voto.
  • El scope de los tags sale de la dirección del propio contrato, no del organizador: dos elecciones nunca comparten tags.
  • Recuento (circuits/src/tally.circom): prueba que los contadores son el descifrado del agregado del contrato con sus claves. publishResults(cid, counts, proof) lo verifica on-chain.
  • Por debajo del quórum, voidBelowQuorum(voters, proof) prueba solo que votaron menos que el quórum y anula sin revelar nunca los contadores.
  • Tamaños de circuito: 5 y 9 slots por defecto (hasta 50 opciones con build:all). La factory elige el par de verificadores más pequeño que cabe.
  • ElectionDeployer guarda el bytecode de creación de ElectionV4, que ya no cabía dentro de la factory por el límite de 24 KB.

Lo que ve cada uno:

Quién Qué puede saber
Cualquiera que mire la cadena, o un coaccionador con el recibo Que hay N papeletas. No que una sea un re-voto, ni cuáles son del mismo votante
El organizador (tiene las claves) La app solo descifra la suma, pero sus claves podrían abrir una papeleta suelta: su opción y, por la cancelación, si sustituyó a otra y de qué opción. Nunca de quién es ni a cuál sustituyó. Ver propuesta de descifrado por umbral
Quien tenga el secreto del votante Todo, como antes: es tener su identidad

Coste medido por la suite E2E: ~0,99M gas por papeleta relayada con 5 slots y ~1,3M con 9. El tope por relay del paymaster sube a 5M (techo de 6M), porque se cobra el gas medido y un tope por debajo haría pagar la diferencia al relayer.

🔴 Seguridad

Problema Consecuencia Corrección
ElectionV4 heredaba ERC2771Context y deploy.ts usaba la dirección del deployer como forwarder si faltaba TRUSTED_FORWARDER El deployer podía suplantar a cualquier organizador (publicar resultados falsos, cancelar, anular) ERC2771Context eliminado por completo: nada lo usaba
/worldid/request firmaba con DEVELOPER_KEY la action que elegía el cliente Cada action da un nullifier distinto: una persona obtenía identidades ilimitadas (Sybil) El servidor fija la action (worldIdAction()) y verifyWorldIdProof rechaza pruebas de otra
La inscripción privada descartaba el nullifier del documento de Self En elecciones DOCUMENT/ORB, un pasaporte con N cuentas de World ID daba N inscripciones Nuevo documentTag en enrollPrivate, derivado del documento con la clave de época y deduplicado on-chain
relayVote sin permisos, re-votos ilimitados y papeletas de tamaño arbitrario Un solo votante podía vaciar el depósito del organizador, y además cobrando él el reembolso como relayer Papeletas probadas y de tamaño fijo, y un límite de ritmo con epochTag: un reemplazo inmediato y después uno por hora. El voto coaccionado se puede sobrescribir, salvo dos votos forzados seguidos en la última hora antes del cierre (documentado como límite conocido)
El owner del paymaster podía cambiar la factory y los parámetros del relay sin límite Posible robo de los saldos de los organizadores Factory fijable una sola vez, parámetros acotados y reserveFromBalanceFor limitado a las elecciones del propio organizador
markVoided sin restricciones Un organizador que prometió no cancelar podía descifrar en privado y anular si perdía Con cancellable == false, solo sin prueba si hay menos papeletas que el quórum; si no, solo con voidBelowQuorum y su prueba
La credencial SD-JWT afirmaba ageOver18: true, país y región a todo el mundo Una credencial firmada que asegura hechos no verificados Eliminado. La edad y la nacionalidad se prueban por elección con el documento
Credenciales de primer inicio con statusListIndex: "0" Irrevocables durante 7 días verifySession comprueba la revocación por el hueco del humano
Escrituras al registro pagadas por la plataforma sin cuota por usuario (vault sin límites de tamaño ni número) Un usuario podía vaciar la wallet del registrar Cuotas por votante (no por IP) y topes en el contrato y en la API

✅ Recuento verificable

  • Cada papeleta se prueba bien formada antes de entrar: una papeleta rellenada o basura no llega a la cadena.
  • El contrato mantiene el agregado él mismo y solo publica un resultado con la prueba del circuito de recuento.
  • Cualquiera lo comprueba sin clave, contra la cadena:
    • TallyCheck en la pantalla de resultados vuelve a sumar todas las BallotCast y comprueba que dan el agregado que se probó;
    • npm run tally -- <elección> --verify hace lo mismo y además vuelve a verificar la prueba publicada contra la clave de verificación del circuito, independientemente del verificador on-chain.
  • Sustituye a la prueba de descifrado de Paillier (tallyProof.ts) de la primera fase de esta PR, que dejaba publicar un recuento y luego contradecirlo; ahora no se puede publicar.

🟡 Bugs

  • VoteConfirmation: mostraba un número de referencia inventado (VTN-2025-xxxxx) al recargar la página. Ahora nunca inventa uno.
  • voting.ts: un fallo de RPC dejaba cacheada la elección como de inscripción pública, y se votaba con la identidad equivocada.
  • electionIdentity.ts: la caché se indexaba solo por elección. Tras una recuperación seguía devolviendo identidades de la identidad maestra anterior.
  • Quórum: se lee del contrato, no del JSON de metadatos.
  • registrar.ts: daba por registrado un commitment que pertenecía a otro votante.
  • Sesión de Self: ya no se consume antes de que el relay tenga éxito.
  • Fronteras de tiempo: voteEnd pasa a ser exclusivo en todas partes, igual que en phase().
  • Seed de demo: derivaba los tags con v1 y el backend con v2. Ahora coinciden.
  • Comentario de ElectionPaymaster: citaba una función depositFor que no existe.
  • Frase de recuperación: derivación con estiramiento PBKDF2‑SHA256 de 600k rondas, y lista de palabras EFF de 1.296 palabras (124 bits en 12 palabras).
  • Wallets MPC o smart wallets: firman de forma no determinista, así que la clave de recuento derivada de la firma era irrecuperable. Ahora se detecta y se usa una clave aleatoria exportable.

Encontrados en esta fase, al ejecutar todo el sistema

  • Relay local de relayEnrollPrivate sin documentTag. El cambio de documentTag no llegó a la ABI escrita a mano del relay local: en la cadena local la inscripción privada fallaba. Nuevo test de paridad de ABI (AbiParity.test.ts) que comprueba cada fragmento escrito a mano del frontend, backend y CLI contra los artefactos compilados. Lo encontré leyendo el código; el test está comprobado contra la versión con el fallo, que ahora no pasaría.
  • Raíces que caducaban al ser reemplazadas. La validez de una raíz antigua se contaba desde que se creó, no desde que se reemplazó. En una elección tranquila, una prueba hecha contra la raíz actual caducaba en cuanto otro votaba primero. Ahora se cuenta como en Semaphore, con test de regresión.
  • La regla de supermayoría del CLI no coincidía con la del contrato para más de dos opciones.
  • Dependencias transitivas: poseidon-solidity y @zk-kit/lean-imt.sol llegaban solo a través de @semaphore-protocol/contracts. Ahora son directas.
  • Afirmaciones de privacidad en la página de privacidad (13 idiomas): decían que ni el organizador podía ver un voto individual. No era cierto ni con Paillier. Corregido con precisión.
  • Tests de circuitos que se saltaban solos si no había build, y en CI iban antes del build: habrían pasado sin probar nada. Ahora compilan primero y fallan en vez de saltarse.
  • CI: la fase 1 pública de Hermez ya responde 403; el runner la generaba él mismo (38 min) y el paso agotaba su tiempo sin cachear nada. Ahora se genera en un paso propio y se cachea por separado en cuanto existe.
  • El CLI se quedaba colgado tras --verify, el seed no dejaba tiempo para probar papeletas reales, y un segundo voto en la misma hora llegaba al votante como texto en inglés.

⚙️ Escalabilidad

  • Backend, transacciones: un único proveedor de RPC y una cola serializada por clave (chain/signer.ts), con NonceManager. Se acabaron las colisiones de nonce con peticiones concurrentes.
  • Lista de revocación: lecturas en paralelo acotado, deduplicación de peticiones simultáneas y sin pérdida silenciosa de índices por encima de 131.072.
  • Frontend: getElections paginado, e historial y recibos en paralelo con un límite de concurrencia.
  • Estado en memoria: límites de sesiones por votante y por IP, para que un atacante no pueda expulsar a los demás.
  • Capacidad por elección: 2^20 miembros y 2^20 papeletas (profundidad de los circuitos).

🧹 Calidad y herramientas

  • Código compartido: ballotCrypto.ts, sin dependencias, lo usan el navegador, el CLI, los tests y el seed. contracts/scripts/lib/prover.ts prueba desde Node exactamente como el navegador. Una sola función de relay, TwoStepOwnable compartido y un helper de errores.
  • CI: nuevo job circuits (tests de testigos + build cacheado) que alimenta al job de contratos. La fase 1 de desarrollo (2^17, ~40 min en un runner) se genera una vez y se cachea aparte, guardada en cuanto existe. REQUIRE_CIRCUITS=1 hace que un build ausente falle en vez de saltarse la E2E.
  • Linter: oxlint en frontend y backend. typescript-eslint no soporta TypeScript 7. Se corrigieron 19 errores reales.
  • Seguridad del servidor: cabeceras de seguridad, manejador de errores en JSON, límite de tamaño del body, errores de ethers reducidos al nombre del revert, y validación de forma de cada papeleta relayada antes de simularla.
  • Versiones fijadas: Node 24 y npm 11 (engines y .nvmrc).
  • Playwright: test:e2e estaba roto porque faltaba @playwright/test. El smoke test ya es autónomo y se ejecuta en CI.
  • Dependencias eliminadas: paillier-bigint, @semaphore-protocol/proof y @semaphore-protocol/contracts.
  • Documentación: README, arquitectura, glosario y guías de módulo describen el nuevo esquema y sus límites, con diagramas Mermaid.

🔧 Cambios posteriores (27-28 sep)

  • Re-voto inmediato: la app usa también el epoch anterior (chooseEpoch), elegido al azar cuando ambos están libres y descartado en los últimos 2 minutos antes del cambio de hora.
  • deploy.ts: las comprobaciones de ceremonia, attester y circuitos se hacen antes de la primera transacción, así que un despliegue rechazado en Amoy ya no deja contratos pagados y huérfanos.
  • Circuitos en Windows: build.mjs usa un circom nativo si lo hay (o CIRCOM), porque circom2 (WASM) no compila estos circuitos en Windows.
  • Coste por papeleta según el tamaño de circuito de cada elección: antes una elección de 6 opciones (circuito de 9 slots) prometía ~50 % más papeletas de las que su reserva podía pagar. Un tamaño sin medir se estima por lo alto.
  • Lecturas del RPC: timeout y un reintento, y una pantalla propia de "no se puede conectar" en lugar de "página no encontrada". El proxy local ya no reutiliza conexiones cerradas por Hardhat.
  • UI e i18n: acentos perdidos en cinco idiomas, plurales, roles de diálogo accesibles, el tag de la papeleta ya no se etiqueta como "referencia", y un mensaje claro con la hora cuando se alcanza el límite de ritmo.
  • Dependencias actualizadas a su última versión (hardhat-toolbox-mocha-ethers 4, vite 8.3, vitest 5, oxlint 1.86, snarkjs 0.7.6…), y Poseidon importado por aridad: el chunk de vendor pasa de 581 kB a 61 kB.
  • Textos legales y de privacidad alineados con lo que hacen los contratos (reserva por elección, límite de ritmo real).

🚀 Despliegue en Amoy

  1. Ceremonia: cd circuits && PTAU=<fase 1 pública de al menos 2^17> CEREMONY_ENTROPY=<secreto> npm run build. La fase 1 debe venir de una ceremonia con muchos participantes (Hermez o la perpetual powers of tau de PSE); la URL histórica de Hermez en Google Storage ya responde 403, así que hay que obtenerla de un espejo y comprobar su hash. Mejor aún, una contribución multiparte en la fase 2. Descartar la entropía después.
  2. Contratos: npm run deploy:amoy. Despliega los verificadores generados y se niega a usar la ceremonia de desarrollo fuera de la cadena local, salvo ALLOW_INSECURE_CEREMONY=1 para una prueba desechable.
  3. Ficheros de prueba: publicar frontend/public/circuits/ (IPFS, por ejemplo) y poner su URL en VITE_CIRCUITS_URL. Son ~50 MB por zkey, así que no van al repositorio. Un fichero equivocado solo produce pruebas que la cadena rechaza.

💡 Propuestas

Descifrado individual (el organizador podría abrir una papeleta suelta)

  • Recomendada: descifrado por umbral (t-de-n). Con ElGamal, que ya está hecho, el obstáculo de la primitiva ha desaparecido:
    • una DKG tipo Pedersen/Feldman entre trustees (organizador, plataforma en su TEE, observadores o una junta electoral declarada) genera cada clave H_i = Σ_j x_ij·G sin que nadie tenga la clave completa;
    • al cerrar, cada trustee publica su descifrado parcial x_ij·A del agregado del contrato con una prueba de igualdad de logaritmos (Chaum-Pedersen) o un circuito pequeño;
    • el contrato combina t parciales y el circuito de recuento cambia x_i por la suma de parciales. Ninguna coalición de menos de t puede abrir papeletas sueltas.
  • Alternativa inmediata: claves custodiadas en el TEE. Las x_i se generan y se sellan en el enclave (Phala), que solo descifra y prueba el agregado cuando phase() indique cierre, comprobado por atestación. Es más simple, pero concentra la confianza en el hardware.
  • Ya mitigado: una papeleta abierta no dice de quién es ni a qué papeleta sustituyó, y nada público enlaza las papeletas de un votante.

Indexador

  • Recomendado: un subgraph (The Graph) o un indexador ligero propio (Ponder, o el backend con SQLite/Postgres):
    • indexa ElectionCreated, MemberEnrolled, BallotCast, ResultsPublished y VoidedBelowQuorum;
    • sirve listas, contadores, historial y recibos (por tag) en una sola consulta;
    • puede servir también las rutas de Merkle de los árboles de miembros y papeletas, que hoy el navegador reconstruye leyendo todos los eventos.
  • El navegador sigue pudiendo leer directamente de la cadena como ruta de auditoría, y la prueba de cada papeleta falla si el indexador mintiera sobre una ruta. El indexador es una optimización, nunca una fuente de confianza.
  • Multicall3 para hidratar elecciones en un solo eth_call.

Estado compartido del backend

  • Redis (o el KV de la plataforma) detrás de una pequeña interfaz Store, para:
    • rate limits (rate-limit-redis);
    • sesiones de elegibilidad y peticiones de World ID en curso, con TTL nativo;
    • cachés de revocación.
  • Transacciones: la cola por clave pasa a una cola distribuida (BullMQ) con un único worker por clave de firma, que conserva el orden de nonces entre instancias.
  • TEE: el estado sensible (asociación sesión ↔ elección) se cifra en reposo con una clave sellada al enclave.
  • Hasta entonces: requisito explícito de una sola instancia, documentado en Known limits.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi


Generated by Claude Code

Paillier lets the key holder recover a ciphertext's randomness, so a tally
can be opened: the randomness that opens the sum of the valid final ballots
to exactly the published counters, and a public opening of each ballot
excluded as invalid. Dependency free, so the browser, the auditor's CLI and
the contract tests share one definition of a valid tally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi
- ElectionV4 no longer inherits ERC2771Context: the deploy script fell back
  to the deployer as trusted forwarder, which could speak as any organizer.
- Ballots capped at 512 bytes; voteEnd is exclusive everywhere.
- publishResults takes the tally proof and the excluded-ballot count, checks
  the counters account for distinctVoters, and emits TallyProofPublished.
- markVoided is refused on a non-cancellable election whose result is
  publishable: voiding it would be the veto it promised not to have.
- enrollPrivate carries a documentTag on gated elections, so one passport
  behind several World ID accounts enrols once.
- ElectionPaymaster: factory set once, relay params bounded, even the factory
  limited to an organizer's own elections, sponsored re-votes spaced by a
  one-hour cooldown so no voter can drain a tank.
- Shared TwoStepOwnable; PlatformRegistry caps vault entries and sizes.
- Seed and E2E publish real proofs; seed tag derivation matches the backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi
…ed writes

- The World ID action is fixed by the server and checked on the proof: a
  client-chosen action minted a new nullifier, and identity, per action.
- Private enrolment signs a document tag and consumes the passport check only
  once the enrolment has landed.
- Credentials no longer assert an unverified country, region or age, and
  first-time sessions are revocable through the human's slot.
- Per-voter quotas and size limits on every platform-paid registry write.
- One provider and one serialized signer per key (no nonce collisions); one
  generic relay path; errors reduced to the revert name for clients.
- Revocation list read in parallel, deduplicated, never truncated.
- Security headers, JSON error handler, body limit, oxlint, noUnused*.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi
- Results are checked in the reader's browser against the chain (TallyCheck),
  the organizer's tally publishes its proof, and the quorum is read from the
  contract.
- The vote confirmation never shows a made-up reference number.
- Per-election identities are cached per master identity, and a failed RPC
  no longer caches an election as publicly enrolling.
- Recovery phrase derivation is stretched (PBKDF2, 600k rounds).
- Wallets that sign non-deterministically get an exportable random tally key
  instead of one that could never be derived again.
- Paginated election list and bounded concurrency for history and receipts.
- oxlint; render-loop-safe state; Playwright smoke test works standalone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi
Twelve words from 128 carried 84 bits. The EFF Short Wordlist 1 keeps the
same design goals (short, common, easy to type, no homophones) with 1295
words, so twelve now carry about 124 bits, drawn by rejection sampling so no
word is likelier than another. Also runs the checks on pull requests into dev.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi
The move to the EFF list left three suites holding phrases from the old one,
which identityFromPhrase now rejects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi
…d verifiers

The ballot circuit proves a ballot is exactly one option, encrypted with
exponential ElGamal on Baby Jubjub, and that its cancellation undoes the
voter's OWN previous ballot, found by membership in the ballots tree rather
than by pointing at it. Tags per chain step and per epoch are derived from
the voter's secret, so nothing public links two ballots of one voter.

The tally circuit proves the counts are the decryption of the election's
aggregate, or, in its other mode, only that fewer voted than the quorum.

build.mjs compiles both per size, runs the setup (a public development
ceremony unless CEREMONY_ENTROPY is set; PTAU takes a public phase 1),
caches, and writes the Solidity verifiers and the browser's proving files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi
ElectionV4 takes ballots with a Groth16 proof, files each under an
unlinkable tag in a ballots tree, keeps the running ElGamal aggregate of
votes and cancellations, and publishes a result only with the tally
circuit's proof of its decryption. voidBelowQuorum ends a small election
without revealing its counts. Epoch tags replace the per-nullifier re-vote
cooldown, which was itself a public link between a voter's ballots. The tag
scope derives from the contract's own address.

Root validity is timed from when a root was superseded, as Semaphore does:
timed from its creation, a root current for over an hour expired the moment
another ballot landed, stranding in-flight proofs in quiet elections.

ElectionDeployer holds the creation code, which no longer fits in the
factory; the factory picks the smallest verifier pair per option count.
deploy.ts deploys the generated verifiers and refuses a development
ceremony off the local chain. The E2E suite proves for real, and a new ABI
parity test checks every hand-written fragment in the frontend, backend and
CLI against the artifacts, which found relayEnrollPrivate's local-relay
signature still missing documentTag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi
The browser walks the voter's own chain of tags, builds the next ballot
with a cancellation of the last one and proves it with snarkjs; the
organizer decrypts the on-chain aggregate with wallet-derived keys and
proves the tally; readers re-add every BallotCast to check the aggregate.
Receipts and history go by ballot tag. The relay validates the new ballot
shape before simulating. The CLI proves and publishes (or voids below the
quorum) and --verify re-checks the published proof against the verifying
key; its outcome rules now match the contract's.

Fixes the local relay's relayEnrollPrivate ABI, which lacked documentTag.
The privacy texts no longer claim the organizer cannot read a single
ballot: the app only decrypts totals, but the key could open one ballot,
never whose it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi
CI gains a circuits job (witness tests, cached build seeded from the Hermez
phase 1) whose outputs the contracts job needs, with REQUIRE_CIRCUITS so a
missing build fails rather than skips. README, architecture, glossary and
the module guides describe the new scheme and its limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi
…zed epoch refusal

Found by running the whole stack against a local node: the seed's finished
elections gave 90 seconds of voting to ballots that now take seconds each to
prove; the CLI answered --verify and then hung on snarkjs's worker threads;
and a second ballot within the hour, refused in the browser before proving,
reached the voter as an English error string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi
The suite skipped itself when no build existed, and CI runs it before the
build: it would have passed on every push while testing nothing. npm test
now compiles the smallest size first (build.mjs --compile, seconds, no
setup), and a missing build is an error rather than a skip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi
@CodeInIA CodeInIA changed the title Revisión final antes del despliegue en Amoy: seguridad, recuento verificable, escalabilidad y calidad Revisión final antes del despliegue en Amoy: re-votos invisibles, recuento probado on-chain, seguridad y calidad Sep 24, 2026
claude and others added 13 commits September 24, 2026 16:57
The Hermez phase 1 now answers 403, so the runner generated its own 2^17
phase 1: 38 minutes, and the 40-minute build step timed out right after it,
saving nothing. build.mjs --ptau <power> generates only that file; CI caches
it under its own key, saved as soon as it exists, so the cost is paid once
and later runs go straight to the minutes-long phase 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JD6VzyiKH5aMXbSnv7zNXi
The development-ceremony check ran after Poseidon, the registry, the domains
and the paymaster were already deployed, and the missing-attester check after
the verifiers too. On Amoy a refused deployment had paid for up to five
contracts and left them orphaned. Both checks, and the missing-circuits one,
now run before anything is sent.

Checked against a local node posing as chain 80002: with the development
ceremony it refuses at nonce 0; with ALLOW_INSECURE_CEREMONY=1 the whole
non-local path deploys, Poseidon through the CREATE2 proxy included.
circom2, the WebAssembly build, cannot compile these circuits on Windows:
its WASI filesystem does not see two includes of circomlib's poseidon as
one file ("Duplicated callable symbol") and then cannot write its output.
build.mjs also spawned its .bin shim, which is a .cmd there and fails with
ENOENT before any of that.

It now runs a native circom when there is one (CIRCOM, or circom on the
PATH) and circom2 through node otherwise, and on Windows without a native
compiler it says what to install instead of failing obscurely. The native
v2.2.3 release, the version circom2 embeds, builds wasm byte-identical to
CI's; the witness tests pass 8/8 on Windows with it.
"Ballots paid for" divided a reserve by one median across every relayed
ballot, and most are five-slot ones. A six-option election proves with the
nine-slot circuit, about 1.24M gas against 0.8M, so it was promised 124
ballots its reserve could pay about 79 of.

The cost is now measured per circuit size, read from each relayed ballot's
own calldata, and every screen about one election uses the size it proves
with. A size nobody has voted in yet is scaled up from the largest measured
one, never down.
… end to end

Found driving the whole flow in Chromium against a local chain:

- A dropped RPC read showed "page not found" for an election that exists,
  and one the RPC never answered left the page on a spinner for good. Reads
  now time out, a failed one is retried once, and a connection failure gets
  its own screen with a retry on the election, results, management and
  change-vote pages. ChangeVote rendered nothing at all.
- A second ballot inside the hour said "Something went wrong" without saying
  when the next one is allowed. It now says the latest ballot already counts
  and gives the time.
- The election page labelled the ballot tag "Reference", the name every other
  screen gives the transaction hash: two different numbers under one name.
- The timeline and sort labels had lost their accents in five languages, and
  German said "payout" (Auszahlung) where it meant the count (Auszaehlung).
- "1 voters" on the verified tally: the count is pluralised now.
- Modals had no dialog role and the date picker's hour and minute fields,
  like the modal's close button, were labelled in English only.
- The smoke test visited /voter/election/:id, which no longer exists and
  passed on the 404 page.
…ections

Found running the smoke suite against a configured local chain:

- The dev proxy reused a socket the Hardhat node had just closed as idle,
  and the read came back as ECONNRESET: an election page failed or waited
  for good about one load in three. The /rpc proxy no longer keeps
  connections alive. The smoke suite went from 4 failures in 12 runs of the
  election pages to 72/72 on all three devices.
- The demo elections' placeholder addresses (0xCon1aaa) passed the 0x check
  in useElectionFunding, so ethers took them for ENS names and logged an
  error on every render. Only a whole address is read now.
…ts options

voteCostFor scaled the largest measured size by the election's option count.
With only five-slot ballots measured, a six-option election came out at 6/5
of their cost, though it proves with the nine-slot circuit: an underestimate,
the opposite of what a figure shown as "ballots paid for" may be. It now works
out the circuit the election proves with, from the sizes build.mjs ships.
…poch

The contract accepts a ballot naming the current epoch or the last one, each
tag once, but the app named only the current one. A voter who had just cast
waited for the next clock hour, and a vote forced after the last hour boundary
before the close could not be replaced at all.

chooseEpoch now uses both. When both are free it draws one at random: the
epoch is public calldata, and naming the previous one only once the current
one was spent would mark the ballot as a re-vote, in the last hour, where a
coercer watches. Inside two minutes of the turn the previous epoch is skipped,
since the proof would arrive stale.
…s do

- Terms said one gas tank pays for all of an organizer's elections and that
  voting stops in every one when it empties. Since the per-election reserve
  (2026-09-16) each election pays from its own reserve first, then from the
  shared free balance, and only that election stops.
- "Change your vote whenever you want" was never true under the hourly limit.
  How it works, privacy and the limit messages now say it: straight away
  once, then once an hour, and nobody watching can tell.
- Privacy states the limit that remains: two votes forced in a row in the last
  hour before the close cannot be replaced.
- Page dates follow their content.
…our limit

- README: anonymity comes from Votain's own ballot proof over Semaphore
  identities, not from Semaphore proofs; test counts; the last-hour limit.
- Glossary: nullifier and nonce described the scheme the hidden re-votes
  replaced; adds the epoch tag, fixes the V4 identity commitment.
- deployment.md had no step for the contracts: ceremony, attester,
  deploy:amoy, the manifests to commit, VITE_CIRCUITS_URL, backend addresses.
- PLAN.md and the agent log presented Paillier as current; marked as history.
- Package guides: test counts, deploy:amoy's ceremony requirement, and
  backend debt that no longer applied.
- deploy.ts: deployVerifiers' comment back above deployVerifiers.
npm-check-updates -u in contracts, backend, frontend and scripts-tally;
circuits was already current. One major: hardhat-toolbox-mocha-ethers 3 to 4,
which moves the contract tests to Mocha 12 and hardhat-mocha 4. The rest are
minors and patches (hardhat 3.18, idkit 4.3, vite 8.3.1, vitest 5.0.2,
oxlint 1.86, snarkjs 0.7.6, dotenv 18.0.4).

After npm install, all green: contracts 222 (the E2E suite with real Groth16
proofs), backend 160, frontend 597, lint, typechecks and builds.

oxlint 1.86 adds React's purity rule, which caught the date picker reading
the clock during render to mark today. It now reads it when the popover opens.
poseidon-lite's root index is CommonJS and loads the round constants of all
sixteen arities, which no bundler can tree-shake: the vendor chunk weighed
581 kB for the three arities the app uses, the one chunk over Vite's 500 kB
warning. Importing poseidon2/3/4 from their own subpaths, as Semaphore and
zk-kit already do, brings it to 61 kB. No limit or chunking rule changed.
- Root: the system context (phone apps, frontend, issuer in its TEE, the
  contracts, the tally CLI) and a vote end to end, from sign-in to a result
  anyone can re-check. Replaces the ASCII sketch.
- frontend: pages, hooks and the lib modules, down to snarkjs, the issuer
  and the RPC.
- backend: routes, the modules behind them, and the two keys that reach the
  chain through one signer queue.
- contracts: who deploys, calls and verifies whom.
- circuits (new README): from the circom sources to the verifiers, the
  proving files and the deployment.
- scripts-tally (new README): the organizer's tally and anyone's --verify.

Every diagram renders with Mermaid 11. The package READMEs also lost what
the hidden re-votes made untrue: a per-nullifier nonce, old test counts.
… epochs

The README's coercion row and the glossary still said one ballot per voter per
hour. Since the app names the previous epoch too, a voter can replace a ballot
straight away once, then one an hour; the glossary also says why the choice
between the two epochs is random.
- architecture.md: the overview and the full voting flow, now a flowchart
  and a sequence diagram with the reasoning kept as text beneath. The ASCII
  versions had drifted: an /attestation endpoint the backend does not serve
  (the TDX report comes from Phala's dstack), enrolment of the platform
  commitment where elections now take one derived per election, and one
  ballot an hour. The election state machine is a state diagram.
- deployment.md: the pipeline showed three checks; CI runs five, with
  circuits feeding contracts, and publishes a release.
- backend/DEVELOPMENT.md: the World ID sign-in, as a sequence diagram.

Directory trees stay as text. All diagrams render with Mermaid 11.
Threshold decryption and an auto-tally stay out of this version, by decision:
a Future work section collects them, with the tally deadline, the last-hour
coercion fix and the scaling work, and points to the design sections that
already argue each one.

Adds the limit nobody had written down: there is no publishing deadline, so an
organizer who never decrypts leaves the election in TALLYING for good, and can
release its gas reserve once voting has ended.
The registrar pays, unreimbursed, for every registration, vault entry,
preferences blob, rotation and revocation: a cost that grows with users. None
of it is on the voting path now that enrollPrivate never reads the registry.
Future work describes batching it behind one Merkle root per period, with the
blobs in content-addressed storage, or billing a per-voter platform fee to the
organizer's reserve.
@CodeInIA CodeInIA changed the title Revisión final antes del despliegue en Amoy: re-votos invisibles, recuento probado on-chain, seguridad y calidad Final review before the Amoy deploy: hidden re-votes, a tally proved on chain, security and quality Sep 28, 2026
@CodeInIA
CodeInIA merged commit 7a665d0 into dev Sep 28, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants