Voting on Solana, built with Rails
An on-chain voting dApp built with Rails 8 and SolRengine. The Rails DB stores only users; polls, candidates, and votes all live on Solana. The architecture, the perf wins, and what we learned shipping it.
voting.solrengine.org is live. It asks one question:
Which dApp should we showcase next on solrengine.org?
Connect a wallet, click Vote on your pick, sign with one click. The vote lands on Solana devnet, the count updates, and the next visitor sees the new tally. No accounts, no email, no database row for your vote — it lives on-chain.
Source: solrengine/voting. Anchor program: solrengine/voting-anchor.
The unusual thing
Rails apps usually have a database. This one barely does.
The SQLite file behind voting.solrengine.org has exactly one table — users, with two columns that matter: wallet_address and a SIWS nonce. That’s it. No polls table. No candidates table. No votes table. All of that lives on Solana.
voting.solrengine.org
│
┌───────────────┴───────────────┐
│ │
Rails (SQLite) Solana (devnet)
──────────────── ─────────────────
users PollAccount ← name, description, window
CandidateAccount ← name, vote count
(per-poll PDAs)
Why? Because the chain is already a database — durable, auditable, anyone can verify it. Mirroring chain state into Postgres just creates two sources of truth that drift apart. So we don’t.
How a vote works
- Browser POSTs
/poll/votewith the candidate name. Rails authenticates the user via SIWS (the wallet signed in earlier). - Rails builds an unsigned instruction. It derives the candidate’s PDA from
[poll_id, candidate_name], packs the Borsh-encoded args, fetches a fresh blockhash. Returns the bytes as JSON. - Browser compiles + signs the transaction with
@solrengine/wallet-utils. The wallet popup opens, the user approves. - Browser submits to Solana RPC directly. The validator runs the
voteinstruction, which incrementscandidate_voteson the PDA. - Browser polls
/poll/confirm/:signatureuntil the program reportsconfirmed. Then the page reloads with the new count.
The Rails server signs nothing at runtime. It hands the user signed-ready bytes; the user’s wallet is the only thing that holds keys. That’s the security boundary.
The architecture, in a few classes
app/models/voting/
poll_snapshot.rb — request-scoped value object: chain + YAML merged
poll_account.rb — Borsh-decoded PollAccount + #open?, #ended?, #voting_state
candidate_account.rb — Borsh-decoded CandidateAccount
ballot_candidate.rb — Data.define wrapping {config, address, account}
vote_instruction.rb — declarative Anchor instruction + #to_wire_payload
candidate_config.rb — config/candidates.yml loader (Data.define)
app/controllers/
polls_controller.rb — GET /poll (HTML + JSON)
polls/votes_controller.rb — POST /poll/vote, GET /poll/confirm/:signature
config/candidates.yml — poll id, presentation metadata, candidate names
PollSnapshot.current is the workhorse. One call per request:
class Voting::PollSnapshot
def self.current = new(CandidateConfig.poll)
def poll_account = loaded[:poll]
def candidates = loaded[:candidates]
def voting_state(now: Time.current)
return :not_initialized if poll_account.nil?
poll_account.voting_state(now: now)
end
private
def load_accounts
addresses = [poll_pda, *candidate_pdas]
values = Solrengine::Rpc.client.request("getMultipleAccounts", [
addresses, { "encoding" => "base64", "commitment" => "confirmed" }
]).dig("result", "value")
# ...decode each value into typed accounts
end
end
One getMultipleAccounts call brings the poll + every candidate from chain in a single round-trip. With HTTP keep-alive in solrengine-rpc, the page renders against devnet in ~170 ms cold.
The gems doing the heavy lifting
- solrengine-auth — Sign In With Solana, mounted as an engine. Handles nonce challenge, signature verification with binding, session creation. v0.2.0 closes a signature-replay vulnerability (the verifier now requires
expected_nonce:andsecure_compares it against the stored nonce). - solrengine-programs — Anchor IDL parsing, Borsh encoding, PDA derivation. The whole
Voting::VoteInstructionclass is 17 lines of declarative DSL:
class Voting::VoteInstruction < Solrengine::Programs::Instruction
program_id Voting::PROGRAM_ID
instruction_name "vote"
argument :poll_id, "u64"
argument :candidate, "string"
account :signer, signer: true, writable: true
account :poll_account, writable: true, pda: [
{ const: "poll".bytes },
{ arg: :poll_id, type: :u64 }
]
account :candidate_account, writable: true, pda: [
{ arg: :poll_id, type: :u64 },
{ arg: :candidate, type: :string }
]
end
PDAs are derived from declarative seed specs — no manual find_program_address math at the call site.
solrengine-rpc — JSON-RPC client with per-thread HTTPS keep-alive. Reuses TCP + TLS connections across requests; first deploy saw page latency drop from ~1 s cold to ~170 ms warm.
@solrengine/wallet-utils — npm package.
WalletControllerfor SIWS sign-in, plusfindWalletByAddress,compileTransactionMessage,signAndSend— used by the voting Stimulus controller to drive the wallet popup.
The frontend, in one Stimulus controller
The vote button is wired to a 7-state machine:
IDLE → CONNECTING → PREPARING → SIGNING → CONFIRMING → SUCCESS
│
│ (stale blockhash retry)
▼
PREPARING (one-shot)
any step → ERROR
A single #setState(state, message) is the only thing that mutates the DOM. Confirmation polls /poll/confirm/:signature every second for up to 45 seconds; only after the program reports confirmed does the page reload. A page-level window.__votingInFlight guard prevents cross-button double-submits if a user clicks Vote on candidate A and then candidate B before A’s wallet popup resolves.
If the user takes a phone call mid-signing, the blockhash expires; the controller catches the specific RPC error, fetches a fresh blockhash, and re-runs the sign+send once. The user sees one extra wallet popup, not a cryptic error banner.
Rotating the poll
Want to ask a different question next month? Same Anchor program, fresh poll_id. Edit config/candidates.yml:
poll:
id: 5 # bump
name: "Your next question"
start_time: 1781000000
end_time: 1783500000
candidates:
- name: "OptionOne"
description: "..."
url: "..."
- name: "OptionTwo"
...
Then from your laptop (the keypair never enters production):
export SOLANA_KEYPAIR_FILE=~/.config/solana/id.json
bin/rails voting:init_poll
bin/rails voting:init_candidates
bin/rails voting:verify # asserts yaml ≡ chain
Deploy. Done. The old poll’s PDAs stay on-chain forever — frozen, queryable on Solscan, just not rendered by the dApp anymore.
A second deployment of the dApp pointing at a different poll_id (different domain, different question, different candidates) shares the same Anchor program with zero interference. The contract is generic; the YAML is the discriminator.
What we shipped, by the numbers
- 3 days from initial commit to live at voting.solrengine.org
- 15 commits on the way (squashed to 1 for the public release)
- ~170 ms end-to-end page load against devnet
- 1 RPC round-trip per page load (down from 5 sequential before batching)
- 13 tests covering the read path, vote validation, frontend race conditions
- Rate-limited
/auth/nonce,/auth/verify,/poll/vote,/poll/confirm/:signature - CSP enforcing with no inline scripts (Stimulus only)
Try it
- Live: voting.solrengine.org — connect a wallet, vote on the next showcase
- Source: solrengine/voting
- Anchor program: solrengine/voting-anchor
Vote tactically — your pick decides what we build next.
Built with SolRengine. MIT licensed.