Skip to content

zkLinkProtocol/zkl-vote-app

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ZKL Governance

Snapshot-style on-chain governance for ZKL holders, deployed on zkLink Nova and Ethereum mainnet. All state — proposals, votes, vote history, final tallies — lives on-chain. There is no backend service.

Design at a glance

Concern Approach
Backend None. The UI reads contracts directly via view calls.
Storage Contract holds proposals, voter set, current choices, append-only vote history, and final tally.
Vote power balanceOf(voter) at tally time — "hold ZKL until the end to be counted".
Cooldown 1h between voting close (endTime) and first legal tally() call.
Cross-chain fusion Identical proposal deployed on both chains with the same derived id. Per-chain tallies summed.
Owner Only the configured owner may createProposal / mirrorProposal. Anyone may vote or tally().
No log scanning The UI uses getVoteHistory(id, offset, limit) — never eth_getLogs.
Text-only proposals The contract does not execute anything. Governance publishes community sentiment only.

Deployed addresses (v2)

Chain Governance ZKL token Owner
zkLink Nova 0x35F0371F7dd862C2Ef8A3198fD09dcfB61E1FBeB 0xC967dabf591B1f4B86CFc74996EAD065867aF19E 0x9FA3b1D0D516E92b7576AC9DD2Ed8f9d3Fc34e27
Ethereum mainnet 0x91443f3352CAb8f20DC5114f6DD32798f2E1A39F 0xfC385A1dF85660a7e041423DB512f779070FCede 0x9FA3b1D0D516E92b7576AC9DD2Ed8f9d3Fc34e27

Repository layout

.
├── contracts/           Foundry + Hardhat-ZKsync
│   ├── src/Governance.sol        Main contract
│   ├── test/Governance.t.sol     Foundry tests (20 cases)
│   ├── script/Deploy.s.sol       Foundry deploy script (mainnet)
│   ├── deploy/deploy-nova.ts     Hardhat-ZKsync deploy script (Nova)
│   ├── foundry.toml / hardhat.config.ts
│   └── .env.example              Template for secrets (copy to .env)
├── frontend/            Vite + React 19 + wagmi 2 + ConnectKit
│   ├── src/
│   │   ├── abi/governance.ts     Typed ABI
│   │   ├── config.ts             Chain + deployment config, LAUNCH_AT filter
│   │   ├── wagmi.ts              wagmi + ConnectKit config, pinned RPC
│   │   ├── hooks/                useProposals / useProposal / useVoteHistory / useFusedTallies / useWriteGov
│   │   ├── pages/                List / Detail / Create
│   │   └── components/           Layout, TallyBar, ChainSplit, WalletButton, AboutPanel, SnapshotHistoryList, …
│   ├── public/
│   │   ├── snapshot-history.json  Snapshot of the old off-chain space (zklink.eth)
│   │   └── favicon.svg            Nova branding
│   └── .env.example              Template for frontend secrets (copy to .env.local)
└── README.md

Local development

