A browser-first, relay-assisted decentralized polling platform. Votes are recorded on a local blockchain, poll data lives in a distributed database (GunDB), and peers find each other through a lightweight WebSocket relay. The app supports offline continuity with later convergence when a connection is available. Records are harder to erase than on a single-server platform, but not impossible to erase.

You need two things running: the frontend dev server and the relay server.
chmod 777 run.sh
./run.shThe app opens at http://localhost:5173. The relay listens on port 8080.
The script loads GunDB,WS, and client. Alternative clients coming soon
The frontend reads these at build time (prefix with VITE_):
| Variable | Default | Purpose |
|---|---|---|
VITE_WS_RELAY_URL |
ws://localhost:8080 |
WebSocket relay |
VITE_GUN_RELAY_URL |
http://localhost:8765/gun |
GunDB relay |
VITE_API_BASE_URL |
http://localhost:8080 |
Backend API |
You can also change relay URLs at runtime from the Settings page. Those overrides are saved in localStorage and take priority.
The relay server reads these directly from the environment:
| Variable | Default | Purpose |
|---|---|---|
FRONTEND_ORIGIN |
http://localhost:5173 |
CORS origin |
SERVER_ORIGIN |
http://localhost:8080 |
Public relay origin used for OAuth callback URIs (required and must be HTTPS in production) |
VOTE_RESERVATION_SECRET |
random per process | HMAC secret for short-lived vote reservation tokens |
GOOGLE_CLIENT_ID |
-- | Google OAuth app ID |
GOOGLE_CLIENT_SECRET |
-- | Google OAuth secret |
MS_CLIENT_ID |
-- | Microsoft OAuth app ID |
MS_CLIENT_SECRET |
-- | Microsoft OAuth secret |
MS_TENANT |
common |
Azure AD tenant |
OAuth is optional. The app works fine without it. Polls can optionally require login to vote -- that is the only feature gated behind OAuth.
npm run dev # Start Vite dev server
npm run build # Type-check + production build
npm run preview # Serve the built dist/ folder locallyThe system has four layers that each handle a different concern.
For a detailed, implementation-aligned protocol write-up, see docs/protocol-whitepaper.md.
graph TD
A[Browser Tab] -->|votes, polls| B[Local Blockchain - IndexedDB]
A -->|poll metadata, users, images| C[GunDB - Distributed]
A -->|peer sync, new blocks| D[WebSocket Relay]
A -->|cross-tab sync| E[BroadcastChannel API]
D -->|relays messages| F[Other Peers]
C -->|replicates| G[GunDB Relay Server]
Every vote gets recorded as a block in a local chain stored in IndexedDB. The chain is append-only and tamper-evident.
A block looks like this:
index: sequential number (0 for genesis)
timestamp: when the block was created
previousHash: SHA-256 of the block before it
voteHash: SHA-256 of the vote data (pollId + choice + deviceId + timestamp)
signature: SHA-256 of {index, voteHash, previousHash} + signing key
currentHash: SHA-256 of the entire block
When someone casts a vote:
- The vote data is hashed with SHA-256
- A new block is created linking to the previous block's hash
- The block is signed and its own hash is computed
- The block and vote are saved to IndexedDB
- A verification code (receipt code) is generated and stored as a human-readable receipt
- The block is broadcast to other peers via WebSocket and BroadcastChannel
Validation walks the entire chain and checks that every block's previousHash matches the preceding block's currentHash, every block's own hash recomputes correctly, and signatures verify. If any block has been tampered with, the chain breaks.
graph LR
G[Genesis Block<br/>index: 0<br/>prev: 000...000] -->|hash links to| B1[Block 1<br/>Vote: Alice -> Option A]
B1 -->|hash links to| B2[Block 2<br/>Vote: Bob -> Option B]
B2 -->|hash links to| B3[Block 3<br/>Vote: Carol -> Option A]
After voting, users get a receipt containing a short verification code. This receipt maps to a specific block in the chain. Anyone can look up a receipt in the Chain Explorer to verify that their vote was recorded and has not been altered. The verification code is a human-readable lookup identifier — it is not a BIP-39 wallet seed or private key, and it is safe to share.
Duplicate voting is prevented at multiple levels:
- Device fingerprinting. A SHA-256 hash of browser properties (user agent, screen size, timezone, canvas fingerprint) creates a persistent device ID. The app tracks which polls each device has voted on.
- Backend authorization. If the relay server is reachable, it maintains a persisted vote registry and rejects duplicates with a two-phase authorize/confirm flow. This path fails closed on backend errors.
- Invite codes. Private polls generate single-use alphanumeric codes. Each code is marked as consumed atomically in GunDB when used.
- OAuth gating. Polls can optionally require a Google or Microsoft login before accepting a vote.
The production relay (relay-server/relay-server-enhanced.js via PM2) uses a two-phase vote flow: /api/vote-authorize creates only a short-lived pending reservation and returns a short-lived reservation token, then /api/vote-record or /api/vote-confirm commits the vote (with that token) to the persisted registry at relay-server/data/vote-registry.json.
When deploying production for this rollout, reset relay-server/data/vote-registry.json to [] before restarting PM2 so stale persisted entries do not keep previously blocked voters locked out.
Verified usernames use an external trust issuer API:
GET /public-key→ issuer metadata (issuerDomain,publicKey)POST /challenge-v2with{ username, pubkey }→ PoW challenge (preferred)POST /claim-v2with{ challengeId, nonce, username, pubkey, authTs, authNonce, authSig }→ signed certificate- Legacy fallback remains supported for older clients:
POST /challengewith{ username, pubkey }POST /claimwith{ challengeId, nonce, username, pubkey }
The client verifies certificate signature and username/pubkey binding locally before writing level: 'verified' claims to GunDB (usernames/{username}). Issuer endpoints must be HTTPS (localhost HTTP allowed for development), and issuer domain must match endpoint host binding.
Poll metadata, communities, user profiles, posts, comments, and images all live in GunDB -- a distributed, eventually-consistent database. Each browser keeps a local copy and syncs with a GunDB relay server. If the relay goes down, data persists locally and syncs when the relay comes back.
When Gun bootstrap is empty, the app can hydrate communities from relay DB snapshot endpoints (/db/search, /db/soul). Fallback ingestion is strict: only top-level canonical community nodes are accepted ({namespace}/communities/{id} with matching data.id, c-* IDs) so poll/index rows cannot appear as communities.
Images are compressed client-side (max 500KB, thumbnails at 20KB), base64-encoded, and stored as GunDB nodes.
The WebSocket relay handles peer discovery and message broadcasting. When a peer connects, it:
- Registers with a random peer ID and joins the default room
- Announces its relay URLs to other peers
- Shares its list of known servers (so peers can discover alternative relays)
- Requests a full chain sync from existing peers
When a new block is created, it is broadcast to all connected peers who merge it into their local chains. The BroadcastChannel API handles the same sync between tabs in the same browser, no network needed.
Peers can discover and switch between relay servers at runtime from the Settings page. Known servers accumulate as peers share their configurations with each other.
You can drop a PR or run peer.js on your laptop to optimise the response time.
src/
components/ UI components (VoteForm, PollCard, PostCard, etc.)
views/ Page-level components (HomePage, VotePage, SettingsPage, etc.)
services/ Core logic -- blockchain, GunDB, WebSocket, crypto, storage
stores/ Pinia state stores (chainStore, pollStore, communityStore, etc.)
router/ Vue Router configuration
config.ts Centralized config with runtime-mutable relay URLs
relay-server.js Dev WebSocket relay + OAuth + vote authorization backend
relay-server/
relay-server-enhanced.js Production PM2 relay with persisted vote registry + two-phase vote commit endpoints
gun-relay-server/
gun-relay.js GunDB relay server
Key services:
| File | What it does |
|---|---|
chainService.ts |
Block creation, hashing, signing, chain validation |
gunService.ts |
GunDB read/write/subscribe wrapper |
websocketService.ts |
WebSocket connection, peer discovery, server list sharing |
broadcastService.ts |
Cross-tab sync via BroadcastChannel |
pollService.ts |
Poll CRUD, invite code generation and validation |
voteTrackerService.ts |
Device fingerprinting, duplicate vote prevention |
cryptoService.ts |
SHA-256 hashing, verification code generation |
auditService.ts |
OAuth login/logout, backend vote authorization |
storageService.ts |
IndexedDB wrapper for blocks, votes, receipts |
pinningService.ts |
Storage policies and quota management |
ipfsService.ts |
Image compression, upload, and retrieval via GunDB |