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.
| 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. |
| Chain | Governance | ZKL token | Owner |
|---|---|---|---|
| zkLink Nova | 0x35F0371F7dd862C2Ef8A3198fD09dcfB61E1FBeB |
0xC967dabf591B1f4B86CFc74996EAD065867aF19E |
0x9FA3b1D0D516E92b7576AC9DD2Ed8f9d3Fc34e27 |
| Ethereum mainnet | 0x91443f3352CAb8f20DC5114f6DD32798f2E1A39F |
0xfC385A1dF85660a7e041423DB512f779070FCede |
0x9FA3b1D0D516E92b7576AC9DD2Ed8f9d3Fc34e27 |
.
├── 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
- Node 22+ and pnpm
- Foundry (
curl -L https://foundry.paradigm.xyz | bash && foundryup)
cd contracts && pnpm install
cd ../frontend && pnpm installcd frontend
cp .env.example .env.local # optional; see keys below
pnpm dev --host # serves on http://localhost:5173/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.
This app is a static SPA talking directly to the on-chain contracts. Ship it two ways:
# 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 --buildThat'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 --buildStop:
docker compose downNeeds Node 22+ and pnpm.
cp .env.example .env # same keys as above
PORT=3000 ./run.shrun.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.
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.
cd contracts
forge build
forge test -vv # 20/20 tests should passKey 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).
contracts/.env (gitignored) holds the deployer key and addresses. Template at .env.example.
Never commit .env. The .gitignore already excludes it.
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 zkLinkNovacd 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_KEYAfter a fresh deploy, update frontend/src/config.ts with the new governance address and
deployBlock for the chain that was redeployed.
- Owner creates a proposal on one chain (
createProposal(title, description, startTime, endTime)). - Owner mirrors it to the other chain via
mirrorProposal(originalCreator, ...). Must be done beforeendTimeto be useful. The UI hides the Mirror button outside the voting window and for non-owners of the target chain. - Any ZKL holder casts a vote (
castVote(id, choice)). Choice isFor | Against | Abstain. Re-calling overwrites the current choice; every call also appends an immutableVoteRecordto the on-chain history (so audits never need log scanning). - Voting ends at
endTime. Cooldown of 1 hour begins — no voting, no tallying. - Anyone calls
tally(id)aftertallyAvailableAt. The contract iterates the voter array, reads currentbalanceOfper voter, and writes the final numbers. First call wins and freezes the result. - Frontend fuses both chains' results. If a voter held ZKL on both chains, both balances count.
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.
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- Single-step ownership transfer. Consider
Ownable2Stepon the next upgrade. - Per-chain ownership coordination.
owneris 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.
MIT.