SolRengine

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.

· moviendo.me

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

  1. Browser POSTs /poll/vote with the candidate name. Rails authenticates the user via SIWS (the wallet signed in earlier).
  2. 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.
  3. Browser compiles + signs the transaction with @solrengine/wallet-utils. The wallet popup opens, the user approves.
  4. Browser submits to Solana RPC directly. The validator runs the vote instruction, which increments candidate_votes on the PDA.
  5. Browser polls /poll/confirm/:signature until the program reports confirmed. 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: and secure_compares it against the stored nonce).
  • solrengine-programs — Anchor IDL parsing, Borsh encoding, PDA derivation. The whole Voting::VoteInstruction class 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. WalletController for SIWS sign-in, plus findWalletByAddress, 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

Vote tactically — your pick decides what we build next.


Built with SolRengine. MIT licensed.

← All posts