SolanaStudio
Ruby primitives for building on Solana — JSON-RPC client, Ed25519 keypairs, Borsh serialization, and transaction builder with PDA derivation.
Part of the McRitchie ecosystem — see
ECOSYSTEM.mdfor the 5-repo map;house-burn-down.mdfor fresh-Mac recovery.
Installation
# Gemfile
gem "solana-studio", "~> 0.5"
Consumer apps use the RubyGems release. Use a local path only while actively developing the gem, and restore the RubyGems dependency before merging.
Usage
Keypair
require "solana-studio"
# Generate a new keypair
kp = Solana::Keypair.generate
kp.address # => "9Fy8P3DvKBh3awt1wr27g4CDh47oDqmJR2FAAQ1bc69D"
kp.to_bytes # => 64-byte Solana format
# Load from file or env
kp = Solana::Keypair.from_json_file("~/.config/solana/id.json")
kp = Solana::Keypair.from_base58(ENV["SOLANA_ADMIN_KEY"])
# Sign a message
signature = kp.sign("hello".b)
Client (JSON-RPC)
client = Solana::Client.new(rpc_url: "https://api.devnet.solana.com")
client.get_balance("9Fy8P3DvKBh3awt...")
client.get_latest_blockhash
client.request_airdrop("9Fy8P3DvKBh3awt...", 1_000_000_000)
client.send_and_confirm(signed_tx_base64)
Borsh Serialization
data = Solana::Borsh.encode_u64(1_000_000) +
Solana::Borsh.encode_string("hello") +
Solana::Borsh.encode_pubkey(kp.public_key_bytes)
Transaction Builder
tx = Solana::Transaction.new
tx.set_recent_blockhash(client.get_latest_blockhash)
tx.add_signer(keypair)
tx.add_instruction(
program_id: "YourProgramId...",
accounts: [
{ pubkey: keypair.public_key_bytes, is_signer: true, is_writable: true },
{ pubkey: pda, is_signer: false, is_writable: true }
],
data: Solana::Transaction.anchor_discriminator("your_instruction") + payload
)
signature = client.send_and_confirm(tx.serialize_base64)
PDA Derivation
pda, bump = Solana::Transaction.find_pda(
["vault".b, wallet_pubkey_bytes],
program_id_bytes
)
Network (cluster identity)
A Solana cluster has three names that must agree, and nothing in the protocol
makes them agree for you: the operator's name (devnet), the chain's own
fingerprint (its genesis hash), and the wallet's name (solana:devnet).
Solana::Network is the lookup table that relates them.
Solana::Network.genesis_hash("devnet") # => "EtWTRABZaYq6..."
Solana::Network.cluster_for_genesis(hash) # => "mainnet-beta" (what an RPC ACTUALLY is)
Solana::Network.wallet_standard_chain("mainnet-beta") # => "solana:mainnet" (note: no -beta)
Solana::Network.canonical("mainnet") # => "mainnet-beta"; nil if unrecognized
Solana::Network.expected_for_environment("qa") # => "devnet"
Alignment has three outcomes, and collapsing the middle one is a bug:
Solana::Network.alignment(cluster: "devnet", genesis_hash: live_hash)
# => :aligned | :mismatched | :unverifiable
:unverifiable means there was no pinned hash to compare — localnet (whose
genesis is minted per boot) or an unrecognized cluster name. Treating it as
:mismatched refuses to boot every local validator; treating it as :aligned
trusts a chain nobody checked.
Rails engine (optional)
The gem is Rails-free by default — railties is not a runtime dependency,
and plain-Ruby consumers (scripts, rake tasks, chain-ops) never load a line of
it. When the gem is required inside a Rails process, SolanaStudio::Engine
defines itself and contributes the onchain UI primitives.
Network mismatch guard
The problem: a user whose wallet is set to Mainnet, using a QA app that runs on Devnet. Their wallet simulates against the wrong chain, shows a frightening approval sheet and a balance from a chain nobody is using, and they abandon the flow.
A website cannot detect this directly. Phantom does not expose its selected
network, and the Wallet Standard chains array lists what a wallet supports,
not what it has selected. There is no pre-flight read to write. So the guard
does the only two things that work:
1. Assert at sign-in. Hand the wallet a SIWS chainId and let it contradict
you — the one pre-emptive signal that exists.
var signInInput = SolanaStudio.network.withSignInChainId({
domain: window.location.host,
nonce: nonce
});
// => adds chainId: "solana:devnet"
2. Explain after a failure. Wrap any onchain action. The guard never blocks and never swallows an error — it re-throws the original rejection untouched, and hands you a hint only when a mismatch would actually explain the failure.
SolanaStudio.network.guard(
function() { return provider.signTransaction(tx); },
{
action: "Entering this contest",
onHint: function(hint) { Alpine.store('modals').open('network-mismatch', hint); }
}
).then(broadcast); // your existing .catch still receives the real error
classify(err) returns "likely", "possible", or "unrelated", and is
calibrated to under-claim: an insufficient-funds error stays an
insufficient-funds error. Dressing up an unrelated failure as a network problem
sends the user to fix the wrong thing, which is the bug this feature exists to
remove.
Host setup
<%# once, inside your modal host %>
<template x-if="$store.modals.current().id === 'network-mismatch'">
<%= render "solana_studio/modals/network_mismatch" %>
</template>
<%# so the browser can read what the server knows %>
<body data-solana-network="<%= Solana::Network.describe(
Solana::Config::NETWORK, environment: Rails.env).to_json %>">
The guard falls back to discrete data-solana-cluster / data-app-environment
attributes, so a host can adopt it before changing its layout.
Requires studio-engine's modal host (Alpine.store('modals')) and its shared
modal blocks. The JS is solana_studio/network_guard.js on the asset path.
Dependencies
ed25519(~> 1.3) — Ed25519 signing- Ruby stdlib only (net/http, json, digest, securerandom)
- No Rails dependency.
railtiesis a development dependency only; the engine loads solely when the host has already loaded Rails.
Development Notes
See RUNBOOK.md for troubleshooting and local test commands.
The browser lane
The Ruby suite cannot see two things this gem ships: whether
network_guard.js actually runs in a browser, and whether
_network_mismatch.html.erb renders (rendering it needs studio-engine's modal
blocks and a view context, so test/views_test.rb only proves it compiles).
npm ci && npx playwright install chromium
npx playwright test # ~40s, boots its own server
npx playwright test --headed # watch it
bin/e2e-executed-set-check # did the lane run its WHOLE declared set?
The lane drives the shipped bytes: e2e/boot.rb copies the real
app/assets/javascripts/solana_studio/network_guard.js into the dummy's public/,
and the lab pages render the real partials by name. A lab page may set up a
partial's locals and nothing else — test/e2e_lane_contract_test.rb asserts that,
because a page that hand-rolled what the gem does would leave the specs grading the
lab while reporting green over untested gem code.
It is not in bin/release-check. It runs as a parallel CI job, so it adds ~0
to the wall time a PR waits and a release does not install a browser to publish.
Two halves, one number. config/e2e_lane.yml declares how many specs must
execute. bin/e2e-executed-set-check reads Playwright's own receipt after the run
and asserts the executed set against it; test/e2e_lane_contract_test.rb asserts
the committed specs still declare it. Static counting answers "how many are
DECLARED" and can never answer "how many RAN" — and a runtime skip, a stray
--grep, --only-changed, and an uncollected file are four spellings of the same
event. The receipt turns all four into one arithmetic failure.
Derive the counts, never hand-count them: npx playwright test --list.
Run the suite with bin/release-check — the same entry point CI and the release
gate use, so they cannot drift apart. It enumerates test files by glob (no list
to forget a file from) and fails a file that runs zero tests or skips one,
because a suite that quietly stops covering something is the failure a green
build cannot show you. node is required: the browser guard's suite runs the
shipped .js under node with window/document stubs.
License
MIT