Prerequisites

  • Node 22+ and pnpm
  • Foundry (curl -L https://foundry.paradigm.xyz | bash && foundryup)

Install

cd contracts && pnpm install
cd ../frontend && pnpm install

Run the app against the live contracts

cd frontend
cp .env.example .env.local        # optional; see keys below
pnpm dev --host                   # serves on http://localhost:5173/

Environment variables (frontend)

frontend/.env.local (optional):

# Unlocks WalletConnect modal + mobile QR wallets.
# Get a free project id at https://cloud.walletconnect.com/
VITE_WALLETCONNECT_PROJECT_ID=

# Hide on-chain proposals whose startTime is earlier than this cutoff.
# Useful at launch to suppress test items. Empty = show all.
VITE_LAUNCH_AT=2026-04-21T00:00:00Z

Injected wallets (MetaMask, Rabby, Brave, Coinbase Wallet browser) work without a WalletConnect project id.

Deploy to a server (one command)

This app is a static SPA talking directly to the on-chain contracts. Ship it two ways:

Option A — Docker Compose (recommended for servers)

# on the server, in the project root
cp .env.example .env         # edit: set PORT, VITE_LAUNCH_AT, VITE_WALLETCONNECT_PROJECT_ID
docker compose up -d --build

That's it. Nginx serves on :${PORT} (default 8999 to avoid clashing with an existing server-level nginx on 80/443) with gzip + SPA fallback + asset caching. Put your reverse proxy / load balancer in front of it for TLS and the public domain. Rebuild + redeploy when any VITE_* value changes:

docker compose up -d --build

Stop:

docker compose down

Option B — Bare metal (no Docker)

Needs Node 22+ and pnpm.

cp .env.example .env         # same keys as above
PORT=3000 ./run.sh

run.sh installs dependencies (first run only), builds the production bundle, and starts a static server on $PORT using Vite preview. Put it behind your existing nginx / caddy / cloud LB if you need TLS.

What's in the deploy bundle

Files relevant to shipping:

Dockerfile             multi-stage: pnpm build → nginx:alpine
docker-compose.yml     one-service stack; reads .env
deploy/nginx.conf      gzip, SPA fallback, aggressive asset caching
.dockerignore          excludes node_modules, build artifacts, contracts cache, secrets
run.sh                 non-Docker fallback for bare metal
.env.example           runtime knobs (PORT, launch time, WC project id)

Everything the browser needs is baked into dist/ at build time. No backend process, no database. The only runtime dependencies are the two on-chain RPCs (Nova + Ethereum), which are pinned in frontend/src/config.ts.

Contracts

Build & test

cd contracts
forge build
forge test -vv          # 20/20 tests should pass

Key coverage includes: deterministic cross-chain id, owner-gated create/mirror, vote/change/tally window enforcement, cooldown respect, "balance at tally time" rule, append-only history pagination, and a gas benchmark of tally() with 100 voters (~235k gas).

Secrets

contracts/.env (gitignored) holds the deployer key and addresses. Template at .env.example. Never commit .env. The .gitignore already excludes it.

Deploy — zkLink Nova (ZKsync stack, needs hardhat-zksync)

cd contracts
set -a; source .env; set +a    # load secrets into the shell

ZKL_ADDRESS=$ZKL_NOVA pnpm exec hardhat deploy-zksync \
  --script deploy-nova.ts --network zkLinkNova

Deploy — Ethereum mainnet (standard EVM, Foundry)

cd contracts
set -a; source .env; set +a

ZKL_ADDRESS=$ZKL_MAINNET forge script script/Deploy.s.sol \
  --rpc-url $MAINNET_RPC_URL \
  --broadcast \
  --private-key $PRIVATE_KEY

After a fresh deploy, update frontend/src/config.ts with the new governance address and deployBlock for the chain that was redeployed.

Governance flow

  1. Owner creates a proposal on one chain (createProposal(title, description, startTime, endTime)).
  2. Owner mirrors it to the other chain via mirrorProposal(originalCreator, ...). Must be done before endTime to be useful. The UI hides the Mirror button outside the voting window and for non-owners of the target chain.
  3. Any ZKL holder casts a vote (castVote(id, choice)). Choice is For | Against | Abstain. Re-calling overwrites the current choice; every call also appends an immutable VoteRecord to the on-chain history (so audits never need log scanning).
  4. Voting ends at endTime. Cooldown of 1 hour begins — no voting, no tallying.
  5. Anyone calls tally(id) after tallyAvailableAt. The contract iterates the voter array, reads current balanceOf per voter, and writes the final numbers. First call wins and freezes the result.
  6. Frontend fuses both chains' results. If a voter held ZKL on both chains, both balances count.

Owner operations

  • transferOwnership(newOwner) — single-step handover. Two chains hold ownership independently; if you're moving to a multisig, remember to execute on both. Contract has no two-step variant yet.
  • createProposal / mirrorProposal — owner only. Text proposals only; no execution payload.

Historical Snapshot proposals

The previous off-chain space at https://snapshot.box/#/s:zklink.eth is preserved in frontend/public/snapshot-history.json (fetched from Snapshot's GraphQL hub). The UI merges them into the "All" tab with a clear Historical · Snapshot badge and a link back to the original proposal. To refresh the snapshot later:

curl -X POST https://hub.snapshot.org/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{ proposals(first: 100, where: { space_in: [\"zklink.eth\"] }, orderBy: \"created\", orderDirection: desc) { id title body choices start end snapshot state author scores scores_total votes created type } }"}' \
  | jq '{ source: "https://snapshot.box/#/s:zklink.eth", space: "zklink.eth", spaceName: "zkLink", fetchedAt: (now | todate), proposals: [ .data.proposals[] | { id, title, body, choices, scores, scoresTotal: .scores_total, votes, state, author, type, startTime: .start, endTime: .end, snapshotBlock: .snapshot, created, url: ("https://snapshot.box/#/s:zklink.eth/proposal/" + .id) } ] }' \
  > frontend/public/snapshot-history.json

Known limitations / future work

  • Single-step ownership transfer. Consider Ownable2Step on the next upgrade.
  • Per-chain ownership coordination. owner is independent on each chain; social coordination is required to keep them aligned (e.g. multisig with identical signers on each chain).
  • Fixed cooldown at compile time (1 hour). Move to a constructor arg or owner-settable variable if different proposals want different cooldowns.
  • Creator address is frozen into the proposal id. An owner transfer does not retroactively re-attribute old proposals.

License

MIT.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors