Local demo of a Nostr + Cashu-style voting flow.
The current project covers:
- local allowlist-based voter eligibility
- mock not-voted checks
- mock mint invoice -> proof issuance
- Nostr claim publishing for proof issuance
- ballot publishing with a proof hash
- operator dashboard for allowed and verified voters
The system separates a few concerns:
- voter eligibility is based on an allowed list of
npubs - proof issuance is coordinated through a Mint API flow
- claim and ballot events are published through Nostr relays
- the final ballot carries a hash of the voter proof instead of the raw proof
Right now everything is simplified for local development:
- the allowed voter list is hardcoded in
src/voterConfig.ts - the already-voted check is mocked and always returns
false - the mint is mocked inside the same local server
- proof issuance is time-based, not real blinded ecash yet
flowchart LR
Voter[Voter]
Portal[Voter Portal\nweb/src/App.tsx]
VotePage[Voting Page\nweb/src/VotingApp.tsx]
Dashboard[Dashboard\nweb/src/DashboardApp.tsx]
Wallet[Local Wallet\nweb/src/cashuWallet.ts]
Server[Voter Server\nsrc/voterServer.ts]
Config[Allowed Npub Config\nsrc/voterConfig.ts]
Mint[Mock Mint API\ninside voter server]
Relays[Nostr Relays]
Voter --> Portal
Voter --> VotePage
Voter --> Dashboard
Portal <--> Wallet
VotePage <--> Wallet
Portal --> Server
Dashboard --> Server
Server --> Config
Server --> Mint
Portal --> Relays
VotePage --> Relays
sequenceDiagram
autonumber
actor Voter
participant Portal as Voter Portal
participant Server as Voter Server
participant Mint as Mock Mint API
participant Relay as Nostr Relays
participant Wallet as Local Wallet
participant VotePage as Voting Page
participant Dashboard as Dashboard
Voter->>Portal: Enter npub
Portal->>Server: GET /api/eligibility/check?npub=...
Server->>Server: Check allowlist
Server->>Server: Mock vote-status check
Server-->>Portal: allowed / hasVoted / canProceed
alt allowed and not voted
Voter->>Portal: Request invoice
Portal->>Mint: GET /mock-mint/invoice
Mint->>Server: Read current approved npub
Mint-->>Portal: quoteId, invoice, npub, coordinatorNpub, electionId, questions, relays
Portal->>Wallet: Store invoice metadata
Voter->>Portal: Enter nsec
Portal->>Portal: Build claim event kind 38010
Portal->>Relay: Publish claim event
Portal->>Server: POST /api/debug/claim-log
loop Poll for proof
Portal->>Mint: GET /mock-mint/proof/:quoteId
alt proof pending
Mint-->>Portal: pending
else proof ready
Mint->>Server: Mark npub verified
Mint-->>Portal: proof
Portal->>Wallet: Store single proof
end
end
Voter->>VotePage: Open /vote.html
VotePage->>Wallet: Load invoice metadata + proof
Voter->>VotePage: Answer ballot questions
VotePage->>VotePage: Hash proof
VotePage->>VotePage: Build ballot event kind 38000
VotePage->>Relay: Publish ballot event
VotePage->>Server: POST /api/debug/ballot-log
Dashboard->>Server: GET /api/eligibility
Server-->>Dashboard: allowed npubs + verified npubs
else not allowed or already voted
Server-->>Portal: cannot proceed
Portal->>Voter: Popup warning
end
sequenceDiagram
autonumber
actor Voter
participant Portal as Voter Portal
participant Server as Voter Server
participant Mint as Mint API
participant Relay as Nostr Relays
participant Coordinator as Coordinator / Mint Listener
participant Wallet as Local Wallet
Voter->>Portal: Approved npub passes Step 1
Portal->>Mint: GET /invoice
Mint-->>Portal: quoteId, npub, coordinatorNpub, electionId, questions, relays, invoice
Voter->>Portal: Enter nsec
Portal->>Portal: Sign claim event
Note over Portal: kind 38010\nTags: p, quote, invoice, mint, amount
Portal->>Relay: Publish claim event
Coordinator->>Relay: Subscribe to kind 38010\nfilter by p tag
Relay-->>Coordinator: Matching claim events
Coordinator->>Coordinator: Verify election, quote, npub, signature
Coordinator->>Mint: Approve issuance
Mint-->>Portal: Proof becomes available
Portal->>Wallet: Store proof for voting
- A Node/TypeScript voter server in
src/voterServer.ts - Local allowed voter config in
src/voterConfig.ts - A voter portal in
web/src/App.tsxfor:- entering or generating an
npub/nsec - checking whether the
npubis on the allowlist - checking whether the voter has already voted via a mock API
- requesting a mint invoice
- signing and publishing an invoice claim to Nostr relays
- polling for a proof
- entering or generating an
- A local single-proof wallet in
web/src/cashuWallet.ts - A voting page in
web/src/VotingApp.tsxfor:- loading election metadata from the stored invoice
- answering 2 single-choice ballot questions
- publishing a ballot event with a proof hash
- A dashboard in
web/src/DashboardApp.tsxshowing allowed and verified voters - Server-side debug logs for:
- invoice details
- claim event details
- publish results
- proof details
- Start the local server
- Open the voter portal
- Enter an
npubfrom the allowlist insrc/voterConfig.ts - The server checks:
- the
npubis allowed - the
npubhas not voted yet (mocked tofalse)
- the
- If the check passes, request an invoice from the mock Mint API
- The invoice response provides:
- voter
npub - coordinator
npub - election ID
- ballot questions
- relay list
- voter
- Sign the invoice claim locally with
nsec - Publish that claim to Nostr relays
- Poll until the proof is ready
- Open the voting page and publish a ballot event with the proof hash
docs/ design docs and planning notes
src/ server and CLI TypeScript code
src/voterServer.ts local voter server and mock mint
src/voterConfig.ts hardcoded allowed npubs
web/ React + Vite frontend
web/src/App.tsx voter portal
web/src/VotingApp.tsx voting page
web/src/DashboardApp.tsx operator dashboard
web/src/cashuMintApi.ts mock mint API client
web/src/cashuWallet.ts single-proof local wallet storage
web/src/ballot.ts ballot event publishing and proof hashing
web/src/nostrIdentity.ts Nostr key and claim helpers
web/src/voterManagementApi.ts allowlist and vote-status API client
Install dependencies:
npm install
npm --prefix web installBuild:
npm run build
npm --prefix web run buildStart the local server:
npm run serverStart the frontend dev server:
npm --prefix web run dev- Voter portal:
http://localhost:5173/ - Dashboard:
http://localhost:5173/dashboard.html - Voting page:
http://localhost:5173/vote.html
GET /api/eligibility- returns the local allowlist plus verified voters
GET /api/eligibility/check?npub=...- checks whether the
npubis in the allowlist and can proceed
- checks whether the
GET /api/vote-status?npub=...- mock vote-status API, currently always returns
hasVoted: false
- mock vote-status API, currently always returns
POST /api/debug/claim-log- internal debug endpoint used by the frontend to mirror claim details into the server console
GET /mock-mint/invoice- returns a mock invoice plus voter
npub, coordinatornpub, election ID, and ballot questions
- returns a mock invoice plus voter
GET /mock-mint/proof/:quoteId- returns
pendinguntil proof issuance is ready, then returns the proof and marks the voter as verified
- returns
The current local allowlist lives in src/voterConfig.ts:
npub1ukdwfffcayn5pyt8duv5fyfkwyjrykgr2efql5vmj5y9df4c82lsgkypvgnpub1kl7g5wf90gezwukh44jqtgh7dmdkv6nd20s7u88djqvv433x7ufsjrq6thnpub1et7edyz9vcpdzljns4da5t7l7qgspe3dr6flx09x6jsy2sut5xfqyfnd3u
- State is in-memory only
- The mock already-voted API always returns
false - The local wallet stores one proof per voter session
nsecstays in the browser and is never sent to the server or mint API- The mock mint is not real blinded Cashu issuance yet
- replace mock invoice/proof endpoints with the real teammate mint API
- replace the mock already-voted API with a real spent-proof / participation check
- move election config and ballot questions fully behind the mint/coordinator service
- implement real Cashu blind issuance
- submit proofs privately for vote counting
- build Merkle commitments and public verification tools
docs/demo-development-plan.mddocs/reference-architecture.mddocs/cashu-nostr-voting-design.mddocs/self-service-issuance-3-mint-model.md