diff --git a/packages/cli/README.md b/packages/cli/README.md index b06367c..a43aa1c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -28,6 +28,9 @@ maci registry list # Compare on-chain vkeys against the bundled registry maci registry check dora1abc...xyz + +# Launch the local web UI (visual verification dashboard) +maci ui ``` ## Commands @@ -147,6 +150,27 @@ maci round dora1abc...xyz --network testnet --recheck --- +### `maci ui` + +Starts a local web UI for round verification and registry browsing — the same checks as `maci round` and `maci registry`, rendered as a visual dashboard with live progress. + +```bash +maci ui # serve on http://127.0.0.1:7766 and open the browser +maci ui --port 8080 # custom port (auto-increments if busy) +maci ui --no-open # don't open the browser automatically +``` + +Features: + +- Run Layer 1 / Layer 2 round verification with real-time step progress (streamed via SSE) +- Structured verification report: per-check PASS/FAIL, commitment comparisons, result banner +- Export the full verification result as JSON +- Browse bundled circuits (parameters, vkey fingerprints, zkey links) and check a contract's on-chain vkeys against the registry + +The server binds to `127.0.0.1` only and performs the same read-only queries as the CLI commands — no private keys, no transactions. + +--- + ## Options Reference | Option | Commands | Default | Description | @@ -155,6 +179,8 @@ maci round dora1abc...xyz --network testnet --recheck | `--rpc` | `round` | *(see Network Defaults)* | Override the CosmWasm RPC endpoint | | `--indexer` | `round` | *(see Network Defaults)* | Override the MACI GraphQL indexer endpoint | | `--recheck` | `round` | `false` | Enable Layer 2 local ZK re-verification | +| `--port` | `ui` | `7766` | Port for the local web UI (auto-increments if busy) | +| `--no-open` | `ui` | — | Don't open the browser automatically | ## Verification Levels diff --git a/packages/cli/src/commands/registry.ts b/packages/cli/src/commands/registry.ts index 36a9309..4c53062 100644 --- a/packages/cli/src/commands/registry.ts +++ b/packages/cli/src/commands/registry.ts @@ -1,15 +1,7 @@ import type { CommandModule, Argv, ArgumentsCamelCase } from 'yargs'; -import { getOnChainVkeys, type Groth16VkeyOnChain } from '../core/chain.js'; -import { - resolveEndpoints, - NETWORK_CHOICES, - type NetworkName, -} from '../core/network.js'; -import { - AMACI_CIRCUITS, - type AmaciCircuitEntry, - type AmaciVkeySet, -} from '../core/circuits.js'; +import { NETWORK_CHOICES, type NetworkName } from '../core/network.js'; +import { AMACI_CIRCUITS } from '../core/circuits.js'; +import { runRegistryCheck } from '../core/pipeline.js'; import { printRegistryList, printCircuitDetail, @@ -17,54 +9,6 @@ import { printError, } from '../core/report.js'; -// ─── Helpers ──────────────────────────────────────────────────────────────── - -function vkeysMatch(onChain: Groth16VkeyOnChain, registered: AmaciVkeySet): boolean { - const fields = [ - 'vk_alpha1', - 'vk_beta_2', - 'vk_gamma_2', - 'vk_delta_2', - 'vk_ic0', - 'vk_ic1', - ] as const; - return fields.every((f) => onChain[f] === registered[f]); -} - -type VkeyMatchResult = { - power: string; - entry: AmaciCircuitEntry; - processMatch: boolean; - tallyMatch: boolean; - deactivateMatch: boolean | null; - addNewKeyMatch: boolean | null; -}; - -/** - * Scan all known aMACI circuits to find one whose vkeys match the on-chain values. - */ -function findMatchingCircuit( - processVkey: Groth16VkeyOnChain, - tallyVkey: Groth16VkeyOnChain, - deactivateVkey: Groth16VkeyOnChain | undefined, - addNewKeyVkey: Groth16VkeyOnChain | undefined -): VkeyMatchResult | null { - for (const [power, entry] of Object.entries(AMACI_CIRCUITS)) { - const pm = vkeysMatch(processVkey, entry.vkeys.process); - const tm = vkeysMatch(tallyVkey, entry.vkeys.tally); - if (pm || tm) { - const dm = deactivateVkey !== undefined - ? vkeysMatch(deactivateVkey, entry.vkeys.deactivate) - : null; - const am = addNewKeyVkey !== undefined - ? vkeysMatch(addNewKeyVkey, entry.vkeys.addNewKey) - : null; - return { power, entry, processMatch: pm, tallyMatch: tm, deactivateMatch: dm, addNewKeyMatch: am }; - } - } - return null; -} - // ─── Subcommand handlers ───────────────────────────────────────────────────── function handleList() { @@ -92,40 +36,20 @@ function handleShow(args: ArgumentsCamelCase<{ power: string }>) { async function handleCheck( args: ArgumentsCamelCase<{ contract: string; network: NetworkName; rpc?: string }> ) { - const { rpc } = resolveEndpoints(args.network, { rpc: args.rpc }); try { - const vkeys = await getOnChainVkeys(rpc, args.contract); - const match = findMatchingCircuit( - vkeys.processVkey, - vkeys.tallyVkey, - vkeys.deactivateVkey, - vkeys.addNewKeyVkey - ); - - if (!match) { - printVkeyCheckResult({ - power: 'UNKNOWN', - source: 'not found in registry', - production: false, - processMatch: null, - tallyMatch: null, - deactivateMatch: null, - addNewKeyMatch: null, - }); - process.exit(1); - } + const result = await runRegistryCheck(args.contract, args.network, args.rpc); printVkeyCheckResult({ - power: match.power, - source: match.entry.source, - production: match.entry.production, - processMatch: match.processMatch, - tallyMatch: match.tallyMatch, - deactivateMatch: match.deactivateMatch, - addNewKeyMatch: match.addNewKeyMatch, + power: result.power, + source: result.source, + production: result.production, + processMatch: result.processMatch, + tallyMatch: result.tallyMatch, + deactivateMatch: result.deactivateMatch, + addNewKeyMatch: result.addNewKeyMatch, }); - if (!match.processMatch || !match.tallyMatch) { + if (!result.passed) { process.exit(1); } } catch (err) { diff --git a/packages/cli/src/commands/ui.ts b/packages/cli/src/commands/ui.ts new file mode 100644 index 0000000..a44236c --- /dev/null +++ b/packages/cli/src/commands/ui.ts @@ -0,0 +1,74 @@ +import { spawn } from 'node:child_process'; +import type { CommandModule, Argv, ArgumentsCamelCase } from 'yargs'; +import chalk from 'chalk'; +import { startUiServer } from '../ui/server.js'; +import { printError } from '../core/report.js'; + +const DEFAULT_PORT = 7766; + +/** Open a URL in the default browser without extra dependencies. */ +function openBrowser(url: string) { + const platform = process.platform; + let cmd: string; + let args: string[]; + + if (platform === 'darwin') { + cmd = 'open'; + args = [url]; + } else if (platform === 'win32') { + // `start` is a cmd built-in; empty title arg required when URL is quoted + cmd = 'cmd'; + args = ['/c', 'start', '', url]; + } else { + cmd = 'xdg-open'; + args = [url]; + } + + const child = spawn(cmd, args, { stdio: 'ignore', detached: true }); + child.on('error', () => { + // Non-fatal: user can open the printed URL manually + }); + child.unref(); +} + +async function handleUi(args: ArgumentsCamelCase<{ port: number; open: boolean }>) { + try { + const { port, url } = await startUiServer(args.port); + + console.log(''); + console.log(` ${chalk.bold('maci ui')} — local verification dashboard`); + console.log(''); + console.log(` Serving on ${chalk.cyan(url)}`); + if (port !== args.port) { + console.log(chalk.dim(` (port ${args.port} was busy, using ${port})`)); + } + console.log(chalk.dim(' Read-only: no keys, no signing. Press Ctrl+C to stop.')); + console.log(''); + + if (args.open) { + openBrowser(url); + } + } catch (err) { + printError(err instanceof Error ? err.message : String(err)); + process.exit(1); + } +} + +export const uiCommand: CommandModule = { + command: 'ui', + describe: 'Start a local web UI for round verification and registry browsing', + builder: (yargs: Argv) => + yargs + .option('port', { + alias: 'p', + type: 'number', + description: 'Port to listen on (auto-increments if busy)', + default: DEFAULT_PORT, + }) + .option('open', { + type: 'boolean', + description: 'Open the browser automatically (use --no-open to disable)', + default: true, + }), + handler: (args) => handleUi(args as ArgumentsCamelCase<{ port: number; open: boolean }>), +}; diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index 88ce9c3..b56ed80 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -1,177 +1,12 @@ import type { CommandModule, Argv, ArgumentsCamelCase } from 'yargs'; -import { - getMsgChainLength, - getNumSignUps, - getStateCommitment, - getTallyCommitment, - getRoundPeriod, - getOnChainVkeys, - getRecheckChainConfig, -} from '../core/chain.js'; -import { - getRoundInfo, - getProofs, - getMessageCount, - getMessages, - type ProofEntry, -} from '../core/indexer.js'; import { createStep, printRoundSummary, printVerificationReport, printError, - type CheckResult, } from '../core/report.js'; -import { - resolveEndpoints, - NETWORK_CHOICES, - type NetworkName, -} from '../core/network.js'; -import { runLayer2, type RecheckResult } from '../core/recheck.js'; - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -/** Parse batchSize from circuit power string (e.g. "9-4-3-125" → 125) */ -function parseBatchSize(circuitPower: string): number { - const parts = circuitPower.split('-'); - const n = parseInt(parts[3] ?? '0', 10); - return isNaN(n) ? 0 : n; -} - -/** Group proofs by actionType */ -function groupByAction(proofs: ProofEntry[]): Record { - return proofs.reduce>((acc, p) => { - const key = p.actionType; - if (!acc[key]) acc[key] = []; - acc[key].push(p); - return acc; - }, {}); -} - -/** Find the last proof commitment of a given actionType (proofs ordered by timestamp asc) */ -function lastCommitmentOf(proofs: ProofEntry[], actionType: string): string | null { - const filtered = proofs.filter((p) => p.actionType === actionType); - if (filtered.length === 0) return null; - return filtered[filtered.length - 1].commitment; -} - -// ─── Layer 1: pure commitment audit (no I/O) ──────────────────────────────── - -/** Convert nanosecond Unix timestamp string to a readable UTC date string */ -function fmtNanoTs(ns: string): string { - try { - const ms = Number(BigInt(ns) / 1_000_000n); - return new Date(ms).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'; - } catch { - return ns; - } -} - -type Layer1Data = { - round: NonNullable>>; - proofs: ProofEntry[]; - onChainMsgChainLength: string; - onChainSignUps: string; - indexedMsgCount: number; - onChainStateCommitment: string; - onChainTallyCommitment: string; -}; - -function computeLayer1Checks(data: Layer1Data): { checks: CheckResult[]; overallPassed: boolean } { - const { round, proofs, onChainMsgChainLength, onChainSignUps, indexedMsgCount, onChainStateCommitment, onChainTallyCommitment } = data; - const checks: CheckResult[] = []; - const byAction = groupByAction(proofs); - - // 1. Each proof type: all must have verifyResult === "true" - const proofTypes = [ - { key: 'message', label: 'processMessage proofs' }, - { key: 'tally', label: 'tally proof' }, - { key: 'deactivate', label: 'deactivate proofs' }, - ]; - for (const { key, label } of proofTypes) { - const group = byAction[key] ?? []; - if (group.length === 0) continue; - const allVerified = group.every((p) => p.verifyResult === 'true'); - const failedCount = group.filter((p) => p.verifyResult !== 'true').length; - checks.push({ - label: `${label} (${group.length})`, - passed: allVerified, - detail: allVerified - ? `all ${group.length} accepted on-chain` - : `${failedCount} of ${group.length} NOT accepted on-chain`, - }); - } - - // 2. Sign-up count: on-chain vs indexed - checks.push({ - label: 'Sign-up count (on-chain vs indexed)', - passed: BigInt(onChainSignUps) === BigInt(round.signUpsCount), - detail: `on-chain: ${onChainSignUps}, indexed: ${round.signUpsCount}`, - }); - - // 3. Message coverage: batchSize × msgProofCount ≥ msgChainLength - const batchSize = parseBatchSize(round.circuitPower); - const msgProofCount = (byAction['message'] ?? []).length; - const chainLength = BigInt(onChainMsgChainLength); - const indexedLength = BigInt(indexedMsgCount); - - checks.push({ - label: 'MSG_CHAIN_LENGTH (on-chain vs indexed)', - passed: chainLength === indexedLength, - detail: `on-chain: ${chainLength}, indexed: ${indexedLength}`, - }); - - const coverage = BigInt(msgProofCount * batchSize); - const coverageOk = coverage >= chainLength; - checks.push({ - label: `Batch coverage (${msgProofCount}×${batchSize})`, - passed: coverageOk, - detail: `${coverage} ${coverageOk ? '≥' : '<'} ${chainLength} messages`, - }); - - // 3. State commitment: last message proof commitment == QueryCurrentStateCommitment - const lastMsgCommitment = lastCommitmentOf(proofs, 'message'); - if (lastMsgCommitment !== null) { - const stateMatch = BigInt(onChainStateCommitment) === BigInt(lastMsgCommitment); - checks.push({ - label: 'State commitment (on-chain vs message proof)', - passed: stateMatch, - detail: stateMatch - ? `0x${BigInt(onChainStateCommitment).toString(16).slice(0, 16)}...` - : `on-chain: ${onChainStateCommitment}\n indexed: ${lastMsgCommitment}`, - }); - } else { - checks.push({ label: 'State commitment', passed: false, detail: 'No message proof found in indexer' }); - } - - // 4. Tally commitment: last tally proof commitment == current_tally_commitment (raw) - const lastTallyCommit = lastCommitmentOf(proofs, 'tally'); - if (lastTallyCommit !== null) { - const tallyMatch = BigInt(onChainTallyCommitment) === BigInt(lastTallyCommit); - checks.push({ - label: 'Tally commitment (on-chain vs tally proof)', - passed: tallyMatch, - detail: tallyMatch - ? `0x${BigInt(onChainTallyCommitment).toString(16).slice(0, 16)}...` - : `on-chain: ${onChainTallyCommitment}\n indexed: ${lastTallyCommit}`, - }); - } else { - checks.push({ label: 'Tally commitment', passed: false, detail: 'No tally proof found in indexer' }); - } - - const overallPassed = checks.every((c) => c.passed); - return { checks, overallPassed }; -} - -// ─── Layer 2 report helpers ─────────────────────────────────────────────────── - -function recheckResultsToChecks(results: RecheckResult[]): CheckResult[] { - return results.map((r) => ({ - label: `[L2] ${r.batchLabel}`, - passed: r.passed, - detail: r.detail, - })); -} +import { NETWORK_CHOICES, type NetworkName } from '../core/network.js'; +import { runRoundVerification, type PipelineEvent } from '../core/pipeline.js'; // ─── Main handler ───────────────────────────────────────────────────────────── @@ -185,10 +20,6 @@ async function handleRound( }> ) { const { contract, network, recheck } = args; - const { rpc, indexer } = resolveEndpoints(network, { - rpc: args.rpc, - indexer: args.indexer, - }); // Print header before fetching starts const shortAddr = `${contract.slice(0, 16)}…${contract.slice(-6)}`; @@ -199,164 +30,41 @@ async function handleRound( const totalSteps = recheck ? 9 : 5; const step = createStep(totalSteps); - try { - // Step 1: round info from indexer - step.start('Fetching round info from indexer'); - const round = await getRoundInfo(indexer, contract); - if (!round) { - step.fail('not found'); - printError( - `Round not found in indexer for contract ${contract}.\n` + - ` Network: ${network}\n` + - ` Indexer: ${indexer}\n` + - ` Tip: Did you mean --network testnet?` - ); - process.exit(1); + // Map pipeline events onto the terminal renderer + const onEvent = (event: PipelineEvent) => { + switch (event.type) { + case 'step:start': + step.start(event.label); + break; + case 'step:update': + step.update?.(event.detail); + break; + case 'step:done': + step.done(event.detail); + break; + case 'step:fail': + step.fail(event.detail); + break; + case 'summary': + printRoundSummary(event.summary); + break; + case 'report': + printVerificationReport(event.report); + break; } - if (round.maciType !== 'aMACI') { - step.fail(`not aMACI (got "${round.maciType}")`); - printError(`This CLI only supports aMACI rounds. Contract ${contract} is a "${round.maciType}" round.`); - process.exit(1); - } - step.done(`${round.circuitName} · ${round.circuitPower} · ${round.status}`); - - // Step 2: proof records from indexer - step.start('Fetching proof records from indexer'); - const proofs = await getProofs(indexer, contract); - const byAction = groupByAction(proofs); - const msgProofCount = (byAction['message'] ?? []).length; - const tallyCount = (byAction['tally'] ?? []).length; - const deactivateCount = (byAction['deactivate'] ?? []).length; - step.done( - `${proofs.length} records (message×${msgProofCount} tally×${tallyCount} deactivate×${deactivateCount})` - ); - - // Step 3: on-chain counts (msg chain length + sign-ups) + indexed message count, all parallel - step.start('Querying on-chain counts (RPC + indexer, parallel)'); - const [onChainMsgChainLength, onChainSignUps, indexedMsgCount] = await Promise.all([ - getMsgChainLength(rpc, contract), - getNumSignUps(rpc, contract), - getMessageCount(indexer, contract), - ]); - step.done( - `sign-ups: ${onChainSignUps} messages: ${onChainMsgChainLength} (on-chain) / ${indexedMsgCount} (indexed)` - ); - - // Step 4: state commitment from RPC - step.start('Querying state commitment (RPC)'); - const onChainStateCommitment = await getStateCommitment(rpc, contract); - step.done(`0x${BigInt(onChainStateCommitment).toString(16).slice(0, 16)}…`); - - // Step 5: tally commitment + round period (parallel) - step.start('Querying tally commitment (RPC)'); - const [onChainTallyCommitment, period] = await Promise.all([ - getTallyCommitment(rpc, contract), - getRoundPeriod(rpc, contract).catch(() => round.status), - ]); - step.done(`0x${BigInt(onChainTallyCommitment).toString(16).slice(0, 16)}…`); - - // Print round summary before the checks - printRoundSummary({ - contractAddress: contract, - network, - circuitPower: round.circuitPower, - circuitName: round.circuitName, - status: period, - operatorAddress: round.operatorAddress, - votingStart: fmtNanoTs(round.votingStart), - votingEnd: fmtNanoTs(round.votingEnd), - signUpsOnChain: onChainSignUps, - signUpsIndexed: String(round.signUpsCount), - messagesOnChain: onChainMsgChainLength, - messagesIndexed: String(indexedMsgCount), - }); + }; - // Pure computation — no more network calls for Layer 1 - const { checks: l1Checks, overallPassed: l1Passed } = computeLayer1Checks({ - round, - proofs, - onChainMsgChainLength, - onChainSignUps, - indexedMsgCount, - onChainStateCommitment, - onChainTallyCommitment, - }); + const result = await runRoundVerification( + { contract, network, rpc: args.rpc, indexer: args.indexer, recheck }, + onEvent + ); - // ── Layer 2 (--recheck) ────────────────────────────────────────────────── - let l2Checks: CheckResult[] = []; - let l2Passed = true; - - if (recheck) { - const msgProofs = byAction['message'] ?? []; - const tallyProofs = byAction['tally'] ?? []; - - // Step 6: download all messages - step.start(`Downloading ${indexedMsgCount} messages from indexer`); - let fetched = 0; - const messages = await getMessages(indexer, contract, (n, total) => { - fetched = n; - step.update?.(`${fetched}/${total} messages`); - }); - step.done(`${messages.length} messages downloaded`); - - // Step 7: fetch on-chain vkeys - step.start('Fetching on-chain vkeys (RPC)'); - const vkeys = await getOnChainVkeys(rpc, contract); - step.done('process vkey + tally vkey loaded'); - - // Step 8: fetch chain recheck config - step.start('Fetching chain config for re-verification (RPC)'); - const chainCfg = await getRecheckChainConfig(rpc, contract); - step.done( - `coordHash=0x${chainCfg.coordinatorHash.toString(16).slice(0, 8)}… circuitType=${chainCfg.circuitType}` - ); - - // Step 9: run snarkjs verifications - step.start( - `Running snarkjs.groth16.verify (${msgProofs.length} msg + ${tallyProofs.length} tally proofs)` - ); - let proofsDone = 0; - const recheckResults = await runLayer2( - messages, - msgProofs, - tallyProofs, - chainCfg, - vkeys, - round.circuitPower, - (label) => { - proofsDone++; - step.update?.(`[${proofsDone}/${msgProofs.length + tallyProofs.length}] ${label}`); - } - ); - - const failedL2 = recheckResults.filter((r) => !r.passed).length; - if (failedL2 === 0) { - step.done(`all ${recheckResults.length} proofs verified locally ✓`); - } else { - step.fail(`${failedL2}/${recheckResults.length} proofs FAILED local re-verification`); - } - - l2Checks = recheckResultsToChecks(recheckResults); - l2Passed = recheckResults.every((r) => r.passed); - } - - const allChecks = [...l1Checks, ...l2Checks]; - const overallPassed = l1Passed && l2Passed; - - printVerificationReport({ - contractAddress: contract, - circuitPower: round.circuitPower, - maciType: round.maciType, - status: period, - checks: allChecks, - overallPassed, - }); - - process.exit(overallPassed ? 0 : 1); - } catch (err) { - printError(err instanceof Error ? err.message : String(err)); + if (result.status === 'error') { + printError(result.message); process.exit(1); } + + process.exit(result.overallPassed ? 0 : 1); } // ─── CommandModule ─────────────────────────────────────────────────────────── diff --git a/packages/cli/src/core/pipeline.ts b/packages/cli/src/core/pipeline.ts new file mode 100644 index 0000000..2bbff7a --- /dev/null +++ b/packages/cli/src/core/pipeline.ts @@ -0,0 +1,507 @@ +/** + * Presentation-agnostic orchestration for round verification and registry checks. + * + * This module performs all the I/O and computation but never touches the + * terminal (no chalk, no process.exit). Progress and results are surfaced + * through a typed event stream, so the same pipeline can drive: + * - the terminal renderer in commands/verify.ts (createStep/print*) + * - the local web UI server in ui/server.ts (SSE) + */ + +import { + getMsgChainLength, + getNumSignUps, + getStateCommitment, + getTallyCommitment, + getRoundPeriod, + getOnChainVkeys, + getRecheckChainConfig, + type Groth16VkeyOnChain, +} from './chain.js'; +import { + getRoundInfo, + getProofs, + getMessageCount, + getMessages, + type ProofEntry, +} from './indexer.js'; +import type { + CheckResult, + RoundSummary, + VerificationReport, +} from './report.js'; +import { resolveEndpoints, type NetworkName } from './network.js'; +import { runLayer2, type RecheckResult } from './recheck.js'; +import { + AMACI_CIRCUITS, + type AmaciCircuitEntry, + type AmaciVkeySet, +} from './circuits.js'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/** Parse batchSize from circuit power string (e.g. "9-4-3-125" → 125) */ +function parseBatchSize(circuitPower: string): number { + const parts = circuitPower.split('-'); + const n = parseInt(parts[3] ?? '0', 10); + return isNaN(n) ? 0 : n; +} + +/** Group proofs by actionType */ +function groupByAction(proofs: ProofEntry[]): Record { + return proofs.reduce>((acc, p) => { + const key = p.actionType; + if (!acc[key]) acc[key] = []; + acc[key].push(p); + return acc; + }, {}); +} + +/** Find the last proof commitment of a given actionType (proofs ordered by timestamp asc) */ +function lastCommitmentOf(proofs: ProofEntry[], actionType: string): string | null { + const filtered = proofs.filter((p) => p.actionType === actionType); + if (filtered.length === 0) return null; + return filtered[filtered.length - 1].commitment; +} + +/** Convert nanosecond Unix timestamp string to a readable UTC date string */ +function fmtNanoTs(ns: string): string { + try { + const ms = Number(BigInt(ns) / 1_000_000n); + return new Date(ms).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'; + } catch { + return ns; + } +} + +// ─── Layer 1: pure commitment audit (no I/O) ──────────────────────────────── + +type Layer1Data = { + round: NonNullable>>; + proofs: ProofEntry[]; + onChainMsgChainLength: string; + onChainSignUps: string; + indexedMsgCount: number; + onChainStateCommitment: string; + onChainTallyCommitment: string; +}; + +function computeLayer1Checks(data: Layer1Data): { checks: CheckResult[]; overallPassed: boolean } { + const { round, proofs, onChainMsgChainLength, onChainSignUps, indexedMsgCount, onChainStateCommitment, onChainTallyCommitment } = data; + const checks: CheckResult[] = []; + const byAction = groupByAction(proofs); + + // 1. Each proof type: all must have verifyResult === "true" + const proofTypes = [ + { key: 'message', label: 'processMessage proofs' }, + { key: 'tally', label: 'tally proof' }, + { key: 'deactivate', label: 'deactivate proofs' }, + ]; + for (const { key, label } of proofTypes) { + const group = byAction[key] ?? []; + if (group.length === 0) continue; + const allVerified = group.every((p) => p.verifyResult === 'true'); + const failedCount = group.filter((p) => p.verifyResult !== 'true').length; + checks.push({ + label: `${label} (${group.length})`, + passed: allVerified, + detail: allVerified + ? `all ${group.length} accepted on-chain` + : `${failedCount} of ${group.length} NOT accepted on-chain`, + }); + } + + // 2. Sign-up count: on-chain vs indexed + checks.push({ + label: 'Sign-up count (on-chain vs indexed)', + passed: BigInt(onChainSignUps) === BigInt(round.signUpsCount), + detail: `on-chain: ${onChainSignUps}, indexed: ${round.signUpsCount}`, + }); + + // 3. Message coverage: batchSize × msgProofCount ≥ msgChainLength + const batchSize = parseBatchSize(round.circuitPower); + const msgProofCount = (byAction['message'] ?? []).length; + const chainLength = BigInt(onChainMsgChainLength); + const indexedLength = BigInt(indexedMsgCount); + + checks.push({ + label: 'MSG_CHAIN_LENGTH (on-chain vs indexed)', + passed: chainLength === indexedLength, + detail: `on-chain: ${chainLength}, indexed: ${indexedLength}`, + }); + + const coverage = BigInt(msgProofCount * batchSize); + const coverageOk = coverage >= chainLength; + checks.push({ + label: `Batch coverage (${msgProofCount}×${batchSize})`, + passed: coverageOk, + detail: `${coverage} ${coverageOk ? '≥' : '<'} ${chainLength} messages`, + }); + + // 3. State commitment: last message proof commitment == QueryCurrentStateCommitment + const lastMsgCommitment = lastCommitmentOf(proofs, 'message'); + if (lastMsgCommitment !== null) { + const stateMatch = BigInt(onChainStateCommitment) === BigInt(lastMsgCommitment); + checks.push({ + label: 'State commitment (on-chain vs message proof)', + passed: stateMatch, + detail: stateMatch + ? `0x${BigInt(onChainStateCommitment).toString(16).slice(0, 16)}...` + : `on-chain: ${onChainStateCommitment}\n indexed: ${lastMsgCommitment}`, + }); + } else { + checks.push({ label: 'State commitment', passed: false, detail: 'No message proof found in indexer' }); + } + + // 4. Tally commitment: last tally proof commitment == current_tally_commitment (raw) + const lastTallyCommit = lastCommitmentOf(proofs, 'tally'); + if (lastTallyCommit !== null) { + const tallyMatch = BigInt(onChainTallyCommitment) === BigInt(lastTallyCommit); + checks.push({ + label: 'Tally commitment (on-chain vs tally proof)', + passed: tallyMatch, + detail: tallyMatch + ? `0x${BigInt(onChainTallyCommitment).toString(16).slice(0, 16)}...` + : `on-chain: ${onChainTallyCommitment}\n indexed: ${lastTallyCommit}`, + }); + } else { + checks.push({ label: 'Tally commitment', passed: false, detail: 'No tally proof found in indexer' }); + } + + const overallPassed = checks.every((c) => c.passed); + return { checks, overallPassed }; +} + +function recheckResultsToChecks(results: RecheckResult[]): CheckResult[] { + return results.map((r) => ({ + label: `[L2] ${r.batchLabel}`, + passed: r.passed, + detail: r.detail, + })); +} + +// ─── Round verification pipeline ───────────────────────────────────────────── + +export type RoundVerifyOptions = { + contract: string; + network: NetworkName; + rpc?: string; + indexer?: string; + recheck: boolean; +}; + +export type PipelineEvent = + | { type: 'step:start'; step: number; total: number; label: string } + | { type: 'step:update'; step: number; detail: string } + | { type: 'step:done'; step: number; detail?: string } + | { type: 'step:fail'; step: number; detail?: string } + | { type: 'summary'; summary: RoundSummary } + | { type: 'report'; report: VerificationReport }; + +export type RoundRunResult = + | { + status: 'completed'; + overallPassed: boolean; + summary: RoundSummary; + report: VerificationReport; + } + | { status: 'error'; message: string }; + +export async function runRoundVerification( + opts: RoundVerifyOptions, + onEvent: (event: PipelineEvent) => void +): Promise { + const { contract, network, recheck } = opts; + const { rpc, indexer } = resolveEndpoints(network, { + rpc: opts.rpc, + indexer: opts.indexer, + }); + + const total = recheck ? 9 : 5; + let current = 0; + const step = { + start(label: string) { + current += 1; + onEvent({ type: 'step:start', step: current, total, label }); + }, + update(detail: string) { + onEvent({ type: 'step:update', step: current, detail }); + }, + done(detail?: string) { + onEvent({ type: 'step:done', step: current, detail }); + }, + fail(detail?: string) { + onEvent({ type: 'step:fail', step: current, detail }); + }, + }; + + try { + // Step 1: round info from indexer + step.start('Fetching round info from indexer'); + const round = await getRoundInfo(indexer, contract); + if (!round) { + step.fail('not found'); + return { + status: 'error', + message: + `Round not found in indexer for contract ${contract}.\n` + + ` Network: ${network}\n` + + ` Indexer: ${indexer}\n` + + ` Tip: Did you mean --network testnet?`, + }; + } + if (round.maciType !== 'aMACI') { + step.fail(`not aMACI (got "${round.maciType}")`); + return { + status: 'error', + message: `This CLI only supports aMACI rounds. Contract ${contract} is a "${round.maciType}" round.`, + }; + } + step.done(`${round.circuitName} · ${round.circuitPower} · ${round.status}`); + + // Step 2: proof records from indexer + step.start('Fetching proof records from indexer'); + const proofs = await getProofs(indexer, contract); + const byAction = groupByAction(proofs); + const msgProofCount = (byAction['message'] ?? []).length; + const tallyCount = (byAction['tally'] ?? []).length; + const deactivateCount = (byAction['deactivate'] ?? []).length; + step.done( + `${proofs.length} records (message×${msgProofCount} tally×${tallyCount} deactivate×${deactivateCount})` + ); + + // Step 3: on-chain counts (msg chain length + sign-ups) + indexed message count, all parallel + step.start('Querying on-chain counts (RPC + indexer, parallel)'); + const [onChainMsgChainLength, onChainSignUps, indexedMsgCount] = await Promise.all([ + getMsgChainLength(rpc, contract), + getNumSignUps(rpc, contract), + getMessageCount(indexer, contract), + ]); + step.done( + `sign-ups: ${onChainSignUps} messages: ${onChainMsgChainLength} (on-chain) / ${indexedMsgCount} (indexed)` + ); + + // Step 4: state commitment from RPC + step.start('Querying state commitment (RPC)'); + const onChainStateCommitment = await getStateCommitment(rpc, contract); + step.done(`0x${BigInt(onChainStateCommitment).toString(16).slice(0, 16)}…`); + + // Step 5: tally commitment + round period (parallel) + step.start('Querying tally commitment (RPC)'); + const [onChainTallyCommitment, period] = await Promise.all([ + getTallyCommitment(rpc, contract), + getRoundPeriod(rpc, contract).catch(() => round.status), + ]); + step.done(`0x${BigInt(onChainTallyCommitment).toString(16).slice(0, 16)}…`); + + // Emit round summary before the checks + const summary: RoundSummary = { + contractAddress: contract, + network, + circuitPower: round.circuitPower, + circuitName: round.circuitName, + status: period, + operatorAddress: round.operatorAddress, + votingStart: fmtNanoTs(round.votingStart), + votingEnd: fmtNanoTs(round.votingEnd), + signUpsOnChain: onChainSignUps, + signUpsIndexed: String(round.signUpsCount), + messagesOnChain: onChainMsgChainLength, + messagesIndexed: String(indexedMsgCount), + }; + onEvent({ type: 'summary', summary }); + + // Pure computation — no more network calls for Layer 1 + const { checks: l1Checks, overallPassed: l1Passed } = computeLayer1Checks({ + round, + proofs, + onChainMsgChainLength, + onChainSignUps, + indexedMsgCount, + onChainStateCommitment, + onChainTallyCommitment, + }); + + // ── Layer 2 (--recheck) ────────────────────────────────────────────────── + let l2Checks: CheckResult[] = []; + let l2Passed = true; + + if (recheck) { + const msgProofs = byAction['message'] ?? []; + const tallyProofs = byAction['tally'] ?? []; + + // Step 6: download all messages + step.start(`Downloading ${indexedMsgCount} messages from indexer`); + const messages = await getMessages(indexer, contract, (n, totalMsgs) => { + step.update(`${n}/${totalMsgs} messages`); + }); + step.done(`${messages.length} messages downloaded`); + + // Step 7: fetch on-chain vkeys + step.start('Fetching on-chain vkeys (RPC)'); + const vkeys = await getOnChainVkeys(rpc, contract); + step.done('process vkey + tally vkey loaded'); + + // Step 8: fetch chain recheck config + step.start('Fetching chain config for re-verification (RPC)'); + const chainCfg = await getRecheckChainConfig(rpc, contract); + step.done( + `coordHash=0x${chainCfg.coordinatorHash.toString(16).slice(0, 8)}… circuitType=${chainCfg.circuitType}` + ); + + // Step 9: run snarkjs verifications + step.start( + `Running snarkjs.groth16.verify (${msgProofs.length} msg + ${tallyProofs.length} tally proofs)` + ); + let proofsDone = 0; + const recheckResults = await runLayer2( + messages, + msgProofs, + tallyProofs, + chainCfg, + vkeys, + round.circuitPower, + (label) => { + proofsDone++; + step.update(`[${proofsDone}/${msgProofs.length + tallyProofs.length}] ${label}`); + } + ); + + const failedL2 = recheckResults.filter((r) => !r.passed).length; + if (failedL2 === 0) { + step.done(`all ${recheckResults.length} proofs verified locally ✓`); + } else { + step.fail(`${failedL2}/${recheckResults.length} proofs FAILED local re-verification`); + } + + l2Checks = recheckResultsToChecks(recheckResults); + l2Passed = recheckResults.every((r) => r.passed); + } + + const allChecks = [...l1Checks, ...l2Checks]; + const overallPassed = l1Passed && l2Passed; + + const report: VerificationReport = { + contractAddress: contract, + circuitPower: round.circuitPower, + maciType: round.maciType, + status: period, + checks: allChecks, + overallPassed, + }; + onEvent({ type: 'report', report }); + + return { status: 'completed', overallPassed, summary, report }; + } catch (err) { + return { + status: 'error', + message: err instanceof Error ? err.message : String(err), + }; + } +} + +// ─── Registry vkey check ───────────────────────────────────────────────────── + +function vkeysMatch(onChain: Groth16VkeyOnChain, registered: AmaciVkeySet): boolean { + const fields = [ + 'vk_alpha1', + 'vk_beta_2', + 'vk_gamma_2', + 'vk_delta_2', + 'vk_ic0', + 'vk_ic1', + ] as const; + return fields.every((f) => onChain[f] === registered[f]); +} + +export type VkeyMatchResult = { + power: string; + entry: AmaciCircuitEntry; + processMatch: boolean; + tallyMatch: boolean; + deactivateMatch: boolean | null; + addNewKeyMatch: boolean | null; +}; + +/** + * Scan all known aMACI circuits to find one whose vkeys match the on-chain values. + */ +export function findMatchingCircuit( + processVkey: Groth16VkeyOnChain, + tallyVkey: Groth16VkeyOnChain, + deactivateVkey: Groth16VkeyOnChain | undefined, + addNewKeyVkey: Groth16VkeyOnChain | undefined +): VkeyMatchResult | null { + for (const [power, entry] of Object.entries(AMACI_CIRCUITS)) { + const pm = vkeysMatch(processVkey, entry.vkeys.process); + const tm = vkeysMatch(tallyVkey, entry.vkeys.tally); + if (pm || tm) { + const dm = deactivateVkey !== undefined + ? vkeysMatch(deactivateVkey, entry.vkeys.deactivate) + : null; + const am = addNewKeyVkey !== undefined + ? vkeysMatch(addNewKeyVkey, entry.vkeys.addNewKey) + : null; + return { power, entry, processMatch: pm, tallyMatch: tm, deactivateMatch: dm, addNewKeyMatch: am }; + } + } + return null; +} + +export type RegistryCheckResult = { + /** Whether a matching circuit was found in the registry */ + found: boolean; + power: string; + source: string; + production: boolean; + processMatch: boolean | null; + tallyMatch: boolean | null; + deactivateMatch: boolean | null; + addNewKeyMatch: boolean | null; + /** true when both process and tally vkeys match (exit code 0 condition) */ + passed: boolean; +}; + +/** + * Fetch on-chain vkeys for a contract and compare against the built-in registry. + */ +export async function runRegistryCheck( + contract: string, + network: NetworkName, + rpcOverride?: string +): Promise { + const { rpc } = resolveEndpoints(network, { rpc: rpcOverride }); + const vkeys = await getOnChainVkeys(rpc, contract); + const match = findMatchingCircuit( + vkeys.processVkey, + vkeys.tallyVkey, + vkeys.deactivateVkey, + vkeys.addNewKeyVkey + ); + + if (!match) { + return { + found: false, + power: 'UNKNOWN', + source: 'not found in registry', + production: false, + processMatch: null, + tallyMatch: null, + deactivateMatch: null, + addNewKeyMatch: null, + passed: false, + }; + } + + return { + found: true, + power: match.power, + source: match.entry.source, + production: match.entry.production, + processMatch: match.processMatch, + tallyMatch: match.tallyMatch, + deactivateMatch: match.deactivateMatch, + addNewKeyMatch: match.addNewKeyMatch, + passed: match.processMatch && match.tallyMatch, + }; +} diff --git a/packages/cli/src/maci.ts b/packages/cli/src/maci.ts index a909996..dc2883e 100644 --- a/packages/cli/src/maci.ts +++ b/packages/cli/src/maci.ts @@ -2,6 +2,7 @@ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import { registryCommand } from './commands/registry.js'; import { roundCommand } from './commands/verify.js'; +import { uiCommand } from './commands/ui.js'; yargs(hideBin(process.argv)) .scriptName('maci') @@ -10,6 +11,7 @@ yargs(hideBin(process.argv)) .demandCommand(1, 'Please specify a command. Run "maci --help" for usage.') .command(registryCommand) .command(roundCommand) + .command(uiCommand) .help() .version() .wrap(Math.min(100, process.stdout.columns ?? 80)) diff --git a/packages/cli/src/ui/server.ts b/packages/cli/src/ui/server.ts new file mode 100644 index 0000000..8ad4777 --- /dev/null +++ b/packages/cli/src/ui/server.ts @@ -0,0 +1,243 @@ +/** + * Local web UI server for `maci ui`. + * + * Zero runtime dependencies beyond what the CLI already ships: + * built on Node's http module, serves the static frontend from dist/web/ + * and exposes a small JSON/SSE API that re-uses core/pipeline.ts. + * + * Security model: binds to 127.0.0.1 only; the server performs read-only + * chain/indexer queries and local proof verification — no keys, no signing. + */ + +import http from 'node:http'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { AMACI_CIRCUITS } from '../core/circuits.js'; +import { + NETWORK_DEFAULTS, + NETWORK_CHOICES, + type NetworkName, +} from '../core/network.js'; +import { + runRoundVerification, + runRegistryCheck, + type PipelineEvent, +} from '../core/pipeline.js'; + +// dist layout: dist/maci.js + dist/web/* (tsup copies src/web → dist/web) +const WEB_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'web'); + +const MIME_TYPES: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', +}; + +function sendJson(res: http.ServerResponse, status: number, body: unknown) { + const payload = JSON.stringify(body); + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(payload), + }); + res.end(payload); +} + +function parseNetwork(value: string | null): NetworkName | null { + if (value === null) return 'mainnet'; + return (NETWORK_CHOICES as readonly string[]).includes(value) + ? (value as NetworkName) + : null; +} + +// ─── Static files ──────────────────────────────────────────────────────────── + +async function serveStatic(url: URL, res: http.ServerResponse) { + let pathname = decodeURIComponent(url.pathname); + if (pathname === '/') pathname = '/index.html'; + + // Resolve and confine to WEB_ROOT (no path traversal) + const filePath = path.normalize(path.join(WEB_ROOT, pathname)); + if (!filePath.startsWith(WEB_ROOT + path.sep) && filePath !== WEB_ROOT) { + res.writeHead(403).end('Forbidden'); + return; + } + + try { + const data = await readFile(filePath); + const ext = path.extname(filePath).toLowerCase(); + res.writeHead(200, { + 'Content-Type': MIME_TYPES[ext] ?? 'application/octet-stream', + 'Cache-Control': 'no-cache', + }); + res.end(data); + } catch { + res.writeHead(404, { 'Content-Type': 'text/plain' }).end('Not found'); + } +} + +// ─── API handlers ──────────────────────────────────────────────────────────── + +function handleRegistryList(res: http.ServerResponse) { + const circuits = Object.values(AMACI_CIRCUITS).map((entry) => ({ + label: entry.label, + production: entry.production, + source: entry.source, + params: entry.params, + })); + sendJson(res, 200, { circuits, networks: NETWORK_DEFAULTS }); +} + +function handleRegistryShow(power: string, res: http.ServerResponse) { + const entry = AMACI_CIRCUITS[power]; + if (!entry) { + sendJson(res, 404, { error: `Circuit power "${power}" not found in aMACI registry.` }); + return; + } + sendJson(res, 200, entry); +} + +async function handleRegistryCheck(url: URL, res: http.ServerResponse) { + const contract = url.searchParams.get('contract'); + const network = parseNetwork(url.searchParams.get('network')); + const rpc = url.searchParams.get('rpc') ?? undefined; + + if (!contract) { + sendJson(res, 400, { error: 'Missing required query param: contract' }); + return; + } + if (!network) { + sendJson(res, 400, { error: 'Invalid network. Use "mainnet" or "testnet".' }); + return; + } + + try { + const result = await runRegistryCheck(contract, network, rpc); + sendJson(res, 200, result); + } catch (err) { + sendJson(res, 502, { error: err instanceof Error ? err.message : String(err) }); + } +} + +async function handleVerify( + url: URL, + req: http.IncomingMessage, + res: http.ServerResponse +) { + const contract = url.searchParams.get('contract'); + const network = parseNetwork(url.searchParams.get('network')); + const recheck = url.searchParams.get('recheck') === 'true'; + const rpc = url.searchParams.get('rpc') ?? undefined; + const indexer = url.searchParams.get('indexer') ?? undefined; + + if (!contract) { + sendJson(res, 400, { error: 'Missing required query param: contract' }); + return; + } + if (!network) { + sendJson(res, 400, { error: 'Invalid network. Use "mainnet" or "testnet".' }); + return; + } + + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + + let clientGone = false; + req.on('close', () => { + clientGone = true; + }); + + const send = (event: string, data: unknown) => { + if (clientGone) return; + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + }; + + const onEvent = (event: PipelineEvent) => send(event.type, event); + + // The pipeline keeps running even if the client disconnects mid-way; + // events are simply dropped. This keeps the implementation simple and + // matches the read-only nature of the work. + const result = await runRoundVerification( + { contract, network, rpc, indexer, recheck }, + onEvent + ); + + send('result', result); + if (!clientGone) res.end(); +} + +// ─── Server ────────────────────────────────────────────────────────────────── + +export type UiServer = { + server: http.Server; + port: number; + url: string; +}; + +/** + * Start the local UI server. If the preferred port is taken, retries on + * incrementally higher ports (up to 20 attempts). + */ +export function startUiServer(preferredPort: number): Promise { + const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost'); + + try { + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'text/plain' }).end('Method not allowed'); + return; + } + + if (url.pathname === '/api/registry') { + handleRegistryList(res); + } else if (url.pathname.startsWith('/api/registry/check')) { + await handleRegistryCheck(url, res); + } else if (url.pathname.startsWith('/api/registry/')) { + const power = decodeURIComponent(url.pathname.slice('/api/registry/'.length)); + handleRegistryShow(power, res); + } else if (url.pathname === '/api/verify') { + await handleVerify(url, req, res); + } else if (url.pathname.startsWith('/api/')) { + sendJson(res, 404, { error: 'Unknown API endpoint' }); + } else { + await serveStatic(url, res); + } + } catch (err) { + if (!res.headersSent) { + sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) }); + } else { + res.end(); + } + } + }); + + const MAX_ATTEMPTS = 20; + + return new Promise((resolve, reject) => { + let attempt = 0; + + const tryListen = (port: number) => { + server.once('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE' && attempt < MAX_ATTEMPTS) { + attempt += 1; + tryListen(port + 1); + } else { + reject(err); + } + }); + server.listen(port, '127.0.0.1', () => { + server.removeAllListeners('error'); + resolve({ server, port, url: `http://127.0.0.1:${port}` }); + }); + }; + + tryListen(preferredPort); + }); +} diff --git a/packages/cli/src/web/app.js b/packages/cli/src/web/app.js new file mode 100644 index 0000000..acb0c2a --- /dev/null +++ b/packages/cli/src/web/app.js @@ -0,0 +1,382 @@ +/* maci ui frontend — vanilla JS, no build step */ + +(() => { + 'use strict'; + + const $ = (sel) => document.querySelector(sel); + + // ── Tabs ────────────────────────────────────────────────────────────────── + + document.querySelectorAll('.tab').forEach((btn) => { + btn.addEventListener('click', () => { + document.querySelectorAll('.tab').forEach((b) => b.classList.toggle('active', b === btn)); + document.querySelectorAll('.tab-panel').forEach((p) => { + p.classList.toggle('active', p.id === `tab-${btn.dataset.tab}`); + }); + }); + }); + + // ── Helpers ─────────────────────────────────────────────────────────────── + + function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + function badge(kind, label) { + return `${label}`; + } + + // Animate any standalone integers inside `text` from 0 to their final value. + function countUp(node, text, dur = 700) { + const final = String(text); + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const parts = final.split(/(\d[\d,]*)/); + const targets = parts.map((p) => + /^\d/.test(p) ? parseInt(p.replace(/,/g, ''), 10) : null + ); + if (reduced || !targets.some((t) => t !== null && t > 0)) { + node.textContent = final; + return; + } + const start = performance.now(); + function frame(now) { + const t = Math.min(1, (now - start) / dur); + const eased = 1 - Math.pow(1 - t, 3); + node.textContent = parts + .map((p, i) => (targets[i] === null ? p : String(Math.round(targets[i] * eased)))) + .join(''); + if (t < 1) requestAnimationFrame(frame); + else node.textContent = final; + } + requestAnimationFrame(frame); + } + + function matchBadge(value) { + if (value === null || value === undefined) return badge('na', 'N/A'); + return value ? badge('pass', 'MATCH') : badge('fail', 'MISMATCH'); + } + + // ── Round verification ──────────────────────────────────────────────────── + + const verifyForm = $('#verify-form'); + const verifyBtn = $('#verify-btn'); + const banner = $('#verify-banner'); + const errorCard = $('#verify-error'); + const stepsCard = $('#steps-card'); + const stepsList = $('#steps'); + const summaryCard = $('#summary-card'); + const summaryEl = $('#summary'); + const reportCard = $('#report-card'); + const checksBody = $('#checks'); + + let currentSource = null; + let lastResult = null; + + function resetVerifyView() { + banner.classList.add('hidden'); + errorCard.classList.add('hidden'); + summaryCard.classList.add('hidden'); + reportCard.classList.add('hidden'); + stepsList.innerHTML = ''; + summaryEl.innerHTML = ''; + checksBody.innerHTML = ''; + stepsCard.classList.remove('hidden'); + lastResult = null; + } + + function stepRow(step) { + let li = stepsList.querySelector(`li[data-step="${step}"]`); + if (!li) { + li = el('li'); + li.dataset.step = String(step); + li.appendChild(el('span', 'step-icon')); + li.appendChild(el('span', 'step-label')); + li.appendChild(el('span', 'step-detail')); + stepsList.appendChild(li); + } + return li; + } + + function renderSummary(summary) { + const fields = [ + ['Contract', summary.contractAddress], + ['Network', summary.network], + ['Circuit', `${summary.circuitPower} (${summary.circuitName})`], + ['Status', summary.status], + ['Operator', summary.operatorAddress], + ['Voting', `${summary.votingStart} \u2192 ${summary.votingEnd}`], + ['Sign-ups', `on-chain ${summary.signUpsOnChain} / indexed ${summary.signUpsIndexed}`], + ['Messages', `on-chain ${summary.messagesOnChain} / indexed ${summary.messagesIndexed}`], + ]; + summaryEl.innerHTML = ''; + for (const [k, v] of fields) { + summaryEl.appendChild(el('dt', null, k)); + const dd = el('dd'); + if (k === 'Sign-ups' || k === 'Messages') countUp(dd, v); + else dd.textContent = v; + summaryEl.appendChild(dd); + } + summaryCard.classList.remove('hidden'); + } + + function renderReport(report) { + checksBody.innerHTML = ''; + for (const check of report.checks) { + const tr = el('tr'); + const tdLabel = el('td'); + tdLabel.appendChild(el('div', 'check-label', check.label)); + if (check.detail) tdLabel.appendChild(el('div', 'check-detail', check.detail)); + const tdStatus = el('td'); + tdStatus.innerHTML = check.passed ? badge('pass', 'PASS') : badge('fail', 'FAIL'); + tr.appendChild(tdLabel); + tr.appendChild(tdStatus); + checksBody.appendChild(tr); + } + reportCard.classList.remove('hidden'); + + banner.textContent = report.overallPassed ? 'Result: VERIFIED \u2713' : 'Result: FAILED \u2717'; + banner.className = `banner ${report.overallPassed ? 'pass' : 'fail'}`; + } + + function finishVerify() { + verifyBtn.disabled = false; + verifyBtn.textContent = 'Verify round'; + if (currentSource) { + currentSource.close(); + currentSource = null; + } + } + + verifyForm.addEventListener('submit', (e) => { + e.preventDefault(); + if (currentSource) currentSource.close(); + + const params = new URLSearchParams({ + contract: $('#contract').value.trim(), + network: $('#network').value, + recheck: $('#recheck').checked ? 'true' : 'false', + }); + const rpc = $('#rpc').value.trim(); + const indexer = $('#indexer').value.trim(); + if (rpc) params.set('rpc', rpc); + if (indexer) params.set('indexer', indexer); + + resetVerifyView(); + verifyBtn.disabled = true; + verifyBtn.textContent = 'Verifying\u2026'; + + const source = new EventSource(`/api/verify?${params}`); + currentSource = source; + + source.addEventListener('step:start', (e) => { + const ev = JSON.parse(e.data); + const li = stepRow(ev.step); + li.className = 'running'; + li.querySelector('.step-label').textContent = `[${ev.step}/${ev.total}] ${ev.label}`; + }); + + source.addEventListener('step:update', (e) => { + const ev = JSON.parse(e.data); + stepRow(ev.step).querySelector('.step-detail').textContent = ` ${ev.detail}`; + }); + + source.addEventListener('step:done', (e) => { + const ev = JSON.parse(e.data); + const li = stepRow(ev.step); + li.className = 'done'; + li.querySelector('.step-icon').textContent = '\u2713'; + if (ev.detail) li.querySelector('.step-detail').textContent = ` ${ev.detail}`; + }); + + source.addEventListener('step:fail', (e) => { + const ev = JSON.parse(e.data); + const li = stepRow(ev.step); + li.className = 'fail'; + li.querySelector('.step-icon').textContent = '\u2717'; + if (ev.detail) li.querySelector('.step-detail').textContent = ` ${ev.detail}`; + }); + + source.addEventListener('summary', (e) => { + renderSummary(JSON.parse(e.data).summary); + }); + + source.addEventListener('report', (e) => { + renderReport(JSON.parse(e.data).report); + }); + + source.addEventListener('result', (e) => { + const result = JSON.parse(e.data); + lastResult = result; + if (result.status === 'error') { + errorCard.textContent = `Error: ${result.message}`; + errorCard.classList.remove('hidden'); + } + finishVerify(); + }); + + source.onerror = () => { + // EventSource fires error on normal stream close too; only report if + // we never received a terminal "result" event. + if (currentSource === source && lastResult === null) { + errorCard.textContent = 'Error: connection to local server lost.'; + errorCard.classList.remove('hidden'); + } + finishVerify(); + }; + }); + + $('#export-btn').addEventListener('click', () => { + if (!lastResult || lastResult.status !== 'completed') return; + const blob = new Blob([JSON.stringify(lastResult, null, 2)], { type: 'application/json' }); + const a = el('a'); + a.href = URL.createObjectURL(blob); + const addr = lastResult.report.contractAddress; + a.download = `maci-verification-${addr.slice(0, 12)}-${Date.now()}.json`; + a.click(); + URL.revokeObjectURL(a.href); + }); + + // ── Registry ────────────────────────────────────────────────────────────── + + const registryRows = $('#registry-rows'); + const circuitDetail = $('#circuit-detail'); + + async function loadRegistry() { + try { + const res = await fetch('/api/registry'); + const data = await res.json(); + registryRows.innerHTML = ''; + for (const c of data.circuits) { + const tr = el('tr'); + tr.dataset.power = c.label; + tr.innerHTML = + `${c.label}` + + `${c.production ? badge('pass', 'production') : badge('warn', 'test-only')}` + + `${c.params.stateTreeDepth}` + + `${c.params.intStateTreeDepth}` + + `${c.params.voteOptionTreeDepth}` + + `${c.params.messageBatchSize}`; + tr.addEventListener('click', () => showCircuit(c.label)); + registryRows.appendChild(tr); + } + } catch { + registryRows.innerHTML = 'Failed to load registry.'; + } + } + + async function showCircuit(power) { + const res = await fetch(`/api/registry/${encodeURIComponent(power)}`); + if (!res.ok) return; + const entry = await res.json(); + + circuitDetail.innerHTML = ''; + circuitDetail.appendChild(el('h3', null, `Circuit Detail: ${entry.label}`)); + + const kv = el('dl', 'kv-grid'); + const rows = [ + ['Status', entry.production ? 'production' : 'test-only'], + ['Source', entry.source], + ['Zkey URL', entry.zkeyUrl], + ['Zkey SHA-256', entry.zkeyTarSha256], + ['state_tree_depth', String(entry.params.stateTreeDepth)], + ['int_state_tree_depth', String(entry.params.intStateTreeDepth)], + ['vote_option_tree_depth', String(entry.params.voteOptionTreeDepth)], + ['message_batch_size', String(entry.params.messageBatchSize)], + ]; + for (const [k, v] of rows) { + kv.appendChild(el('dt', null, k)); + const dd = el('dd'); + if (k === 'Zkey URL') { + const a = el('a', null, v); + a.href = v; + a.target = '_blank'; + a.rel = 'noopener'; + dd.appendChild(a); + } else { + dd.textContent = v; + } + kv.appendChild(dd); + } + circuitDetail.appendChild(kv); + + const vkeyNames = [ + ['process', 'Process vkey (Groth16)'], + ['tally', 'Tally vkey (Groth16)'], + ['deactivate', 'Deactivate vkey (Groth16)'], + ['addNewKey', 'AddNewKey vkey (Groth16)'], + ]; + for (const [key, title] of vkeyNames) { + const group = el('div', 'vkey-group'); + group.appendChild(el('h4', null, title)); + for (const [field, value] of Object.entries(entry.vkeys[key])) { + const line = el('div', 'vkey-line'); + line.innerHTML = `${field}: ${value.slice(0, 48)}\u2026`; + line.title = value; + group.appendChild(line); + } + circuitDetail.appendChild(group); + } + + circuitDetail.classList.remove('hidden'); + circuitDetail.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + + // ── Registry check ──────────────────────────────────────────────────────── + + const checkForm = $('#check-form'); + const checkBtn = $('#check-btn'); + const checkResult = $('#check-result'); + + checkForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const contract = $('#check-contract').value.trim(); + const network = $('#check-network').value; + if (!contract) return; + + checkBtn.disabled = true; + checkBtn.textContent = 'Checking\u2026'; + checkResult.classList.add('hidden'); + + try { + const params = new URLSearchParams({ contract, network }); + const res = await fetch(`/api/registry/check?${params}`); + const data = await res.json(); + + if (!res.ok) { + checkResult.innerHTML = `
${data.error ?? 'Request failed'}
`; + } else { + const statusBadge = data.production + ? badge('pass', 'production') + : badge('warn', data.found ? 'test/legacy' : 'unknown'); + const kv = [ + ['Circuit power', `${data.power} ${statusBadge}`], + ['Source', `${data.source}`], + ['Process vkey', matchBadge(data.processMatch)], + ['Tally vkey', matchBadge(data.tallyMatch)], + ['Deactivate vkey', matchBadge(data.deactivateMatch)], + ['AddNewKey vkey', matchBadge(data.addNewKeyMatch)], + ]; + let html = '
'; + for (const [k, v] of kv) html += `
${k}
${v}
`; + html += '
'; + if (!data.found) { + html += + '

Warning: no matching circuit found in the aMACI registry. ' + + 'This contract may use an unregistered or custom circuit.

'; + } + checkResult.innerHTML = html; + } + } catch (err) { + checkResult.innerHTML = `
${err.message ?? String(err)}
`; + } + + checkResult.classList.remove('hidden'); + checkBtn.disabled = false; + checkBtn.textContent = 'Check vkeys'; + }); + + loadRegistry(); +})(); diff --git a/packages/cli/src/web/index.html b/packages/cli/src/web/index.html new file mode 100644 index 0000000..7c3bd76 --- /dev/null +++ b/packages/cli/src/web/index.html @@ -0,0 +1,137 @@ + + + + + + maci ui — aMACI Verifier + + + +
+
+ maci + aMACI round verifier · read-only +
+ +
+ +
+ +
+
+
+ + +
+
+
+ + +
+
+ +
+
+
+ Advanced endpoints +
+ + +
+
+ + +
+
+
+ +
+
+ + + + + + + + + + +
+ + +
+
+

Known aMACI Circuits

+

vkeys built into this CLI, sourced from circuit_params.rs. Click a row for details.

+ + + + + + + + +
PowerStatusstateintvotebatch
+ +
+ +
+

Check a contract's vkeys

+

Reads vkeys from the contract on-chain and compares them against the registry.

+
+ + +
+
+
+ + +
+
+
+ +
+ +
+
+
+ +
+ Local read-only verifier — no private keys, no transactions. Anyone can audit. +
+ + + + diff --git a/packages/cli/src/web/protocol-i18n.js b/packages/cli/src/web/protocol-i18n.js new file mode 100644 index 0000000..1528594 --- /dev/null +++ b/packages/cli/src/web/protocol-i18n.js @@ -0,0 +1,362 @@ +/* Bilingual copy for the aMACI protocol explainer. + * Content distilled from aMACI_EXTERNAL.md. Keys map to data-i18n attributes. */ + +window.PROTOCOL_I18N = { + en: { + 'nav.subtitle': 'aMACI protocol explainer', + 'nav.back': '\u2190 Dashboard', + footer: 'Local read-only verifier \u2014 no private keys, no transactions. Anyone can audit.', + + 'hero.kicker': 'Protocol Explainer', + 'hero.title': 'aMACI: Anonymous, Anti-Collusion On-Chain Voting', + 'hero.lead': + 'aMACI (Anonymous MACI) extends the original MACI protocol: voters cast verifiable on-chain votes without revealing who they are \u2014 no wallet, no key management, no identity trail. This page walks through the protocol design, the infrastructure that runs it, and the trust boundaries under different configurations.', + 'hero.scroll': 'Scroll to explore \u2193', + + 's1.title': 'Why on-chain voting is hard to get right', + 's1.lead': + 'On-chain voting must deliver two properties that pull against each other. The blockchain naturally provides the first; the second is subtle \u2014 and a transparent chain actively works against it.', + 's1.cardA.title': 'Blockchain guarantees', + 's1.cardA.body': + 'Results execute correctly and no message can be censored. As long as the contract logic is right, nobody can unilaterally forge an outcome or block a legitimate vote.', + 's1.cardB.title': 'Anti-collusion', + 's1.cardB.body': + 'A voter must NOT be able to credibly prove how they voted. If you can prove your vote, you can sell that proof \u2014 and bribery becomes a viable strategy.', + 's1.bribe1': 'Every vote is public on a transparent chain', + 's1.bribe2': '\u201cYou voted A\u201d is verifiable by anyone', + 's1.bribe3': 'Bribery becomes enforceable', + + 's2.title': 'MACI\u2019s answer: a deniable key change', + 's2.lead': + 'MACI (Vitalik Buterin, 2019) resolves the tension: a voter can switch their voting key at any time, invalidating earlier messages \u2014 and the switch is invisible from outside. Publishing \u201cvote A\u201d proves nothing: the key may already have been rotated, silently.', + 's2.chainLabel': 'on-chain messages (encrypted)', + 's2.voided': 'invalidated', + 's2.keyMsg': 'enc(key change)', + 's2.briber': 'Was that the final vote? Impossible to tell.', + 's2.note': + 'One problem remains: in original MACI the voter\u2019s public key is registered openly on-chain. Even with encrypted votes, who participated stays visible \u2014 enough for targeted pressure or retaliation if the registered identity can be linked to a real person.', + + 's3.title': 'aMACI\u2019s core move: deactivate & add-key', + 's3.lead': + 'aMACI replaces the simple key change with a stronger mechanism. A voter can anonymously deactivate their current account, then re-join as a cryptographically unlinkable new identity: holding the old key of a deactivated slot, they build a ZK proof \u2014 \u201cI hold a valid, unused deactivated key in the tree\u201d \u2014 without revealing which one, and register a brand-new voting key K_i.', + 's3.deactTree': 'Deactivate tree', + 's3.stateTree': 'State tree', + 's3.dkey': 'deactivated key', + 's3.nullifier': '+ nullifier: each deactivated key usable once', + 's3.replay': 'Replay', + 's3.cut': + 'The ZK proof\u2019s hiding property severs the link: no observer \u2014 not even the coordinator \u2014 can map the new K_i back to the deactivated key it came from.', + 's3.compare.maci': + 'chain records \u201caddress X \u2192 pubkey K_i\u201d \u2014 voter identity visible', + 's3.compare.amaci': + 'chain records \u201cvalid ZK proof \u2192 new pubkey K_i\u201d \u2014 voter identity hidden', + + 's4.title': 'One round, five phases', + 's4.lead': + 'Click through the phases to see who does what. The contract is the source of truth throughout; the coordinator processes encrypted messages off-chain but every state change it makes must be proven on-chain.', + 's4.actor.voter': 'Voter', + 's4.actor.chain': 'aMACI contract', + 's4.actor.coord': 'Coordinator', + 's4.p0.name': 'Setup', + 's4.p1.name': 'Add-key', + 's4.p2.name': 'Vote', + 's4.p3.name': 'Tally', + 's4.p4.name': 'Result', + 's4.p0.desc': + 'The coordinator publishes its public key K_c. The contract initializes an empty state tree, stores the deactivate tree root (the verification anchor for later add-key proofs), and fixes the voting window T_start \u2192 T_end plus options and parameters.', + 's4.p1.desc': + 'Mandatory before voting: each voter obtains a deactivated key, generates a fresh voting key pair (k_i, K_i) locally, and submits an add-key ZK proof. On verification, K_i enters the state tree \u2014 an active, anonymous voting account.', + 's4.p2.desc': + 'The voter signs the vote with k_i, encrypts it to the coordinator\u2019s key K_c, and publishes it on-chain. Votes can be changed any time before T_end \u2014 last write wins, and nobody outside can tell whether a message was superseded.', + 's4.p3.desc': + 'After T_end, the coordinator decrypts all messages in on-chain order, applies last-write-wins per account, computes the final tally, and generates two ZK proofs: a process proof (\u201cI updated state correctly\u201d) and a tally proof (\u201cI counted correctly\u201d).', + 's4.p4.desc': + 'The contract independently recomputes the public input hash from on-chain data and verifies both proofs with its built-in verification keys. Only then is the tally accepted. Anyone can re-verify \u2014 that is exactly what this dashboard\u2019s Round Verification does.', + 's4.flow.setup': 'K_c \u00b7 deactivate root \u00b7 T_start/T_end', + 's4.flow.addkey': 'ZK proof + new K_i', + 's4.flow.vote': 'enc(sign(vote, k_i), K_c)', + 's4.flow.read': 'read all messages, decrypt with k_c', + 's4.flow.proofs': 'process proof + tally proof', + 's4.flow.verify': 'contract verifies proofs with built-in vkeys', + + 's5.title': 'From protocol to infrastructure: V1 \u2192 V3', + 's5.lead': + 'The protocol defines what is valid; the infrastructure decides how much anonymity survives contact with real users. Our design evolved in three steps, each closing a linkage path.', + 's5.v1.box': 'User wallet address', + 's5.v1.desc': + 'V1 \u2014 Web3-native: the MACI key is derived from the user\u2019s chain address. Voting identity and on-chain identity are fully bound; who participated is plain to see. A gas station fixed costs, not anonymity.', + 's5.v2.box': 'User browser \u2014 same environment', + 's5.v2.session': 'login session (email)', + 's5.v2.desc': + 'V2 \u2014 Email login + Relay: no wallet needed, the relay broadcasts all transactions. But the ZK proof was built in the user\u2019s browser \u2014 login session and aMACI key shared one environment, so an attacker inside it could link identity to key.', + 's5.v3.boxA': 'User browser', + 's5.v3.isolated': 'isolated', + 's5.v3.desc': + 'V3 \u2014 Voting Sandbox (current): all key computation moves into an isolated runtime that holds no user session and never learns who the user is. The aMACI key is generated, used, and discarded inside the sandbox. The linkage path never exists.', + 's5.e2e.webapp': + 'Knows the email \u2014 receives only the E2E-encrypted option, never the plaintext choice.', + 's5.e2e.lock': 'E2E-encrypted option', + 's5.e2e.sandbox': + 'Sees the plaintext option and runs the protocol \u2014 never knows which email it serves.', + + 's6.title': 'Trust model: who can learn what?', + 's6.lead': + 'Three services each hold one fragment of information. Toggle a coalition below and see what it could \u2014 and could not \u2014 reconstruct. The full chain email \u2192 deactivated key \u2192 K_i \u2192 vote is what an attacker would need.', + 's6.role.webapp': 'knows the email; the option is E2E-encrypted away from it', + 's6.role.relay': 'sees key-assignment timing; never sees an email', + 's6.role.operator': 'decrypts every vote; all keys are anonymous', + 's6.user': 'User actively cooperates (tries to reveal their voting key)', + 's6.node.vote': 'vote content', + 's6.link.never': 'never recorded \u2014 random assignment', + 's6.link.timing': 'timing correlation \u2014 probabilistic only', + 's6.link.zk': 'severed by ZK proof', + 's6.link.dec': 'decrypted by operator', + 's6.link.unknown': 'not observable', + 's6.v.none': 'Select one or more parties above to see what they could jointly infer.', + 's6.v.webapp': + 'Web App alone: knows who participated and when. No keys, no votes \u2014 the option left the browser E2E-encrypted.', + 's6.v.relay': + 'Relay/API alone: sees deactivated keys being handed out and K_i registrations \u2014 with no idea whose they are. Assignment is random and unrecorded.', + 's6.v.operator': + 'Operator alone: reads every vote in plaintext \u2014 every one cast by an anonymous key. No path to any identity.', + 's6.v.webapp_relay': + 'Scenario 1 \u2014 Web App + Relay/API: can attempt timing correlation (email active at T, key assigned at T\u2032). Probabilistic at best, defeated by concurrent voters, impossible to reconstruct after the fact \u2014 and still reveals no vote content.', + 's6.v.webapp_operator': + 'Web App + Operator: identity on one side, votes on the other \u2014 but the middle links (deactivated key, K_i) are missing entirely. Very limited overlap.', + 's6.v.relay_operator': + 'Relay/API + Operator: key timing plus vote content \u2014 but nothing connects either to a real identity.', + 's6.v.all': + 'Scenario 2 \u2014 full three-party coalition: email \u2192 (timing guess) \u2192 K_i \u2192 vote. The most complete attack possible, yet still probabilistic, still requires live cross-system monitoring during the vote, and can never be reconstructed afterwards. The larger the anonymity set, the less reliable it gets.', + 's6.v.user': + 'Scenarios 3/4 \u2014 even with the user cooperating: the voting key is generated and used inside the Voting Sandbox and never leaves it. There is no K_i for the user to hand over \u2014 and the overwrite mechanism makes any \u201cproof of vote\u201d unreliable anyway.', + + 's7.title': 'Anonymity set: how strong in practice?', + 's7.lead': + 'The ZK guarantee is constant, but timing-correlation risk depends on how many people vote concurrently. The gap between the two curves is what trust configuration must cover. Drag the slider.', + 's7.crypto': 'Cryptographic anonymity (ZK proof) \u2014 ceiling', + 's7.timing': 'Timing-correlation risk \u2014 fades with scale', + 's7.effective': 'Effective anonymity \u2014 rises with set size', + 's7.gap': 'timing-correlation risk (the gap)', + 's7.ylabel': 'participation anonymity', + 's7.xlabel': 'anonymity set size (concurrent voters)', + 's7.scenario': 'Scenario', + 's7.risk': 'Timing risk', + 's7.config': 'Recommended setup', + 's7.scenarios': [ + 'Board decision (\u22645 voters)', + 'Security council vote (~10)', + 'DAO quarterly budget (~60)', + 'Protocol governance (500+)', + 'National election (100k+)', + ], + 's7.risks': [ + 'Very high \u2014 near-certain correlation', + 'High', + 'Medium \u2014 narrows at peak hours', + 'Low', + 'Very low', + ], + 's7.configs': [ + 'Independent operator; disclose weak participation anonymity', + 'Independent operator (strongly advised)', + 'Standard, or independent operator', + 'Standard configuration', + 'Three-way independent (platform / operator / organizer split)', + ], + + 's8.title': 'Choosing a trust configuration', + 's8.lead': + 'Replacing wallets with email login introduced two platform services outside the protocol \u2014 that is the explicit price paid for zero Web3 friction. The configurable part is how many independent parties must collude to link identity to vote.', + 's8.th.config': 'Configuration', + 's8.th.collude': 'Parties needed to collude', + 's8.row1.name': 'Standard (Dora full-stack)', + 's8.row2.name': 'Independent operator', + 's8.row3.name': 'Three-way independent', + 's8.thirdparty': '3rd party', + 's8.opA': 'Operator A', + 's8.opB': 'Operator B', + 's8.final': + 'Whatever the configuration, the final guarantee lives on-chain: every tally is verified by ZK proofs inside the contract. No operator \u2014 honest or not \u2014 can forge a result without detection.', + 's8.cta': 'Verify a round yourself \u2192', + 's8.reading': 'Further reading', + }, + + zh: { + 'nav.subtitle': 'aMACI \u534f\u8bae\u52a8\u6548\u8bb2\u89e3', + 'nav.back': '\u2190 \u8fd4\u56de\u4eea\u8868\u76d8', + footer: '\u672c\u5730\u53ea\u8bfb\u9a8c\u8bc1\u5668 \u2014 \u65e0\u79c1\u94a5\u3001\u65e0\u4ea4\u6613\uff0c\u4efb\u4f55\u4eba\u90fd\u53ef\u5ba1\u8ba1\u3002', + + 'hero.kicker': '\u534f\u8bae\u8bb2\u89e3', + 'hero.title': 'aMACI\uff1a\u533f\u540d\u6297\u5171\u8c0b\u7684\u94fe\u4e0a\u6295\u7968', + 'hero.lead': + 'aMACI\uff08Anonymous MACI\uff09\u662f\u5bf9\u539f\u59cb MACI \u534f\u8bae\u7684\u6269\u5c55\uff1avoter \u65e0\u9700\u66b4\u9732\u8eab\u4efd\u5373\u53ef\u5b8c\u6210\u53ef\u9a8c\u8bc1\u7684\u94fe\u4e0a\u6295\u7968\uff0c\u65e0\u9700\u94b1\u5305\u3001\u65e0\u9700\u7ba1\u7406\u79c1\u94a5\u3001\u4e0d\u7559\u8eab\u4efd\u75d5\u8ff9\u3002\u672c\u9875\u9010\u6b65\u8bb2\u89e3\u534f\u8bae\u8bbe\u8ba1\u3001\u57fa\u7840\u8bbe\u65bd\u6f14\u8fdb\uff0c\u4ee5\u53ca\u4e0d\u540c\u4fe1\u4efb\u914d\u7f6e\u4e0b\u7684\u5b89\u5168\u8fb9\u754c\u3002', + 'hero.scroll': '\u5411\u4e0b\u6eda\u52a8\u63a2\u7d22 \u2193', + + 's1.title': '\u94fe\u4e0a\u6295\u7968\u4e3a\u4ec0\u4e48\u96be\u4ee5\u505a\u5bf9', + 's1.lead': + '\u94fe\u4e0a\u6295\u7968\u9700\u8981\u540c\u65f6\u6ee1\u8db3\u4e24\u4ef6\u4e92\u76f8\u5f20\u529b\u7684\u4e8b\u3002\u7b2c\u4e00\u4ef6\u662f\u533a\u5757\u94fe\u5929\u7136\u63d0\u4f9b\u7684\uff1b\u7b2c\u4e8c\u4ef6\u975e\u5e38\u5fae\u5999\u2014\u2014\u900f\u660e\u7684\u94fe\u53cd\u800c\u4f1a\u7834\u574f\u5b83\u3002', + 's1.cardA.title': '\u533a\u5757\u94fe\u4fdd\u8bc1', + 's1.cardA.body': + '\u6267\u884c\u7ed3\u679c\u6b63\u786e\uff0c\u6d88\u606f\u4e0d\u88ab\u5ba1\u67e5\u3002\u53ea\u8981\u5408\u7ea6\u903b\u8f91\u6b63\u786e\uff0c\u4efb\u4f55\u4eba\u90fd\u65e0\u6cd5\u5355\u65b9\u9762\u4f2a\u9020\u7ed3\u679c\u6216\u5c4f\u853d\u5408\u6cd5\u6d88\u606f\u3002', + 's1.cardB.title': '\u6297\u5171\u8c0b', + 's1.cardB.body': + '\u6295\u7968\u8005\u5fc5\u987b\u65e0\u6cd5\u5411\u7b2c\u4e09\u65b9\u53ef\u4fe1\u5730\u8bc1\u660e\u81ea\u5df1\u600e\u4e48\u6295\u7684\u7968\u3002\u5982\u679c\u80fd\u8bc1\u660e\uff0c\u5c31\u80fd\u51fa\u552e\u8fd9\u4e2a\u8bc1\u660e\u2014\u2014\u8d3f\u8d42\u5c31\u53d8\u5f97\u53ef\u884c\u3002', + 's1.bribe1': '\u900f\u660e\u94fe\u4e0a\u6bcf\u4e00\u7968\u90fd\u516c\u5f00\u53ef\u67e5', + 's1.bribe2': '\u4efb\u4f55\u4eba\u90fd\u80fd\u9a8c\u8bc1\u201c\u4f60\u6295\u4e86 A\u201d', + 's1.bribe3': '\u8d3f\u8d42\u53d8\u5f97\u53ef\u6267\u884c', + + 's2.title': 'MACI \u7684\u7b54\u6848\uff1a\u53ef\u5426\u8ba4\u7684\u6362 key', + 's2.lead': + 'MACI\uff08Vitalik Buterin\uff0c2019\uff09\u89e3\u51b3\u4e86\u8fd9\u4e2a\u77db\u76fe\uff1a\u7528\u6237\u53ef\u4ee5\u968f\u65f6\u66f4\u6362\u6295\u7968\u5bc6\u94a5\uff0c\u4f7f\u4e4b\u524d\u7684\u6d88\u606f\u5931\u6548\uff0c\u800c\u8fd9\u4e2a\u64cd\u4f5c\u5bf9\u5916\u754c\u4e0d\u53ef\u89c1\u3002\u53d1\u5e03\u201c\u6295 A\u201d\u4ec0\u4e48\u4e5f\u8bc1\u660e\u4e0d\u4e86\uff1akey \u53ef\u80fd\u65e9\u5df2\u88ab\u6084\u6084\u6362\u6389\u3002', + 's2.chainLabel': '\u94fe\u4e0a\u6d88\u606f\uff08\u5168\u7a0b\u52a0\u5bc6\uff09', + 's2.voided': '\u5df2\u4f5c\u5e9f', + 's2.keyMsg': 'enc(\u6362 key)', + 's2.briber': '\u90a3\u662f\u6700\u7ec8\u6295\u7968\u5417\uff1f\u65e0\u4ece\u5224\u65ad\u3002', + 's2.note': + '\u4f46\u8fd8\u6709\u4e00\u4e2a\u9057\u7559\u95ee\u9898\uff1a\u539f\u59cb MACI \u4e2d voter \u7684\u516c\u94a5\u662f\u516c\u5f00\u6ce8\u518c\u5230\u94fe\u4e0a\u7684\u3002\u5373\u4f7f\u6295\u7968\u5185\u5bb9\u52a0\u5bc6\uff0c\u201c\u8c01\u53c2\u4e0e\u4e86\u201d\u4ecd\u7136\u53ef\u89c1\u2014\u2014\u4e00\u65e6\u6ce8\u518c\u8eab\u4efd\u80fd\u5173\u8054\u5230\u771f\u5b9e\u8eab\u4efd\uff0c\u5b9a\u5411\u65bd\u538b\u548c\u4e8b\u540e\u8ffd\u8d23\u5c31\u6709\u4e86\u4f9d\u636e\u3002', + + 's3.title': 'aMACI \u7684\u6838\u5fc3\u521b\u65b0\uff1adeactivate \u4e0e add-key', + 's3.lead': + 'aMACI \u7528\u66f4\u5f3a\u7684\u673a\u5236\u53d6\u4ee3\u7b80\u5355\u6362 key\uff1avoter \u53ef\u4ee5\u533f\u540d\u6ce8\u9500\u5f53\u524d\u8d26\u6237\uff0c\u518d\u4ee5\u4e00\u4e2a\u5bc6\u7801\u5b66\u4e0a\u5b8c\u5168\u4e0d\u53ef\u5173\u8054\u7684\u65b0\u8eab\u4efd\u91cd\u65b0\u53c2\u4e0e\u2014\u2014\u6301\u6709\u67d0\u4e2a\u5df2\u6ce8\u9500\u69fd\u4f4d\u7684\u65e7\u79c1\u94a5\uff0c\u6784\u9020 ZK proof\u201c\u6211\u6301\u6709 deactivate tree \u4e2d\u5408\u6cd5\u4e14\u672a\u4f7f\u7528\u7684 deactivated key\u201d\uff0c\u4f46\u4e0d\u6cc4\u9732\u662f\u54ea\u4e00\u4e2a\uff0c\u540c\u65f6\u6ce8\u518c\u5168\u65b0\u7684\u6295\u7968\u516c\u94a5 K_i\u3002', + 's3.deactTree': 'Deactivate tree', + 's3.stateTree': 'State tree', + 's3.dkey': 'deactivated key', + 's3.nullifier': '+ nullifier\uff1a\u6bcf\u4e2a deactivated key \u53ea\u80fd\u7528\u4e00\u6b21', + 's3.replay': '\u91cd\u64ad', + 's3.cut': + 'ZK proof \u7684\u9690\u85cf\u5c5e\u6027\u526a\u65ad\u4e86\u8fd9\u6761\u5173\u8054\uff1a\u4efb\u4f55\u89c2\u5bdf\u8005\u2014\u2014\u5305\u62ec coordinator\u2014\u2014\u90fd\u65e0\u6cd5\u4ece\u65b0\u7684 K_i \u53cd\u63a8\u51fa\u5b83\u5bf9\u5e94\u54ea\u4e2a deactivated key\u3002', + 's3.compare.maci': + '\u94fe\u4e0a\u8bb0\u5f55\u201c\u5730\u5740 X \u2192 \u516c\u94a5 K_i\u201d\u2014\u2014voter \u8eab\u4efd\u53ef\u89c1', + 's3.compare.amaci': + '\u94fe\u4e0a\u8bb0\u5f55\u201cZK proof \u5408\u6cd5 \u2192 \u65b0\u516c\u94a5 K_i\u201d\u2014\u2014voter \u8eab\u4efd\u4e0d\u53ef\u89c1', + + 's4.title': '\u4e00\u8f6e\u6295\u7968\uff0c\u4e94\u4e2a\u9636\u6bb5', + 's4.lead': + '\u70b9\u51fb\u5404\u9636\u6bb5\u67e5\u770b\u8c01\u5728\u505a\u4ec0\u4e48\u3002\u5408\u7ea6\u59cb\u7ec8\u662f\u4e8b\u5b9e\u6e90\uff1bcoordinator \u5728\u94fe\u4e0b\u5904\u7406\u52a0\u5bc6\u6d88\u606f\uff0c\u4f46\u5b83\u7684\u6bcf\u4e00\u6b65\u72b6\u6001\u53d8\u66f4\u90fd\u5fc5\u987b\u5728\u94fe\u4e0a\u51fa\u793a\u8bc1\u660e\u3002', + 's4.actor.voter': 'Voter', + 's4.actor.chain': 'aMACI \u5408\u7ea6', + 's4.actor.coord': 'Coordinator', + 's4.p0.name': 'Setup \u521d\u59cb\u5316', + 's4.p1.name': 'Add-key \u6ce8\u518c', + 's4.p2.name': 'Vote \u6295\u7968', + 's4.p3.name': 'Tally \u8ba1\u7968', + 's4.p4.name': 'Result \u7ed3\u679c', + 's4.p0.desc': + 'Coordinator \u516c\u5e03\u516c\u94a5 K_c\u3002\u5408\u7ea6\u521b\u5efa\u7a7a\u7684 state tree\uff0c\u5199\u5165 deactivate tree \u6839\u54c8\u5e0c\uff08\u540e\u7eed add-key proof \u7684\u9a8c\u8bc1\u57fa\u51c6\uff09\uff0c\u8bbe\u5b9a\u6295\u7968\u7a97\u53e3 T_start \u2192 T_end \u53ca\u9009\u9879\u53c2\u6570\u3002', + 's4.p1.desc': + '\u6295\u7968\u524d\u7684\u5f3a\u5236\u524d\u63d0\uff1avoter \u83b7\u53d6\u4e00\u4e2a deactivated key\uff0c\u672c\u5730\u751f\u6210\u65b0\u7684\u6295\u7968\u5bc6\u94a5\u5bf9 (k_i, K_i)\uff0c\u63d0\u4ea4 add-key ZK proof\u3002\u9a8c\u8bc1\u901a\u8fc7\u540e K_i \u8fdb\u5165 state tree\u2014\u2014\u4e00\u4e2a\u6fc0\u6d3b\u4e14\u533f\u540d\u7684\u6295\u7968\u8d26\u6237\u3002', + 's4.p2.desc': + 'Voter \u7528 k_i \u7b7e\u540d\u6295\u7968\u6570\u636e\uff0c\u4ee5 K_c \u52a0\u5bc6\u540e\u4e0a\u94fe\u3002T_end \u4e4b\u524d\u53ef\u968f\u65f6\u6539\u7968\u2014\u2014\u540e\u5230\u4f18\u5148\uff08last-write-wins\uff09\uff0c\u5916\u754c\u65e0\u6cd5\u5f97\u77e5\u67d0\u6761\u6d88\u606f\u662f\u5426\u5df2\u88ab\u8986\u76d6\u3002', + 's4.p3.desc': + 'T_end \u4e4b\u540e\uff0ccoordinator \u6309\u94fe\u4e0a\u987a\u5e8f\u9010\u6761\u89e3\u5bc6\u6d88\u606f\uff0c\u6309\u8d26\u6237\u5e94\u7528 last-write-wins\uff0c\u8ba1\u7b97\u6700\u7ec8 tally\uff0c\u5e76\u751f\u6210\u4e24\u7c7b ZK proof\uff1aprocess proof\uff08\u201c\u6211\u6b63\u786e\u66f4\u65b0\u4e86\u72b6\u6001\u201d\uff09\u548c tally proof\uff08\u201c\u6211\u6b63\u786e\u7edf\u8ba1\u4e86\u7968\u6570\u201d\uff09\u3002', + 's4.p4.desc': + '\u5408\u7ea6\u7528\u94fe\u4e0a\u5df2\u77e5\u6570\u636e\u72ec\u7acb\u91cd\u7b97\u516c\u5f00\u8f93\u5165 hash\uff0c\u7528\u5185\u7f6e verification key \u9a8c\u8bc1\u4e24\u4e2a proof\uff0c\u901a\u8fc7\u540e\u624d\u63a5\u53d7 tally\u3002\u4efb\u4f55\u4eba\u90fd\u53ef\u4ee5\u72ec\u7acb\u590d\u9a8c\u2014\u2014\u8fd9\u6b63\u662f\u672c\u4eea\u8868\u76d8 Round Verification \u505a\u7684\u4e8b\u3002', + 's4.flow.setup': 'K_c \u00b7 deactivate root \u00b7 T_start/T_end', + 's4.flow.addkey': 'ZK proof + \u65b0 K_i', + 's4.flow.vote': 'enc(sign(vote, k_i), K_c)', + 's4.flow.read': '\u8bfb\u53d6\u5168\u90e8\u6d88\u606f\uff0c\u7528 k_c \u89e3\u5bc6', + 's4.flow.proofs': 'process proof + tally proof', + 's4.flow.verify': '\u5408\u7ea6\u7528\u5185\u7f6e vkey \u9a8c\u8bc1 proof', + + 's5.title': '\u4ece\u534f\u8bae\u5230\u57fa\u7840\u8bbe\u65bd\uff1aV1 \u2192 V3', + 's5.lead': + '\u534f\u8bae\u5b9a\u4e49\u4ec0\u4e48\u662f\u5408\u6cd5\u7684\uff1b\u57fa\u7840\u8bbe\u65bd\u51b3\u5b9a\u533f\u540d\u6027\u5728\u771f\u5b9e\u7528\u6237\u9762\u524d\u8fd8\u5269\u591a\u5c11\u3002\u6211\u4eec\u7684\u8bbe\u8ba1\u7ecf\u8fc7\u4e09\u6b65\u6f14\u8fdb\uff0c\u6bcf\u4e00\u6b65\u90fd\u5173\u95ed\u4e00\u6761\u5173\u8054\u8def\u5f84\u3002', + 's5.v1.box': '\u7528\u6237\u94fe\u4e0a\u5730\u5740', + 's5.v1.desc': + 'V1 \u2014 Web3 \u539f\u751f\uff1aMACI key \u4ece\u7528\u6237\u94fe\u4e0a\u5730\u5740\u786e\u5b9a\u6027\u884d\u751f\uff0c\u6295\u7968\u8eab\u4efd\u4e0e\u94fe\u4e0a\u8eab\u4efd\u5b8c\u5168\u7ed1\u5b9a\uff0c\u8c01\u53c2\u4e0e\u4e86\u4e00\u76ee\u4e86\u7136\u3002Gas Station \u89e3\u51b3\u4e86\u6210\u672c\uff0c\u6ca1\u89e3\u51b3\u533f\u540d\u3002', + 's5.v2.box': '\u7528\u6237\u6d4f\u89c8\u5668 \u2014 \u540c\u4e00\u73af\u5883', + 's5.v2.session': '\u767b\u5f55 session\uff08email\uff09', + 's5.v2.desc': + 'V2 \u2014 \u90ae\u7bb1\u767b\u5f55 + Relay\uff1a\u65e0\u9700\u94b1\u5305\uff0cRelay \u4ee3\u4e3a\u5e7f\u64ad\u4ea4\u6613\u3002\u4f46 ZK proof \u5728\u7528\u6237\u6d4f\u89c8\u5668\u91cc\u6784\u9020\u2014\u2014\u767b\u5f55 session \u548c aMACI key \u51fa\u73b0\u5728\u540c\u4e00\u8ba1\u7b97\u73af\u5883\uff0c\u80fd\u8bbf\u95ee\u8be5\u73af\u5883\u7684\u653b\u51fb\u8005\u53ef\u4ee5\u5efa\u7acb\u8eab\u4efd\u4e0e key \u7684\u5173\u8054\u3002', + 's5.v3.boxA': '\u7528\u6237\u6d4f\u89c8\u5668', + 's5.v3.isolated': '\u9694\u79bb', + 's5.v3.desc': + 'V3 \u2014 Voting Sandbox\uff08\u5f53\u524d\u7248\u672c\uff09\uff1a\u6240\u6709\u4e0e key \u76f8\u5173\u7684\u8ba1\u7b97\u79fb\u5165\u4e00\u4e2a\u4e0d\u6301\u6709\u7528\u6237 session\u3001\u4e0d\u77e5\u9053\u7528\u6237\u662f\u8c01\u7684\u9694\u79bb\u8fd0\u884c\u65f6\u3002aMACI key \u5728 Sandbox \u5185\u751f\u6210\u3001\u4f7f\u7528\u3001\u4e22\u5f03\u2014\u2014\u5173\u8054\u4ece\u672a\u5b58\u5728\u8fc7\u3002', + 's5.e2e.webapp': + '\u77e5\u9053 email\u2014\u2014\u53ea\u6536\u5230\u7aef\u5230\u7aef\u52a0\u5bc6\u540e\u7684\u9009\u9879\uff0c\u6c38\u8fdc\u770b\u4e0d\u5230\u660e\u6587\u9009\u62e9\u3002', + 's5.e2e.lock': '\u9009\u9879\u7aef\u5230\u7aef\u52a0\u5bc6', + 's5.e2e.sandbox': + '\u770b\u5230\u660e\u6587\u9009\u9879\u5e76\u6267\u884c\u534f\u8bae\u2014\u2014\u6c38\u8fdc\u4e0d\u77e5\u9053\u670d\u52a1\u7684\u662f\u54ea\u4e2a email\u3002', + + 's6.title': '\u4fe1\u4efb\u6a21\u578b\uff1a\u8c01\u80fd\u77e5\u9053\u4ec0\u4e48\uff1f', + 's6.lead': + '\u4e09\u4e2a\u670d\u52a1\u5404\u6301\u4e00\u6bb5\u4fe1\u606f\u3002\u52fe\u9009\u4e0b\u65b9\u7684\u5408\u8c0b\u7ec4\u5408\uff0c\u770b\u770b\u5b83\u4eec\u80fd\u2014\u2014\u4ee5\u53ca\u4e0d\u80fd\u2014\u2014\u91cd\u5efa\u4ec0\u4e48\u3002\u653b\u51fb\u8005\u9700\u8981\u7684\u662f\u5b8c\u6574\u94fe\u8def email \u2192 deactivated key \u2192 K_i \u2192 \u6295\u7968\u5185\u5bb9\u3002', + 's6.role.webapp': '\u77e5\u9053 email\uff1b\u9009\u9879\u7ecf E2E \u52a0\u5bc6\uff0c\u770b\u4e0d\u5230\u660e\u6587', + 's6.role.relay': '\u77e5\u9053 key \u5206\u914d\u65f6\u5e8f\uff1b\u4ece\u4e0d\u63a5\u89e6 email', + 's6.role.operator': '\u89e3\u5bc6\u6240\u6709\u6295\u7968\uff1b\u6240\u6709 key \u90fd\u662f\u533f\u540d\u7684', + 's6.user': '\u7528\u6237\u4e3b\u52a8\u914d\u5408\uff08\u8bd5\u56fe\u51fa\u793a\u81ea\u5df1\u7684 voting key\uff09', + 's6.node.vote': '\u6295\u7968\u5185\u5bb9', + 's6.link.never': '\u4ece\u672a\u8bb0\u5f55 \u2014 \u968f\u673a\u5206\u914d', + 's6.link.timing': '\u65f6\u95f4\u5173\u8054 \u2014 \u4ec5\u6982\u7387\u6027', + 's6.link.zk': '\u88ab ZK proof \u526a\u65ad', + 's6.link.dec': 'operator \u53ef\u89e3\u5bc6', + 's6.link.unknown': '\u4e0d\u53ef\u89c2\u6d4b', + 's6.v.none': '\u52fe\u9009\u4e0a\u65b9\u4e00\u4e2a\u6216\u591a\u4e2a\u89d2\u8272\uff0c\u67e5\u770b\u5b83\u4eec\u8054\u5408\u8d77\u6765\u80fd\u63a8\u65ad\u51fa\u4ec0\u4e48\u3002', + 's6.v.webapp': + '\u4ec5 Web App\uff1a\u77e5\u9053\u8c01\u5728\u4ec0\u4e48\u65f6\u5019\u53c2\u4e0e\u4e86\u3002\u6ca1\u6709 key\u3001\u6ca1\u6709\u6295\u7968\u5185\u5bb9\u2014\u2014\u9009\u9879\u79bb\u5f00\u6d4f\u89c8\u5668\u65f6\u5df2\u662f E2E \u5bc6\u6587\u3002', + 's6.v.relay': + '\u4ec5 Relay/API\uff1a\u770b\u5230 deactivated key \u88ab\u53d6\u8d70\u3001K_i \u88ab\u6ce8\u518c\u2014\u2014\u4f46\u4e0d\u77e5\u9053\u662f\u8c01\u7684\u3002\u5206\u914d\u968f\u673a\u4e14\u4e0d\u7559\u8bb0\u5f55\u3002', + 's6.v.operator': + '\u4ec5 Operator\uff1a\u80fd\u8bfb\u5230\u6bcf\u4e00\u7968\u7684\u660e\u6587\u2014\u2014\u4f46\u6bcf\u4e00\u7968\u90fd\u6765\u81ea\u533f\u540d key\uff0c\u6ca1\u6709\u4efb\u4f55\u901a\u5411\u8eab\u4efd\u7684\u8def\u5f84\u3002', + 's6.v.webapp_relay': + '\u573a\u666f\u4e00 \u2014 Web App + Relay/API\uff1a\u53ef\u5c1d\u8bd5\u65f6\u95f4\u5173\u8054\uff08email \u5728 T \u65f6\u523b\u6d3b\u8dc3\uff0ckey \u5728 T\u2032 \u88ab\u5206\u914d\uff09\u3002\u81f3\u591a\u662f\u6982\u7387\u6027\u63a8\u65ad\uff0c\u5e76\u53d1\u6295\u7968\u8d8a\u591a\u8d8a\u4e0d\u53ef\u9760\uff0c\u65e0\u6cd5\u4e8b\u540e\u91cd\u5efa\u2014\u2014\u4e14\u4f9d\u7136\u5f97\u4e0d\u5230\u4efb\u4f55\u6295\u7968\u5185\u5bb9\u3002', + 's6.v.webapp_operator': + 'Web App + Operator\uff1a\u4e00\u8fb9\u662f\u8eab\u4efd\uff0c\u4e00\u8fb9\u662f\u6295\u7968\u5185\u5bb9\u2014\u2014\u4f46\u4e2d\u95f4\u73af\u8282\uff08deactivated key\u3001K_i\uff09\u5b8c\u5168\u7f3a\u5931\uff0c\u4fe1\u606f\u4ea4\u96c6\u6781\u5176\u6709\u9650\u3002', + 's6.v.relay_operator': + 'Relay/API + Operator\uff1akey \u65f6\u5e8f\u52a0\u6295\u7968\u5185\u5bb9\u2014\u2014\u4f46\u6ca1\u6709\u4efb\u4f55\u4e1c\u897f\u80fd\u628a\u5b83\u4eec\u8fde\u5230\u771f\u5b9e\u8eab\u4efd\u3002', + 's6.v.all': + '\u573a\u666f\u4e8c \u2014 \u4e09\u65b9\u5168\u5408\u8c0b\uff1aemail \u2192\uff08\u65f6\u95f4\u731c\u6d4b\uff09\u2192 K_i \u2192 \u6295\u7968\u5185\u5bb9\u3002\u8fd9\u662f\u4fe1\u606f\u6700\u5b8c\u6574\u7684\u653b\u51fb\u7ec4\u5408\uff0c\u4f46\u4ecd\u662f\u6982\u7387\u6027\u7684\uff0c\u5fc5\u987b\u5728\u7528\u6237\u6295\u7968\u5f53\u4e0b\u8de8\u7cfb\u7edf\u5b9e\u65f6\u76d1\u63a7\uff0c\u65e0\u6cd5\u4e8b\u540e\u91cd\u5efa\u3002\u533f\u540d\u96c6\u8d8a\u5927\uff0c\u63a8\u65ad\u8d8a\u4e0d\u53ef\u9760\u3002', + 's6.v.user': + '\u573a\u666f\u4e09/\u56db \u2014 \u5373\u4f7f\u7528\u6237\u4e3b\u52a8\u914d\u5408\uff1avoting key \u5728 Voting Sandbox \u5185\u751f\u6210\u548c\u4f7f\u7528\uff0c\u4ece\u672a\u79bb\u5f00\u8fc7 Sandbox\u2014\u2014\u7528\u6237\u624b\u91cc\u6839\u672c\u6ca1\u6709 K_i \u53ef\u4ee5\u4ea4\u51fa\u3002\u4e14\u8986\u76d6\u673a\u5236\u672c\u8eab\u5c31\u8ba9\u4efb\u4f55\u201c\u5df2\u6295\u8bc1\u660e\u201d\u4e0d\u53ef\u4fe1\u3002', + + 's7.title': '\u533f\u540d\u96c6\uff1a\u5b9e\u9645\u533f\u540d\u6027\u6709\u591a\u5f3a\uff1f', + 's7.lead': + 'ZK \u4fdd\u8bc1\u662f\u6052\u5b9a\u7684\uff0c\u4f46\u65f6\u95f4\u5173\u8054\u98ce\u9669\u53d6\u51b3\u4e8e\u540c\u65f6\u6bb5\u6709\u591a\u5c11\u4eba\u6295\u7968\u3002\u4e24\u6761\u66f2\u7ebf\u4e4b\u95f4\u7684\u5dee\u8ddd\uff0c\u5c31\u662f\u9700\u8981\u7528\u4fe1\u4efb\u914d\u7f6e\u6765\u5f25\u8865\u7684\u7a7a\u95f4\u3002\u62d6\u52a8\u6ed1\u5757\u8bd5\u8bd5\u3002', + 's7.crypto': '\u5bc6\u7801\u5b66\u533f\u540d\u4fdd\u8bc1\uff08ZK proof\uff09\u2014 \u4e0a\u9650', + 's7.timing': '\u65f6\u95f4\u5173\u8054\u98ce\u9669 \u2014 \u968f\u89c4\u6a21\u6d88\u9000', + 's7.effective': '\u6709\u6548\u533f\u540d\u6027 \u2014 \u968f\u89c4\u6a21\u4e0a\u5347', + 's7.gap': '\u65f6\u95f4\u5173\u8054\u98ce\u9669\uff08\u5dee\u8ddd\uff09', + 's7.ylabel': '\u53c2\u4e0e\u533f\u540d\u6027', + 's7.xlabel': '\u533f\u540d\u96c6\u5927\u5c0f\uff08\u540c\u65f6\u6bb5\u6295\u7968\u4eba\u6570\uff09', + 's7.scenario': '\u4ee3\u8868\u573a\u666f', + 's7.risk': '\u65f6\u95f4\u5173\u8054\u98ce\u9669', + 's7.config': '\u63a8\u8350\u914d\u7f6e', + 's7.scenarios': [ + '\u673a\u6784\u5185\u90e8\u51b3\u7b56\uff08\u22645 \u4eba\uff09', + '\u5b89\u5168\u59d4\u5458\u4f1a\u7d27\u6025\u8868\u51b3\uff08~10 \u4eba\uff09', + 'DAO \u5b63\u5ea6\u9884\u7b97\uff08~60 \u4eba\uff09', + '\u534f\u8bae\u6cbb\u7406\u5347\u7ea7\uff08500 \u4eba+\uff09', + '\u5c0f\u56fd\u8bae\u4f1a\u5927\u9009\uff0810 \u4e07\u4eba+\uff09', + ], + 's7.risks': [ + '\u6781\u9ad8\uff08\u8fd1\u786e\u5b9a\uff09', + '\u9ad8', + '\u4e2d\uff08\u9ad8\u5cf0\u671f\u6536\u7a84\uff09', + '\u4f4e', + '\u6781\u4f4e', + ], + 's7.configs': [ + '\u72ec\u7acb operator\uff1b\u987b\u5411\u53c2\u4e0e\u65b9\u8bf4\u660e\u53c2\u4e0e\u533f\u540d\u6027\u8f83\u5f31', + '\u72ec\u7acb operator\uff08\u5f3a\u70c8\u5efa\u8bae\uff09', + '\u6807\u51c6\u914d\u7f6e\u6216\u72ec\u7acb operator', + '\u6807\u51c6\u914d\u7f6e', + '\u4e09\u65b9\u72ec\u7acb\uff08\u5e73\u53f0 / Operator / \u53d1\u8d77\u65b9\u5206\u79bb\uff09', + ], + + 's8.title': '\u9009\u62e9\u4fe1\u4efb\u914d\u7f6e', + 's8.lead': + '\u7528\u90ae\u7bb1\u767b\u5f55\u66ff\u6362\u94b1\u5305\uff0c\u5f15\u5165\u4e86\u4e24\u4e2a\u534f\u8bae\u5916\u7684\u5e73\u53f0\u670d\u52a1\u2014\u2014\u8fd9\u662f\u4e3a\u96f6 Web3 \u95e8\u69db\u4ed8\u51fa\u7684\u660e\u786e\u4ee3\u4ef7\u3002\u53ef\u914d\u7f6e\u7684\u662f\uff1a\u8981\u628a\u8eab\u4efd\u548c\u6295\u7968\u5173\u8054\u8d77\u6765\uff0c\u9700\u8981\u591a\u5c11\u4e2a\u72ec\u7acb\u4e3b\u4f53\u540c\u65f6\u5171\u8c0b\u3002', + 's8.th.config': '\u914d\u7f6e', + 's8.th.collude': '\u5173\u8054\u8eab\u4efd\u4e0e\u6295\u7968\u9700\u51e0\u65b9\u5171\u8c0b', + 's8.row1.name': '\u6807\u51c6\u914d\u7f6e\uff08Dora \u5168\u6808\u8fd0\u8425\uff09', + 's8.row2.name': '\u72ec\u7acb Operator', + 's8.row3.name': '\u4e09\u65b9\u72ec\u7acb', + 's8.thirdparty': '\u7b2c\u4e09\u65b9', + 's8.opA': '\u72ec\u7acb\u8fd0\u8425\u65b9 A', + 's8.opB': '\u72ec\u7acb\u8fd0\u8425\u65b9 B', + 's8.final': + '\u65e0\u8bba\u54ea\u79cd\u914d\u7f6e\uff0c\u6700\u7ec8\u4fdd\u8bc1\u90fd\u5728\u94fe\u4e0a\uff1a\u6240\u6709 tally \u7ed3\u679c\u7531 ZK proof \u5728\u5408\u7ea6\u4e2d\u9a8c\u8bc1\u3002\u65e0\u8bba operator \u662f\u8c01\uff0c\u90fd\u65e0\u6cd5\u5728\u4e0d\u88ab\u53d1\u73b0\u7684\u60c5\u51b5\u4e0b\u4f2a\u9020\u7ed3\u679c\u3002', + 's8.cta': '\u4eb2\u81ea\u9a8c\u8bc1\u4e00\u4e2a\u8f6e\u6b21 \u2192', + 's8.reading': '\u5ef6\u4f38\u9605\u8bfb', + }, +}; diff --git a/packages/cli/src/web/protocol.css b/packages/cli/src/web/protocol.css new file mode 100644 index 0000000..45f3e50 --- /dev/null +++ b/packages/cli/src/web/protocol.css @@ -0,0 +1,1188 @@ +/* aMACI protocol explainer — layout + scroll-triggered animations */ + +.proto { + max-width: 820px; + margin: 0 auto; + padding: 24px 20px 64px; +} + +.proto section { + margin-bottom: 96px; +} + +.proto h2 { + font-size: 22px; + margin: 0 0 14px; + display: flex; + align-items: baseline; + gap: 12px; +} + +.sec-num { + font-family: var(--mono); + font-size: 13px; + color: var(--accent); + letter-spacing: 0.1em; +} + +.sec-lead { + color: var(--text-dim); + font-size: 15px; + line-height: 1.7; + margin: 0 0 28px; +} + +.caption { + color: var(--text-dim); + font-size: 13.5px; + line-height: 1.6; + margin: 14px 0 0; +} + +.note { + border-left: 3px solid var(--border); + padding: 10px 16px; + color: var(--text-dim); + font-size: 13.5px; + line-height: 1.7; + margin: 24px 0 0; +} + +.note.warn { border-left-color: var(--yellow); } + +/* ── Scroll reveal ── */ + +.reveal > * { + opacity: 0; + transform: translateY(18px); + transition: opacity 0.6s var(--ease-out), transform 0.6s var(--ease-out); +} + +.reveal.in-view > * { opacity: 1; transform: none; } + +.reveal.in-view > *:nth-child(1) { transition-delay: 0.04s; } +.reveal.in-view > *:nth-child(2) { transition-delay: 0.12s; } +.reveal.in-view > *:nth-child(3) { transition-delay: 0.2s; } +.reveal.in-view > *:nth-child(4) { transition-delay: 0.28s; } +.reveal.in-view > *:nth-child(5) { transition-delay: 0.36s; } +.reveal.in-view > *:nth-child(n + 6) { transition-delay: 0.42s; } + +@media (prefers-reduced-motion: reduce) { + .reveal > * { opacity: 1; transform: none; transition: none; } + .proto * { animation: none !important; transition: none !important; } +} + +/* ── Hero ── */ + +.hero { + text-align: center; + padding: 72px 0 40px; + margin-bottom: 72px !important; +} + +.kicker { + font-family: var(--mono); + font-size: 12px; + letter-spacing: 0.25em; + text-transform: uppercase; + color: var(--accent); + margin: 0 0 14px; +} + +.hero h1 { + font-size: 32px; + line-height: 1.25; + margin: 0 auto 18px; + max-width: 640px; + background: var(--grad-text); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.hero .lead { + color: var(--text-dim); + font-size: 15.5px; + line-height: 1.75; + max-width: 620px; + margin: 0 auto; +} + +.scroll-hint { + margin-top: 36px; + font-size: 13px; + animation: bob 2.2s ease-in-out infinite; +} + +@keyframes bob { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(6px); } +} + +/* ── Section 1: tension ── */ + +.tension { + display: flex; + align-items: stretch; + gap: 14px; + margin-bottom: 28px; +} + +.tension-card { + flex: 1; + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + padding: 20px; + box-shadow: var(--shadow-1); + opacity: 0; + transition: opacity 0.6s var(--ease-out) 0.25s, transform 0.6s var(--ease-out) 0.25s; +} + +.tension-card:first-child { transform: translateX(-24px); } +.tension-card:last-child { + transform: translateX(24px); + transition-delay: 0.45s; +} + +.in-view .tension-card { opacity: 1; transform: none; } + +.tension-card h3 { margin: 0 0 8px; font-size: 15px; color: var(--accent); } +.tension-card p { margin: 0; color: var(--text-dim); font-size: 13.5px; line-height: 1.65; } + +.vs { + align-self: center; + font-family: var(--mono); + font-weight: 700; + color: var(--red); + font-size: 14px; + opacity: 0; + transition: opacity 0.5s ease 0.75s; +} + +.in-view .vs { opacity: 1; } + +.bribe-line { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + font-family: var(--mono); + font-size: 12.5px; + color: var(--text-dim); +} + +.bribe-step { + border: 1px dashed var(--border); + border-radius: 6px; + padding: 7px 12px; + opacity: 0; + transition: opacity 0.5s ease; +} + +.bribe-step.bad { color: var(--red); border-color: var(--red); } +.bribe-arrow { color: var(--text-dim); opacity: 0; transition: opacity 0.4s ease; } + +.in-view .bribe-step:nth-child(1) { transition-delay: 0.9s; opacity: 1; } +.in-view .bribe-arrow:nth-child(2) { transition-delay: 1.2s; opacity: 1; } +.in-view .bribe-step:nth-child(3) { transition-delay: 1.4s; opacity: 1; } +.in-view .bribe-arrow:nth-child(4) { transition-delay: 1.7s; opacity: 1; } +.in-view .bribe-step:nth-child(5) { transition-delay: 1.9s; opacity: 1; } + +/* ── Section 2: key change scene ── */ + +.scene { + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + padding: 28px 24px; + box-shadow: var(--shadow-1); +} + +.keychange { display: flex; gap: 28px; align-items: center; flex-wrap: wrap; } + +.chain-strip { + flex: 1; + min-width: 300px; + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px; + position: relative; +} + +.chain-label { + font-family: var(--mono); + font-size: 11px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 12px; +} + +.msg-block { + display: inline-flex; + align-items: center; + gap: 10px; + font-family: var(--mono); + font-size: 13px; + border: 1px solid var(--accent); + color: var(--accent); + border-radius: 6px; + padding: 9px 14px; + margin-right: 12px; + position: relative; + opacity: 0; + transition: opacity 0.5s ease, color 0.6s ease, border-color 0.6s ease; +} + +.in-view .msg-block.voteA { opacity: 1; transition-delay: 0.3s; } +.in-view .msg-block.keychg { opacity: 1; transition-delay: 1.3s; } + +.key-icon { display: inline-block; } + +.in-view .msg-block.keychg .key-icon { + animation: spinkey 1s ease 1.6s both; +} + +@keyframes spinkey { + from { transform: rotate(0); } + to { transform: rotate(360deg); } +} + +/* old message becomes void after key change */ +.in-view .msg-block.voteA { + animation: voided 0.6s ease 2.4s forwards; +} + +@keyframes voided { + to { + color: var(--text-dim); + border-color: var(--border); + text-decoration: line-through; + } +} + +.void-mark { + position: absolute; + top: -10px; + right: -8px; + background: var(--red-bg); + color: var(--red); + font-size: 10px; + border-radius: 4px; + padding: 1px 6px; + opacity: 0; +} + +.in-view .voteA .void-mark { animation: fadein 0.5s ease 2.6s forwards; } + +@keyframes fadein { to { opacity: 1; } } + +.briber { + width: 200px; + text-align: center; + opacity: 0; +} + +.in-view .briber { animation: fadein 0.6s ease 3.2s forwards; } + +.briber-face { font-size: 30px; margin-bottom: 8px; } + +.briber-bubble { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; + font-size: 12.5px; + color: var(--text-dim); + line-height: 1.5; +} + +/* ── Section 3: add-key SVG ── */ + +.addkey { padding: 18px; } +.addkey-svg { width: 100%; height: auto; display: block; } + +.addkey-svg .panel { + fill: var(--bg); + stroke: var(--border); +} + +.addkey-svg .svg-title { + fill: var(--text-dim); + font-size: 12px; + text-anchor: middle; + font-family: var(--mono); +} + +.addkey-svg .slot rect { + fill: var(--bg-card); + stroke: var(--border); +} + +.addkey-svg .slot text { + fill: var(--text-dim); + font-size: 12px; + text-anchor: middle; + font-family: var(--mono); +} + +.addkey-svg .slot.picked rect { stroke: var(--yellow); } +.addkey-svg .slot.picked text { fill: var(--yellow); } + +.addkey-svg .slot.newkey rect { stroke: var(--green); opacity: 0; } +.addkey-svg .slot.newkey text { fill: var(--green); opacity: 0; } + +.addkey-svg.play .slot.newkey rect, +.addkey-svg.play .slot.newkey text { + animation: fadein 0.6s ease 3s forwards; +} + +/* traveling key dot — fed in, then absorbed (shrinks + fades) by the proof */ +.addkey-svg .dkey { opacity: 0; } +.addkey-svg .dkey circle { fill: var(--yellow); } +.addkey-svg .dkey .dkey-label { + fill: var(--yellow); + font-size: 11px; + text-anchor: middle; + font-family: var(--mono); +} + +.addkey-svg.play .dkey { + animation: travel 2s ease 0.5s forwards; +} + +@keyframes travel { + 0% { opacity: 0; transform: translate(160px, 128px) scale(1); } + 12% { opacity: 1; } + 70% { opacity: 1; transform: translate(380px, 150px) scale(1); } + 100% { opacity: 0; transform: translate(380px, 150px) scale(0.15); } +} + +/* the traceable link, severed at the shield */ +.addkey-svg .link-line { + stroke: var(--text-dim); + stroke-width: 1.5; + stroke-dasharray: 5 4; + opacity: 0; +} + +.addkey-svg.play .link-line { animation: fadein 0.4s ease 0.9s forwards; } + +.addkey-svg.play .link-line.half1 { + animation: fadein 0.4s ease 0.9s forwards, cutleft 0.8s ease 2.4s forwards; +} + +.addkey-svg.play .link-line.half2 { + animation: fadein 0.4s ease 0.9s forwards, cutright 0.8s ease 2.4s forwards; +} + +@keyframes cutleft { + to { transform: translate(-8px, 7px) rotate(-3deg); stroke: var(--red); opacity: 0.5; } +} + +@keyframes cutright { + to { transform: translate(8px, -7px) rotate(-3deg); stroke: var(--red); opacity: 0.5; } +} + +.addkey-svg .cut-mark { + fill: var(--red); + font-size: 17px; + text-anchor: middle; + opacity: 0; +} + +.addkey-svg.play .cut-mark { animation: fadein 0.4s ease 2.5s forwards; } + +/* ZK shield */ +.addkey-svg .zk-shield path { + fill: rgba(88, 166, 255, 0.12); + stroke: var(--accent); + stroke-width: 1.5; + filter: drop-shadow(0 0 6px rgba(88, 166, 255, 0.45)); + animation: shieldpulse 3s ease-in-out infinite; +} + +@keyframes shieldpulse { + 0%, 100% { filter: drop-shadow(0 0 5px rgba(88, 166, 255, 0.35)); } + 50% { filter: drop-shadow(0 0 11px rgba(88, 166, 255, 0.6)); } +} + +/* slot hover micro-interaction */ +.addkey-svg .slot rect { transition: stroke 0.2s var(--ease-out); } +.addkey-svg .slot:hover rect { stroke: var(--accent); } + +.addkey-svg .zk-text { + fill: var(--accent); + font-size: 20px; + font-weight: 700; + text-anchor: middle; + font-family: var(--mono); +} + +.addkey-svg .zk-sub { + fill: var(--accent); + font-size: 11px; + text-anchor: middle; + font-family: var(--mono); +} + +.addkey-svg .svg-note { + fill: var(--text-dim); + font-size: 11.5px; + text-anchor: middle; + font-family: var(--mono); + opacity: 0; +} + +.addkey-svg.play .svg-note { animation: fadein 0.5s ease 2.8s forwards; } + +.addkey-svg .emit-line { + stroke: var(--green); + stroke-width: 1.5; + stroke-dasharray: 150; + stroke-dashoffset: 150; +} + +.addkey-svg.play .emit-line { + animation: draw 0.7s ease 2.7s forwards; +} + +@keyframes draw { to { stroke-dashoffset: 0; } } + +/* replay control */ +.addkey { position: relative; } + +.replay-btn { + position: absolute; + top: 12px; + right: 12px; + z-index: 2; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-dim); + font-size: 12px; + font-family: var(--mono); + padding: 5px 11px; + cursor: pointer; + transition: color 0.2s ease, border-color 0.2s ease; +} + +.replay-btn:hover { color: var(--accent); border-color: var(--accent); } + +/* reduced motion: skip the animation but keep the explanatory end state */ +@media (prefers-reduced-motion: reduce) { + .addkey-svg .link-line, + .addkey-svg .cut-mark, + .addkey-svg .emit-line, + .addkey-svg .slot.newkey rect, + .addkey-svg .slot.newkey text { opacity: 1; } + .addkey-svg .emit-line { stroke-dashoffset: 0; } + .addkey-svg .dkey { opacity: 0; } +} + +.compare { margin-top: 20px; } + +.compare-row { + display: flex; + gap: 12px; + align-items: baseline; + font-family: var(--mono); + font-size: 13px; + padding: 10px 14px; + border-radius: 6px; + margin-bottom: 8px; +} + +.compare-row .tag { + flex-shrink: 0; + font-size: 11px; + font-weight: 700; + border-radius: 4px; + padding: 2px 8px; +} + +.compare-row.old { background: var(--red-bg); color: var(--text-dim); } +.compare-row.old .tag { background: var(--red); color: var(--bg); } +.compare-row.new { background: var(--green-bg); color: var(--text); } +.compare-row.new .tag { background: var(--green); color: var(--bg); } + +/* ── Section 4: phase stepper ── */ + +.stepper { + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + padding: 20px; + box-shadow: var(--shadow-1); +} + +.phase-tabs { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.phase-tab { + flex: 1; + min-width: 110px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-dim); + font-size: 13px; + padding: 9px 10px; + cursor: pointer; + transition: border-color 0.25s var(--ease-out), color 0.25s var(--ease-out), + background 0.25s var(--ease-out), transform 0.2s var(--ease-spring); +} + +.phase-tab:hover { transform: translateY(-1px); color: var(--text); } + +.phase-tab b { + font-family: var(--mono); + font-size: 11px; + border: 1px solid var(--border); + border-radius: 50%; + width: 20px; + height: 20px; + line-height: 18px; + text-align: center; + flex-shrink: 0; +} + +.phase-tab.active { + border-color: rgba(88, 166, 255, 0.5); + color: var(--text); + background: var(--accent-soft); + box-shadow: var(--glow-accent); +} + +.phase-tab.active b { + border-color: var(--accent); + color: var(--accent); +} + +.phase-stage svg { width: 100%; height: auto; display: block; } + +.phase-stage .actor rect { + fill: var(--bg); + stroke: var(--border); + transition: stroke 0.4s ease; +} + +.phase-stage .actor text { + fill: var(--text); + font-size: 14px; + text-anchor: middle; +} + +.phase-stage .actor .actor-sub { + fill: var(--text-dim); + font-size: 11px; + font-family: var(--mono); +} + +.phase-stage .flow { + opacity: 0; + transition: opacity 0.45s ease; +} + +.phase-stage .flow path { + fill: none; + stroke: var(--accent); + stroke-width: 1.8; + marker-end: none; + stroke-dasharray: 6 5; + filter: drop-shadow(0 0 4px rgba(88, 166, 255, 0.5)); + animation: flowdash 1.2s linear infinite; +} + +@keyframes flowdash { to { stroke-dashoffset: -22; } } + +.phase-stage .flow-particle { + fill: #cfe6ff; + filter: drop-shadow(0 0 5px var(--accent)); +} + +.phase-stage .flow-proofs .flow-particle, +.phase-stage .flow-read .flow-particle { + fill: #fff; +} + +.phase-stage .flow text { + fill: var(--accent); + font-size: 12px; + font-family: var(--mono); + text-anchor: middle; +} + +.phase-stage .verify-ring { + fill: none; + stroke: var(--green); + stroke-width: 2; +} + +.phase-stage[data-phase="4"] .verify-ring { + filter: drop-shadow(0 0 6px rgba(63, 185, 80, 0.55)); + animation: verifypulse 2s ease-in-out infinite; +} + +@keyframes verifypulse { + 0%, 100% { filter: drop-shadow(0 0 4px rgba(63, 185, 80, 0.4)); } + 50% { filter: drop-shadow(0 0 12px rgba(63, 185, 80, 0.7)); } +} + +.phase-stage .verify-check { + fill: var(--green); + font-size: 16px; + text-anchor: middle; +} + +.phase-stage .flow-verify text:last-child { fill: var(--green); } + +/* phase → visible flows + highlighted actors */ +.phase-stage[data-phase="0"] .flow-setup { opacity: 1; } +.phase-stage[data-phase="0"] .actor-coord rect, +.phase-stage[data-phase="0"] .actor-chain rect { stroke: var(--accent); } + +.phase-stage[data-phase="1"] .flow-addkey { opacity: 1; } +.phase-stage[data-phase="1"] .actor-voter rect, +.phase-stage[data-phase="1"] .actor-chain rect { stroke: var(--accent); } + +.phase-stage[data-phase="2"] .flow-vote { opacity: 1; } +.phase-stage[data-phase="2"] .actor-voter rect, +.phase-stage[data-phase="2"] .actor-chain rect { stroke: var(--accent); } + +.phase-stage[data-phase="3"] .flow-read, +.phase-stage[data-phase="3"] .flow-proofs { opacity: 1; } +.phase-stage[data-phase="3"] .actor-coord rect, +.phase-stage[data-phase="3"] .actor-chain rect { stroke: var(--accent); } + +.phase-stage[data-phase="4"] .flow-verify { opacity: 1; } +.phase-stage[data-phase="4"] .actor-chain rect { stroke: var(--green); } + +.phase-desc { + margin: 14px 4px 0; + color: var(--text-dim); + font-size: 13.5px; + line-height: 1.7; + min-height: 66px; +} + +/* ── Section 5: V1→V3 acts ── */ + +.acts { + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 24px; + box-shadow: var(--shadow-1); +} + +.act-tabs { display: flex; gap: 8px; margin-bottom: 18px; } + +.act-tab { + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: 8px; + color: var(--text-dim); + font-family: var(--mono); + font-size: 13px; + font-weight: 700; + padding: 7px 22px; + cursor: pointer; + transition: border-color 0.25s var(--ease-out), color 0.25s var(--ease-out), + background 0.25s var(--ease-out), transform 0.2s var(--ease-spring); +} + +.act-tab:hover { transform: translateY(-1px); color: var(--text); } + +.act-tab.active { + border-color: rgba(88, 166, 255, 0.5); + color: var(--accent); + background: var(--accent-soft); +} + +.act-panel { display: none; } +.act-stage[data-act="0"] .act-v1 { display: block; } +.act-stage[data-act="1"] .act-v2 { display: block; } +.act-stage[data-act="2"] .act-v3 { display: block; } + +.act-panel { animation: actfade 0.5s ease; } + +@keyframes actfade { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: none; } +} + +.iso-box { + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + text-align: center; + max-width: 440px; + margin: 0 auto; +} + +.iso-box.leaky { border-color: var(--red); } +.iso-box.safe { border-color: var(--green); margin: 0; } + +.iso-title { + font-size: 12px; + color: var(--text-dim); + font-family: var(--mono); + margin-bottom: 12px; +} + +.iso-chip { + display: inline-block; + font-family: var(--mono); + font-size: 12.5px; + border-radius: 6px; + padding: 7px 14px; +} + +.iso-chip.id { background: rgba(88, 166, 255, 0.12); color: var(--accent); } +.iso-chip.key { background: var(--yellow-bg); color: var(--yellow); } + +.bind-line { + width: 2px; + height: 22px; + background: var(--red); + margin: 8px auto; +} + +.iso-row { + display: flex; + align-items: center; + justify-content: center; + gap: 0; +} + +.leak-line { + width: 56px; + height: 2px; + background: var(--red); + position: relative; + animation: leakpulse 1.4s ease-in-out infinite; +} + +@keyframes leakpulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +.iso-pair { + display: flex; + align-items: stretch; + justify-content: center; + gap: 0; +} + +.iso-gap { + width: 90px; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.iso-gap::before, +.iso-gap::after { + content: ""; + position: absolute; + top: 8%; + bottom: 8%; + width: 1px; + background: repeating-linear-gradient(var(--green), var(--green) 5px, transparent 5px, transparent 9px); +} + +.iso-gap::before { left: 28%; } +.iso-gap::after { right: 28%; } + +.gap-label { + font-family: var(--mono); + font-size: 10.5px; + color: var(--green); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.act-caption { + margin: 18px auto 0; + max-width: 560px; + color: var(--text-dim); + font-size: 13.5px; + line-height: 1.7; + text-align: center; +} + +.e2e { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.e2e-box { + flex: 1; + min-width: 220px; + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + padding: 16px; + box-shadow: var(--shadow-1); + transition: transform 0.35s var(--ease-out), border-color 0.35s var(--ease-out); +} + +.e2e-box:hover { transform: translateY(-2px); border-color: rgba(88, 166, 255, 0.25); } + +.e2e-box h4 { margin: 0 0 6px; font-size: 13.5px; font-family: var(--mono); color: var(--accent); } +.e2e-box p { margin: 0; font-size: 12.5px; color: var(--text-dim); line-height: 1.6; } + +.e2e-lock { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + font-size: 18px; +} + +.e2e-lock span { + font-size: 10.5px; + color: var(--text-dim); + font-family: var(--mono); + max-width: 110px; + text-align: center; +} + +/* ── Section 6: collusion explorer ── */ + +.collusion { + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + padding: 20px; + box-shadow: var(--shadow-1); +} + +.role-cards { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.role-card { + flex: 1; + min-width: 190px; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: 10px; + padding: 14px; + cursor: pointer; + transition: border-color 0.25s var(--ease-out), background 0.25s var(--ease-out), + transform 0.25s var(--ease-spring), box-shadow 0.25s var(--ease-out); + position: relative; +} + +.role-card:hover { + border-color: rgba(88, 166, 255, 0.4); + transform: translateY(-2px); + box-shadow: var(--shadow-1); +} + +.role-card.checked { + border-color: var(--red); + background: var(--red-bg); +} + +.role-card input { + position: absolute; + top: 12px; + right: 12px; + accent-color: var(--red); +} + +.role-card h4 { margin: 0 0 6px; font-size: 13.5px; font-family: var(--mono); } +.role-card p { margin: 0; font-size: 12px; color: var(--text-dim); line-height: 1.55; } + +.user-coop { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-dim); + cursor: pointer; + margin-bottom: 24px; +} + +.user-coop input { accent-color: var(--red); } + +.kchain { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0; + margin-bottom: 20px; + justify-content: center; +} + +.kchain-node { + font-family: var(--mono); + font-size: 12.5px; + border: 1px solid var(--border); + border-radius: 6px; + padding: 9px 14px; + color: var(--text-dim); + background: var(--bg); + transition: border-color 0.3s ease, color 0.3s ease; + white-space: nowrap; +} + +.kchain-node[data-known="yes"] { border-color: var(--red); color: var(--red); } +.kchain-node[data-known="partial"] { border-color: var(--yellow); color: var(--yellow); } + +.kchain-link { + display: flex; + flex-direction: column; + align-items: center; + width: 130px; + position: relative; + padding-top: 4px; +} + +.kchain-link::before { + content: ""; + width: 100%; + height: 2px; + background: var(--border); + transition: background 0.3s ease; +} + +.kchain-link[data-state="blocked"]::before { + background: repeating-linear-gradient(90deg, var(--red), var(--red) 5px, transparent 5px, transparent 10px); +} + +.kchain-link[data-state="blocked"]::after { + content: "✕"; + position: absolute; + top: -9px; + color: var(--red); + font-size: 12px; + background: var(--bg-card); + padding: 0 4px; +} + +.kchain-link[data-state="prob"]::before { + background: repeating-linear-gradient(90deg, var(--yellow), var(--yellow) 3px, transparent 3px, transparent 7px); +} + +.kchain-link[data-state="prob"]::after { + content: "~"; + position: absolute; + top: -12px; + color: var(--yellow); + font-size: 15px; + background: var(--bg-card); + padding: 0 4px; +} + +.kchain-link[data-state="open"]::before { background: var(--red); } + +.kchain-link-label { + margin-top: 7px; + font-size: 10px; + font-family: var(--mono); + color: var(--text-dim); + text-align: center; + line-height: 1.4; + max-width: 124px; +} + +.kchain-link[data-state="blocked"] .kchain-link-label { color: var(--red); } +.kchain-link[data-state="prob"] .kchain-link-label { color: var(--yellow); } + +.verdict { + border-top: 1px solid var(--border); + padding-top: 16px; +} + +.verdict p { + margin: 0; + font-size: 13.5px; + line-height: 1.7; + color: var(--text); +} + +.verdict-user { + margin-top: 10px !important; + color: var(--green) !important; +} + +@media (max-width: 720px) { + .kchain { flex-direction: column; gap: 6px; } + .kchain-link { width: 4px; height: 56px; padding: 0; } + .kchain-link::before { width: 2px; height: 100%; } + .kchain-link-label { display: none; } +} + +/* ── Section 7: anonymity set ── */ + +.anon { + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + padding: 20px; + box-shadow: var(--shadow-1); +} + +.anon-svg { width: 100%; height: auto; display: block; } + +.anon-svg .axis { stroke: var(--border); stroke-width: 1.5; } + +.anon-svg .axis-label { + fill: var(--text-dim); + font-size: 11px; + font-family: var(--mono); + text-anchor: middle; +} + +.anon-svg .curve-crypto { + stroke: var(--green); + stroke-width: 2; + stroke-dasharray: 2 5; +} + +/* shaded residual-risk region between ceiling and effective curve */ +.anon-svg .gap-area { + fill: rgba(248, 81, 73, 0.13); + stroke: none; +} + +.anon-svg .curve-effective { + fill: none; + stroke: var(--green); + stroke-width: 2; +} + +.anon-svg .curve-label { + font-size: 12px; + font-family: var(--mono); + text-anchor: middle; +} + +.anon-svg .curve-label.crypto { fill: var(--green); } +.anon-svg .curve-label.effective { fill: var(--green); } +.anon-svg .curve-label.gap { fill: var(--red); } + +.anon-svg .marker { + fill: var(--accent); + stroke: var(--bg); + stroke-width: 2; + filter: drop-shadow(0 0 6px rgba(88, 166, 255, 0.6)); + transition: cx 0.5s var(--ease-spring), cy 0.5s var(--ease-spring); +} + +.anon-svg .tick { + fill: var(--text-dim); + font-size: 11.5px; + font-family: var(--mono); + text-anchor: middle; +} + +.anon-svg .dim-tick { font-size: 11px; opacity: 0.7; } + +#anon-slider { + width: calc(100% - 30px); + margin: 14px 15px 20px; + accent-color: var(--accent); +} + +.anon-readout { + display: flex; + gap: 14px; + flex-wrap: wrap; +} + +.readout-item { + flex: 1; + min-width: 200px; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: 10px; + padding: 12px 14px; + transition: transform 0.3s var(--ease-out), border-color 0.3s var(--ease-out); +} + +.readout-item:hover { transform: translateY(-2px); border-color: rgba(88, 166, 255, 0.3); } + +.readout-label { + display: block; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-dim); + margin-bottom: 6px; +} + +.readout-value { font-size: 13.5px; line-height: 1.5; } + +#anon-risk[data-level="0"], #anon-risk[data-level="1"] { color: var(--red); } +#anon-risk[data-level="2"] { color: var(--yellow); } +#anon-risk[data-level="3"], #anon-risk[data-level="4"] { color: var(--green); } + +/* ── Section 8: summary ── */ + +.config-table { margin-bottom: 24px; } +.config-table td { font-size: 13px; } +.config-table td:first-child { font-weight: 600; } + +.final-note { + font-size: 14.5px; + line-height: 1.75; + border-left: 3px solid var(--green); + padding: 10px 16px; + margin: 0 0 28px; +} + +.cta { text-align: center; margin-bottom: 40px; } + +.cta-btn { + display: inline-block; + text-decoration: none; + font-size: 14px; + padding: 11px 26px; +} + +.reading h3 { font-size: 14px; margin: 0 0 10px; } + +.reading ul { + margin: 0; + padding-left: 18px; + color: var(--text-dim); + font-size: 13px; + line-height: 2; +} diff --git a/packages/cli/src/web/protocol.html b/packages/cli/src/web/protocol.html new file mode 100644 index 0000000..8055126 --- /dev/null +++ b/packages/cli/src/web/protocol.html @@ -0,0 +1,441 @@ + + + + + + aMACI Protocol Explainer — maci ui + + + + +
+
+ maci + aMACI protocol explainer +
+ +
+ +
+ + +
+

Protocol Explainer

+

aMACI: Anonymous, Anti-Collusion On-Chain Voting

+

+

Scroll to explore ↓

+
+ + +
+

01 Why on-chain voting is hard to get right

+

+ +
+
+

Blockchain guarantees

+

+
+
VS
+
+

Anti-collusion

+

+
+
+ +
+ Every vote is public on a transparent chain + + “You voted A” is verifiable by anyone + + Bribery becomes enforceable +
+
+ + +
+

02 MACI’s answer: a deniable key change

+

+ +
+
+
on-chain messages (encrypted)
+
+ enc(“vote A”) + invalidated +
+
+ enc(key change) + +
+
+
+
👁
+
Was that the final vote? Impossible to tell.
+
+
+ +

+
+ + +
+

03 aMACI’s core move: deactivate & add-key

+

+ +
+ + +

+
+ +
+
MACI
+
aMACI
+
+
+ + +
+

04 One round, five phases

+

+ +
+
+ + + + + +
+ +
+ +
+ +

+
+
+ + +
+

05 From protocol to infrastructure: V1 → V3

+

+ +
+
+ + + +
+ +
+ +
+
+
User wallet address
+
dora1xy…z
+
+
MACI key (derived)
+
+

+
+ +
+
+
User browser — same environment
+
+
login session (email)
+
+
aMACI key
+
+
+

+
+ +
+
+
+
User browser
+
login session (email)
+
+
+ isolated +
+
+
Voting Sandbox
+
aMACI key
+
+
+

+
+
+
+ +
+
+

Web App

+

+
+
🔒E2E-encrypted option
+
+

Voting Sandbox

+

+
+
+
+ + +
+

06 Trust model: who can learn what?

+

+ +
+
+ + + +
+ + +
+
email
+ +
deactivated key
+ +
K_i
+ +
vote content
+
+ +
+

+ +
+
+
+ + +
+

07 Anonymity set: how strong in practice?

+

+ +
+ + + + +
+
+ Scenario + +
+
+ Timing risk + +
+
+ Recommended setup + +
+
+
+
+ + +
+

08 Choosing a trust configuration

+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ConfigurationWeb AppRelay/APIOperatorParties needed to collude
Standard (Dora full-stack)DoraDoraDora1
Independent operatorDoraDora3rd party2
Three-way independentOperator AOperator B3rd party3
+ +

+ + + + +
+
+ +
+ Local read-only verifier — no private keys, no transactions. Anyone can audit. +
+ + + + + diff --git a/packages/cli/src/web/protocol.js b/packages/cli/src/web/protocol.js new file mode 100644 index 0000000..9e2e85e --- /dev/null +++ b/packages/cli/src/web/protocol.js @@ -0,0 +1,234 @@ +/* aMACI protocol explainer — scroll reveals, i18n, interactive widgets */ + +(() => { + 'use strict'; + + const $ = (sel) => document.querySelector(sel); + const $$ = (sel) => Array.from(document.querySelectorAll(sel)); + const I18N = window.PROTOCOL_I18N; + + // Honor reduced-motion for SMIL-driven particle animations (CSS can't reach them) + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + $$('svg').forEach((svg) => { + if (typeof svg.pauseAnimations === 'function') svg.pauseAnimations(); + }); + } + + // ── i18n ────────────────────────────────────────────────────────────────── + + let lang = localStorage.getItem('maci-proto-lang') || 'en'; + if (!I18N[lang]) lang = 'en'; + + const t = (key) => I18N[lang][key] ?? I18N.en[key] ?? key; + + function applyI18n() { + document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'; + $$('[data-i18n]').forEach((node) => { + const value = t(node.dataset.i18n); + if (typeof value === 'string') node.textContent = value; + }); + $('#lang-toggle').textContent = lang === 'en' ? '中文' : 'EN'; + // Re-render dynamic widgets whose copy comes from the dictionary + renderPhase(currentPhase); + renderCollusion(); + renderAnon(); + } + + $('#lang-toggle').addEventListener('click', () => { + lang = lang === 'en' ? 'zh' : 'en'; + localStorage.setItem('maci-proto-lang', lang); + applyI18n(); + }); + + // ── Scroll reveal ───────────────────────────────────────────────────────── + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + entry.target.classList.add('in-view'); + observer.unobserve(entry.target); + } + } + }, + { threshold: 0.25 } + ); + $$('.reveal').forEach((sec) => observer.observe(sec)); + + // ── Section 3: add-key animation (play once on view, replayable) ─────────── + + const addkeySvg = $('.addkey-svg'); + function playAddkey() { + if (!addkeySvg) return; + addkeySvg.classList.remove('play'); + void addkeySvg.getBoundingClientRect(); // force reflow to restart animations + addkeySvg.classList.add('play'); + } + if (addkeySvg) { + const addkeyObserver = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + playAddkey(); + addkeyObserver.unobserve(entry.target); + } + } + }, + { threshold: 0.4 } + ); + addkeyObserver.observe(addkeySvg); + $('#addkey-replay').addEventListener('click', playAddkey); + } + + // ── Section 4: phase stepper ────────────────────────────────────────────── + + const stage = $('.phase-stage'); + const phaseTabs = $$('.phase-tab'); + let currentPhase = 0; + let autoTimer = null; + + function renderPhase(n) { + currentPhase = n; + stage.dataset.phase = String(n); + phaseTabs.forEach((tab) => { + tab.classList.toggle('active', Number(tab.dataset.phase) === n); + }); + $('#phase-desc').textContent = t(`s4.p${n}.desc`); + } + + function stopAuto() { + if (autoTimer !== null) { + clearInterval(autoTimer); + autoTimer = null; + } + } + + phaseTabs.forEach((tab) => { + tab.addEventListener('click', () => { + stopAuto(); + renderPhase(Number(tab.dataset.phase)); + }); + }); + + // Auto-advance once when the stepper scrolls into view; stop at last phase + // or on any manual interaction. + const stepperObserver = new IntersectionObserver( + (entries) => { + if (!entries.some((e) => e.isIntersecting) || autoTimer !== null) return; + stepperObserver.disconnect(); + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (reduced) return; + autoTimer = setInterval(() => { + if (currentPhase >= 4) { + stopAuto(); + return; + } + renderPhase(currentPhase + 1); + }, 3200); + }, + { threshold: 0.5 } + ); + stepperObserver.observe($('#phase-stepper')); + + // ── Section 5: V1 → V3 acts ─────────────────────────────────────────────── + + const actStage = $('.act-stage'); + $$('.act-tab').forEach((tab) => { + tab.addEventListener('click', () => { + $$('.act-tab').forEach((b) => b.classList.toggle('active', b === tab)); + actStage.dataset.act = tab.dataset.act; + }); + }); + + // ── Section 6: collusion explorer ───────────────────────────────────────── + + const roleBoxes = { + webapp: $('.role-card[data-role="webapp"] input'), + relay: $('.role-card[data-role="relay"] input'), + operator: $('.role-card[data-role="operator"] input'), + }; + const userCoop = $('#user-coop-box'); + + function verdictKey(w, r, o) { + if (w && r && o) return 's6.v.all'; + if (w && r) return 's6.v.webapp_relay'; + if (w && o) return 's6.v.webapp_operator'; + if (r && o) return 's6.v.relay_operator'; + if (w) return 's6.v.webapp'; + if (r) return 's6.v.relay'; + if (o) return 's6.v.operator'; + return 's6.v.none'; + } + + function setLink(n, state, labelKey) { + const link = $(`.kchain-link[data-link="${n}"]`); + link.dataset.state = state; + link.querySelector('.kchain-link-label').textContent = labelKey ? t(labelKey) : ''; + } + + function renderCollusion() { + const w = roleBoxes.webapp.checked; + const r = roleBoxes.relay.checked; + const o = roleBoxes.operator.checked; + + $$('.role-card').forEach((card) => { + card.classList.toggle('checked', card.querySelector('input').checked); + }); + + // Node knowledge highlighting + $('.kchain-node[data-node="email"]').dataset.known = w ? 'yes' : 'no'; + $('.kchain-node[data-node="dkey"]').dataset.known = r ? 'partial' : 'no'; + $('.kchain-node[data-node="ki"]').dataset.known = r ? 'partial' : 'no'; + $('.kchain-node[data-node="vote"]').dataset.known = o ? 'yes' : 'no'; + + // Link 1: email → deactivated key. Never recorded; timing correlation + // becomes attemptable only when Web App AND Relay collude. + if (w && r) setLink(1, 'prob', 's6.link.timing'); + else setLink(1, 'blocked', 's6.link.never'); + + // Link 2: deactivated key → K_i. Always severed by the ZK proof. + setLink(2, 'blocked', 's6.link.zk'); + + // Link 3: K_i → vote content. Operator decrypts. + if (o) setLink(3, 'open', 's6.link.dec'); + else setLink(3, 'unknown', 's6.link.unknown'); + + $('#verdict-text').textContent = t(verdictKey(w, r, o)); + $('#verdict-text').dataset.i18n = verdictKey(w, r, o); + $('#verdict-user').classList.toggle('hidden', !userCoop.checked); + } + + Object.values(roleBoxes).forEach((box) => box.addEventListener('change', renderCollusion)); + userCoop.addEventListener('change', renderCollusion); + + // ── Section 7: anonymity-set slider ─────────────────────────────────────── + + const slider = $('#anon-slider'); + // Marker positions sampled along the rising effective-anonymity curve + const MARKER_POS = [ + { x: 60, y: 205 }, + { x: 215, y: 182 }, + { x: 370, y: 128 }, + { x: 560, y: 78 }, + { x: 730, y: 62 }, + ]; + + function renderAnon() { + const i = Number(slider.value); + const pos = MARKER_POS[i]; + const marker = $('#anon-marker'); + marker.setAttribute('cx', pos.x); + marker.setAttribute('cy', pos.y); + $('#anon-scenario').textContent = t('s7.scenarios')[i]; + $('#anon-risk').textContent = t('s7.risks')[i]; + $('#anon-config').textContent = t('s7.configs')[i]; + // Risk badge colour shifts with level + $('#anon-risk').dataset.level = String(i); + } + + slider.addEventListener('input', renderAnon); + + // ── Init ────────────────────────────────────────────────────────────────── + + applyI18n(); +})(); diff --git a/packages/cli/src/web/style.css b/packages/cli/src/web/style.css new file mode 100644 index 0000000..dd36f36 --- /dev/null +++ b/packages/cli/src/web/style.css @@ -0,0 +1,556 @@ +/* maci ui — Aurora Glass theme */ + +:root { + --bg: #0a0c12; + --bg-2: #0d1117; + --bg-card: #161b22; + --bg-input: #0d1117; + --border: #2a313b; + --text: #e6edf3; + --text-dim: #8b949e; + + --accent: #58a6ff; + --accent-2: #56d4dd; + --accent-3: #a371f7; + --accent-soft: rgba(88, 166, 255, 0.16); + + --green: #3fb950; + --green-bg: rgba(63, 185, 80, 0.15); + --red: #f85149; + --red-bg: rgba(248, 81, 73, 0.15); + --yellow: #d29922; + --yellow-bg: rgba(210, 153, 34, 0.15); + + /* gradients */ + --grad-accent: linear-gradient(120deg, #58a6ff 0%, #7aa2ff 42%, #a371f7 100%); + --grad-text: linear-gradient(120deg, #9ecbff 0%, #8ed8e0 45%, #c4a7ff 100%); + + /* glass */ + --glass-bg: rgba(22, 27, 34, 0.6); + --glass-bg-strong: rgba(22, 27, 34, 0.82); + --glass-border: rgba(255, 255, 255, 0.08); + --glass-hi: rgba(255, 255, 255, 0.07); + --glass-blur: 14px; + + /* elevation + glow */ + --shadow-1: 0 4px 24px rgba(0, 0, 0, 0.35); + --shadow-2: 0 14px 44px rgba(0, 0, 0, 0.48); + --glow-accent: 0 0 26px rgba(88, 166, 255, 0.4); + + --radius: 12px; + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); + + --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; +} + +* { box-sizing: border-box; } + +html { background: var(--bg); } + +body { + margin: 0; + background: transparent; + color: var(--text); + font-family: var(--sans); + font-size: 14px; + line-height: 1.5; +} + +/* ── Aurora backdrop ── */ +body::before { + content: ""; + position: fixed; + inset: -25vmax; + z-index: -1; + pointer-events: none; + background: + radial-gradient(38vmax 38vmax at 16% 10%, rgba(88, 166, 255, 0.18), transparent 60%), + radial-gradient(36vmax 36vmax at 86% 16%, rgba(163, 113, 247, 0.16), transparent 60%), + radial-gradient(46vmax 46vmax at 72% 92%, rgba(86, 212, 221, 0.12), transparent 62%); + filter: blur(8px); + animation: aurora 28s ease-in-out infinite alternate; +} + +@keyframes aurora { + 0% { transform: translate3d(0, 0, 0) scale(1); } + 50% { transform: translate3d(2.5%, 1.5%, 0) scale(1.07); } + 100% { transform: translate3d(-2.5%, -1%, 0) scale(1.03); } +} + +/* second aurora layer that drifts with scroll (parallax) */ +body::after { + content: ""; + position: fixed; + inset: -25vmax; + z-index: -1; + pointer-events: none; + background: + radial-gradient(34vmax 34vmax at 78% 28%, rgba(86, 212, 221, 0.1), transparent 60%), + radial-gradient(30vmax 30vmax at 28% 72%, rgba(163, 113, 247, 0.11), transparent 62%); + filter: blur(10px); + animation: aurora-parallax linear both; + animation-timeline: scroll(root block); +} + +@keyframes aurora-parallax { + from { transform: translateY(-4%) scale(1.02); } + to { transform: translateY(11%) scale(1.07); } +} + +@media (prefers-reduced-motion: reduce) { + body::before, + body::after { animation: none; } +} + +main { + max-width: 860px; + margin: 0 auto; + padding: 24px 20px 48px; +} + +.dim { color: var(--text-dim); } +.small { font-size: 12.5px; } +.hidden { display: none !important; } +code { font-family: var(--mono); font-size: 12.5px; } + +/* ── Top bar ── */ + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + padding: 14px 24px; + border-bottom: 1px solid var(--glass-border); + background: rgba(13, 17, 23, 0.72); + backdrop-filter: blur(var(--glass-blur)) saturate(1.3); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.3); + position: sticky; + top: 0; + z-index: 50; +} + +.brand { display: flex; align-items: baseline; gap: 10px; } + +.brand-mark { + font-family: var(--mono); + font-weight: 700; + font-size: 18px; + background: var(--grad-text); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.brand-sub { color: var(--text-dim); font-size: 12.5px; } + +.tabs { display: flex; gap: 4px; } + +.tab { + background: none; + border: 1px solid transparent; + border-radius: 8px; + color: var(--text-dim); + font-size: 13.5px; + padding: 6px 14px; + cursor: pointer; + transition: color 0.25s var(--ease-out), background 0.25s var(--ease-out), + border-color 0.25s var(--ease-out), transform 0.2s var(--ease-spring); +} + +.tab:hover { color: var(--text); transform: translateY(-1px); } + +.tab.active { + color: var(--text); + background: var(--accent-soft); + border-color: rgba(88, 166, 255, 0.4); +} + +.tab-link { + text-decoration: none; + display: inline-block; + line-height: 1.5; +} + +/* ── Cards ── */ + +.card { + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 16px; + box-shadow: var(--shadow-1); + position: relative; + transition: transform 0.4s var(--ease-out), box-shadow 0.4s var(--ease-out), + border-color 0.4s var(--ease-out); +} + +.card::after { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 1px; + border-radius: var(--radius) var(--radius) 0 0; + background: linear-gradient(90deg, transparent, var(--glass-hi), transparent); + pointer-events: none; +} + +.card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-2); + border-color: rgba(88, 166, 255, 0.22); +} + +.card h2 { + margin: 0 0 12px; + font-size: 15px; + font-weight: 600; +} + +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.card-head h2 { margin: 0; } + +/* ── Forms ── */ + +.form-row { margin-bottom: 14px; } + +.form-row label, +.field > label { + display: block; + font-size: 12.5px; + color: var(--text-dim); + margin-bottom: 5px; +} + +input[type="text"], select { + width: 100%; + background: rgba(13, 17, 23, 0.6); + border: 1px solid var(--glass-border); + border-radius: 8px; + color: var(--text); + font-family: var(--mono); + font-size: 13px; + padding: 9px 11px; + transition: border-color 0.25s var(--ease-out), box-shadow 0.25s var(--ease-out); +} + +input[type="text"]:focus, select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.form-row-inline { + display: flex; + gap: 24px; + align-items: flex-end; + flex-wrap: wrap; +} + +.field { min-width: 160px; } + +.field-checkbox { padding-bottom: 8px; } + +.checkbox-label { + display: flex !important; + align-items: center; + gap: 8px; + color: var(--text) !important; + font-size: 13.5px !important; + cursor: pointer; + margin: 0 !important; +} + +.checkbox-label input { accent-color: var(--accent); } + +.advanced { margin-bottom: 14px; } + +.advanced summary { + cursor: pointer; + color: var(--text-dim); + font-size: 12.5px; + margin-bottom: 10px; + user-select: none; +} + +.form-actions { display: flex; gap: 10px; } + +.btn-primary { + background: var(--grad-accent); + border: none; + border-radius: 8px; + color: #0a0c12; + font-size: 13.5px; + font-weight: 600; + padding: 9px 20px; + cursor: pointer; + box-shadow: 0 2px 12px rgba(88, 166, 255, 0.25); + transition: transform 0.25s var(--ease-spring), box-shadow 0.3s var(--ease-out), + filter 0.2s ease; +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: var(--glow-accent); + filter: brightness(1.05); +} + +.btn-primary:active { transform: translateY(0); } + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.btn-ghost { + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: 8px; + color: var(--text-dim); + font-size: 12.5px; + padding: 5px 12px; + cursor: pointer; + transition: color 0.25s var(--ease-out), border-color 0.25s var(--ease-out), + transform 0.2s var(--ease-spring); +} + +.btn-ghost:hover { + color: var(--text); + border-color: rgba(88, 166, 255, 0.4); + transform: translateY(-1px); +} + +/* ── Result banner ── */ + +.banner { + border-radius: 8px; + font-family: var(--mono); + font-size: 15px; + font-weight: 700; + padding: 14px 20px; + margin-bottom: 16px; + border: 1px solid; +} + +.banner.pass { + color: var(--green); + background: var(--green-bg); + border-color: var(--green); +} + +.banner.fail { + color: var(--red); + background: var(--red-bg); + border-color: var(--red); +} + +.error-card { + border-color: var(--red); + color: var(--red); + font-family: var(--mono); + font-size: 13px; + white-space: pre-wrap; +} + +/* ── Steps ── */ + +.steps { + list-style: none; + margin: 0; + padding: 0; + font-family: var(--mono); + font-size: 13px; +} + +.steps li { + display: flex; + gap: 10px; + padding: 5px 0; + align-items: baseline; +} + +.step-icon { width: 16px; flex-shrink: 0; text-align: center; } + +.steps li.running .step-icon { color: var(--accent); } +.steps li.done .step-icon { color: var(--green); } +.steps li.fail .step-icon { color: var(--red); } + +.steps li.running .step-icon::after { + content: ""; + display: inline-block; + width: 10px; + height: 10px; + border: 2px solid var(--accent); + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +.step-label { color: var(--text); } +.step-detail { color: var(--text-dim); } + +/* ── Summary ── */ + +.summary-grid { + display: grid; + grid-template-columns: 130px 1fr; + gap: 6px 16px; + margin: 0; +} + +.summary-grid dt { color: var(--text-dim); font-size: 12.5px; padding-top: 1px; } + +.summary-grid dd { + margin: 0; + font-family: var(--mono); + font-size: 13px; + word-break: break-all; +} + +/* ── Checks table ── */ + +table { + width: 100%; + border-collapse: collapse; +} + +th { + text-align: left; + font-size: 11.5px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim); + border-bottom: 1px solid var(--border); + padding: 6px 10px; + font-weight: 600; +} + +td { + padding: 8px 10px; + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +tr:last-child td { border-bottom: none; } + +.col-status { width: 90px; } + +.check-label { font-size: 13.5px; } + +.check-detail { + font-family: var(--mono); + font-size: 12px; + color: var(--text-dim); + margin-top: 3px; + white-space: pre-wrap; + word-break: break-all; +} + +.badge { + display: inline-block; + font-family: var(--mono); + font-size: 11.5px; + font-weight: 700; + border-radius: 4px; + padding: 2px 8px; +} + +.badge.pass { color: var(--green); background: var(--green-bg); } +.badge.fail { color: var(--red); background: var(--red-bg); } +.badge.warn { color: var(--yellow); background: var(--yellow-bg); } +.badge.na { color: var(--text-dim); background: rgba(139, 148, 158, 0.12); } + +/* ── Registry ── */ + +.registry-table tbody tr { cursor: pointer; } +.registry-table tbody tr td { transition: background 0.2s var(--ease-out); } +.registry-table tbody tr:hover td { background: var(--accent-soft); } +.registry-table td { font-family: var(--mono); font-size: 13px; } + +.circuit-detail { + margin-top: 16px; + border-top: 1px solid var(--border); + padding-top: 16px; +} + +.circuit-detail h3 { + margin: 0 0 10px; + font-size: 14px; + font-family: var(--mono); +} + +.kv-grid { + display: grid; + grid-template-columns: 170px 1fr; + gap: 5px 14px; + margin: 0 0 14px; +} + +.kv-grid dt { color: var(--text-dim); font-size: 12.5px; } + +.kv-grid dd { + margin: 0; + font-family: var(--mono); + font-size: 12px; + word-break: break-all; +} + +.vkey-group { margin-bottom: 12px; } + +.vkey-group h4 { + margin: 0 0 6px; + font-size: 12.5px; + color: var(--text-dim); + font-weight: 600; +} + +.vkey-line { + font-family: var(--mono); + font-size: 11.5px; + color: var(--text-dim); + word-break: break-all; + padding-left: 12px; +} + +.vkey-line b { color: var(--text); font-weight: 600; } + +.check-result { margin-top: 16px; } + +/* ── Footer ── */ + +.footer { + text-align: center; + padding: 0 20px 32px; +} + +.tab-panel { display: none; } +.tab-panel.active { display: block; animation: panelfade 0.45s var(--ease-out); } + +@keyframes panelfade { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: none; } +} + +a { color: var(--accent); } + +@media (prefers-reduced-motion: reduce) { + .card, .btn-primary, .btn-ghost, .tab { transition: none !important; } + .card:hover, .btn-primary:hover, .btn-ghost:hover, .tab:hover { transform: none; } + .tab-panel.active { animation: none; } +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 0659612..76e5cde 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -13,4 +13,7 @@ export default defineConfig({ outDir: 'dist', // Keep heavy ZK libraries external (installed as runtime deps, not bundled) external: ['snarkjs', 'ffjavascript', '@zk-kit/poseidon-cipher'], + // Copy the static web UI (served by `maci ui`) next to the bundle + onSuccess: + 'node -e "require(\'node:fs\').cpSync(\'src/web\', \'dist/web\', { recursive: true })"', });