solana-ruby-kit
A Ruby port of @anza-xyz/kit — the official Solana TypeScript SDK — translated into idiomatic Ruby with Sorbet static types.
Every module maps 1-to-1 to a TypeScript package. All methods are synchronous (Ruby's RbNaCl is synchronous; TypeScript's Web Crypto API is not). This port now tracks the anza-xyz/kit version.
Requirements
- Ruby >= 3.2
- libsodium (required by
rbnacl)
# macOS
brew install libsodium
# Debian / Ubuntu
apt-get install libsodium-dev
Installation
# Gemfile
gem 'solana-ruby-kit'
bundle install
Examples
Full worked examples live on the wiki:
- Quick Start
- Create a Wallet
- Transfer SOL
- Create an Associated Token Account
- Stake SOL with a Validator
- Verify a Wallet-Signed Transaction in Rails
- Build a Transaction for Browser Signing
The sections below cover configuration and the per-module API reference.
Rails
The gem includes a Railtie that auto-configures when Rails is present. Add it to your Gemfile as usual, then run the install generator:
rails generate solana:ruby:kit:install
This creates config/initializers/ruby_kit.rb:
Solana::Ruby::Kit.configure do |config|
config.rpc_url = 'https://api.mainnet-beta.solana.com'
config.ws_url = 'wss://api.mainnet-beta.solana.com'
config.commitment = :confirmed
config.timeout = 30
end
Or configure via config/application.rb:
config.ruby_kit.rpc_url = ENV['SOLANA_RPC_URL']
config.ruby_kit.commitment = :finalized
Get a pre-configured client anywhere in your app:
rpc = Solana::Ruby::Kit.rpc_client
Configuration
| Option | Default | Description |
|---|---|---|
rpc_url |
https://api.mainnet-beta.solana.com |
JSON-RPC endpoint |
ws_url |
nil |
WebSocket endpoint for subscriptions |
commitment |
:confirmed |
Default commitment level |
timeout |
30 |
HTTP read timeout in seconds |
Modules
Solana::Ruby::Kit::Addresses — @solana/addresses
Validate and work with base58-encoded Solana addresses.
Addr = Solana::Ruby::Kit::Addresses
# Validate
Addr.address?('11111111111111111111111111111111') # => true
# Wrap into a typed Address value object (raises on invalid input)
addr = Addr.address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA')
# Encode / decode raw bytes
bytes = Addr.decode_address(addr) # => 32-byte binary String
str = Addr.encode_address(bytes) # => base58 String
# Program Derived Addresses (PDAs)
program = Addr.address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA')
pda = Addr.get_program_derived_address(
program_address: program,
seeds: ['my-seed', [1, 2, 3]]
)
puts pda.address # => Address
puts pda.bump # => Integer (0-255)
Solana::Ruby::Kit::Keys — @solana/keys
Ed25519 key generation, signing, and verification via libsodium.
Keys = Solana::Ruby::Kit::Keys
# Generate
kp = Keys.generate_key_pair
kp.signing_key # => RbNaCl::SigningKey
kp.verify_key # => RbNaCl::VerifyKey
# From 64 raw bytes (seed || public key)
kp = Keys.create_key_pair_from_bytes(File.binread('wallet.bin'))
# From 32-byte private seed only
kp = Keys.create_key_pair_from_private_key_bytes(seed_bytes)
# Sign / verify
sig = Keys.sign_bytes(kp.signing_key, data)
ok = Keys.verify_signature(kp.verify_key, sig, data)
# Base58-encode a signature
Keys.encode_signature(sig) # => Signature string
Keys.decode_signature(str) # => SignatureBytes
Solana::Ruby::Kit::Signers — @solana/signers
High-level signer abstraction that wraps a key pair and exposes an address.
Signers = Solana::Ruby::Kit::Signers
# Random new signer
signer = Signers.generate_key_pair_signer
signer.address # => Addresses::Address
signer.to_s # => base58 string
# From existing key pair
signer = Signers.create_signer_from_key_pair(kp)
# From raw bytes
signer = Signers.create_key_pair_signer_from_bytes(bytes_64)
signer = Signers.create_key_pair_signer_from_private_key_bytes(seed_32)
# Sign arbitrary data
sig = signer.sign()
# Sign a batch of messages for multiple signers
map = Signers.([signer1, signer2], bytes)
# => { "addr1" => SignatureBytes, "addr2" => SignatureBytes }
Solana::Ruby::Kit::TransactionMessages — @solana/transaction-messages
Immutable transaction message builder. Every method returns a new struct; originals are unmodified.
TxMsg = Solana::Ruby::Kit::TransactionMessages
msg = TxMsg.(version: 0)
# Set fee payer
msg = TxMsg.set_fee_payer(signer.address, msg)
# Set blockhash lifetime
constraint = TxMsg::BlockhashLifetimeConstraint.new(
blockhash: '4vJ9...', last_valid_block_height: 123_456
)
msg = TxMsg.set_blockhash_lifetime(constraint, msg)
# Append / prepend instructions
msg = TxMsg.append_instructions(msg, [instruction])
msg = TxMsg.prepend_instructions(msg, [priority_fee_ix])
# Compute unit limit (SetComputeUnitLimit instruction from the Compute Budget program)
msg = TxMsg.(200_000, msg)
TxMsg.(msg) # => 200_000
# Loaded accounts data size limit
msg = TxMsg.(64_000, msg)
TxMsg.(msg) # => 64_000
# Durable nonce lifetime
nonce_constraint = TxMsg::DurableNonceLifetimeConstraint.new(
nonce: 'abc...',
nonce_account_address: nonce_addr
)
msg = TxMsg.set_durable_nonce_lifetime(nonce_constraint, msg)
Solana::Ruby::Kit::Transactions — @solana/transactions
Compile, sign, and inspect transactions.
Txns = Solana::Ruby::Kit::Transactions
# Compile a TransactionMessage into wire bytes + an empty signatures map.
# message_bytes are the bytes that each required signer must sign.
# A lifetime constraint (blockhash or durable nonce) is optional at compile
# time — omitting it writes 32 zero bytes into the blockhash field, which
# must be replaced before the transaction is valid for submission.
transaction = Txns.()
# Partially sign (one or more keys, not necessarily all signers)
tx = Txns.partially_sign_transaction([kp.signing_key], transaction)
# Fully sign (raises unless all signers have signed)
signed_tx = Txns.sign_transaction([kp.signing_key], transaction)
# Encode the fully signed transaction for submission via sendTransaction.
# Prepends compact-u16 signature count + 64-byte signatures to message bytes.
wire_bytes = Txns.wire_encode_transaction(signed_tx)
wire_base64 = Base64.strict_encode64(wire_bytes)
# Get the transaction signature (fee payer's signature, base58)
sig = Txns.get_signature_from_transaction(signed_tx)
# Check completeness and size
Txns.fully_signed_transaction?(tx) # => true / false
Txns.assert_fully_signed_transaction!(tx)
Txns.within_size_limit?(tx) # => true if wire size <= 1232 bytes
Txns.assert_within_size_limit!(tx)
# Sendable = fully signed AND within size limit
Txns.sendable_transaction?(signed_tx) # => true / false
Txns.assert_sendable_transaction!(signed_tx)
Solana::Ruby::Kit::Rpc — @solana/rpc
Synchronous JSON-RPC client backed by Net::HTTP.
rpc = Solana::Ruby::Kit::Rpc::Client.new(
Solana::Ruby::Kit::RpcTypes.devnet,
timeout: 10,
open_timeout: 5
)
rpc.get_slot # => Integer
rpc.get_block_height # => Integer
rpc.get_balance(address) # resp.value => lamports
rpc.get_latest_blockhash # resp.value.blockhash, .last_valid_block_height
rpc.get_account_info(address, encoding: 'base64')
rpc.get_multiple_accounts([addr1, addr2])
rpc.get_program_accounts(program_address)
rpc.get_signature_statuses([sig_str])
rpc.is_blockhash_valid(blockhash, commitment: :confirmed)
rpc.get_minimum_balance_for_rent_exemption(data_length)
rpc.get_transaction(signature, encoding: 'base64')
rpc.get_token_account_balance(token_account)
rpc.get_token_accounts_by_owner(owner, mint: mint_address)
rpc.get_epoch_info
rpc.get_epoch_schedule
rpc.get_block_time(slot)
rpc.get_inflation_reward([address])
rpc.get_signatures_for_address(address)
rpc.get_vote_accounts
rpc.simulate_transaction(encoded_tx)
rpc.send_transaction(encoded_tx)
rpc.request_airdrop(address, lamports) # devnet / testnet only
Errors:
rescue Solana::Ruby::Kit::Rpc::RpcError => e
puts e.code # JSON-RPC error code
puts e.message # JSON-RPC error message
rescue Solana::Ruby::Kit::Rpc::HttpTransportError => e
puts e.status_code # HTTP status code
Solana::Ruby::Kit::RpcTypes — @solana/rpc-types
Cluster URL helpers and typed wrappers.
RpcTypes = Solana::Ruby::Kit::RpcTypes
RpcTypes.mainnet # default mainnet URL
RpcTypes.mainnet('https://my-rpc.com') # custom mainnet URL
RpcTypes.devnet # devnet
RpcTypes.testnet # testnet
RpcTypes.cluster_url('http://localhost:8899') # custom / localnet
Solana::Ruby::Kit::Options — @solana/options
Rust-style Option<T> for Solana's on-chain option codec pattern.
Opts = Solana::Ruby::Kit::Options
some = Opts.some(42) # => Some(42)
none = Opts.none # => None
Opts.some?(some) # => true
Opts.none?(none) # => true
Opts.option?(some) # => true
Opts.unwrap_option(some) # => 42
Opts.unwrap_option(none) # => nil
Opts.unwrap_option(none, -> { 0 }) # => 0
Opts.wrap_nullable(nil) # => None
Opts.wrap_nullable(42) # => Some(42)
# Recursively unwrap nested structures
Opts.unwrap_option_recursively({ a: some, b: [none, some] })
# => { a: 42, b: [nil, 42] }
Solana::Ruby::Kit::Functional — @solana/functional
Functional pipeline composition.
Kit = Solana::Ruby::Kit
result = Kit::Functional.pipe(
Kit::TransactionMessages.(version: 0),
->(tx) { Kit::TransactionMessages.set_fee_payer(fee_payer, tx) },
->(tx) { Kit::TransactionMessages.set_blockhash_lifetime(constraint, tx) },
->(tx) { Kit::TransactionMessages.append_instructions(tx, [ix]) }
)
Solana::Ruby::Kit::Codecs — @solana/codecs
Binary encoder/decoder framework for Solana on-chain data.
Codecs = Solana::Ruby::Kit::Codecs
# Numbers
u8 = Codecs.u8
u16 = Codecs.u16_le # little-endian (default for Solana)
u32 = Codecs.u32_le
u64 = Codecs.u64_le
i8 = Codecs.i8
f32 = Codecs.f32_le
u16.encode(1000) # => "\xe8\x03"
u16.decode("\xe8\x03") # => 1000
# Strings
utf8 = Codecs.utf8
bytes = Codecs.bytes_codec
# Data structures
struct_codec = Codecs.struct_codec([
['amount', u64],
['mint', bytes]
])
Solana::Ruby::Kit::RpcSubscriptions — @solana/rpc-subscriptions
WebSocket-based subscription client.
ws = Solana::Ruby::Kit::RpcSubscriptions::Client.new(
'wss://api.devnet.solana.com'
)
sub = ws.account_subscribe(address, commitment: :confirmed)
sub. { |notification| puts notification }
sub.on_error { |err| puts err }
ws.account_unsubscribe(sub.id)
ws.close
Solana::Ruby::Kit::Sysvars — @solana/sysvars
Well-known sysvar addresses and decoded account data.
Sysvars = Solana::Ruby::Kit::Sysvars
Sysvars::Addresses::CLOCK_ADDRESS # => Address
Sysvars::Addresses::RENT_ADDRESS
Sysvars::Addresses::EPOCH_SCHEDULE_ADDRESS
# Fetch and decode via an RPC client
clock = Sysvars.fetch_sysvar_clock(rpc)
clock.slot # => Integer
clock.epoch # => Integer
clock. # => Integer
rent = Sysvars.fetch_sysvar_rent(rpc)
rent.lamports_per_byte_year # => Integer
rent.exemption_threshold # => Float
Solana::Ruby::Kit::Programs — @solana/programs
Program error helpers and well-known program interfaces.
Programs = Solana::Ruby::Kit::Programs
# Inspect custom program errors in transaction simulation results
Programs.program_error?(err) # => true / false
Programs.program_error?(err, expected_code: 1) # match a specific code
Programs.get_program_error_code(err) # => Integer or nil
Programs::StakeProgram
Create and delegate stake accounts.
Stake = Solana::Ruby::Kit::Programs::StakeProgram
# Well-known addresses
Stake::PROGRAM_ID # Stake11111111111111111111111111111111111111
Stake::STAKE_CONFIG_ID # StakeConfig11111111111111111111111111111111
Stake::STAKE_ACCOUNT_SPACE # => 200 (bytes required for a stake account)
# Build two instructions that allocate and initialise a new stake account.
# The caller appends both to a transaction message.
create_ixs = Stake.create_account_instructions(
from: fee_payer_address, # funding wallet (writable, signer)
stake_account: stake_keypair.address, # new stake account address (writable, signer)
authorized: owner_address, # becomes both staker and withdrawer
lamports: 2_282_880 # enough for rent + some stake
)
# Build one instruction that delegates an initialised stake account.
delegate_ix = Stake.delegate_instruction(
stake_account: stake_keypair.address,
vote_account: validator_vote_address,
authorized: owner_address # must sign the transaction
)
Programs::AssociatedTokenAccount
Create SPL token accounts at their canonical (Associated Token Account) address.
ATA = Solana::Ruby::Kit::Programs::AssociatedTokenAccount
# Well-known program IDs
ATA::PROGRAM_ID # ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL
ATA::TOKEN_PROGRAM_ID # TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
ATA::TOKEN_2022_PROGRAM_ID
ATA::SYSTEM_PROGRAM_ID
# Derive the ATA address (no RPC call required)
pda = ATA.get_associated_token_address(
wallet: owner_address,
mint: mint_address,
token_program_id: ATA::TOKEN_PROGRAM_ID # default; omit for SPL Token
)
pda.address # => Addresses::Address (the ATA)
pda.bump # => Integer
# Build the createAssociatedTokenAccount instruction
ix = ATA.create_instruction(
payer: fee_payer_address, # pays rent
wallet: owner_address, # will own the ATA
mint: mint_address,
token_program_id: ATA::TOKEN_PROGRAM_ID,
idempotent: true # use CreateIdempotent — safe to call if ATA exists
)
Solana::Ruby::Kit::OffchainMessages — @solana/signers, @solana/offchain-messages
Sign and verify off-chain messages (Phantom wallet standard).
OffChain = Solana::Ruby::Kit::OffchainMessages
msg = OffChain::Message.new(
version: 1, # 0 = legacy printable ASCII, 1 = extended UTF-8
domain: 'example.com',
message: 'Hello, Solana!'
)
encoded = OffChain.(msg)
decoded = OffChain.(encoded)
signature = OffChain.(signer, msg)
OffChain.(verify_key_bytes, signature, msg) # => true
Checking that a signer signed what you asked
A wallet returns the message bytes it signed alongside its signature. Verifying that signature only proves the signer produced it over those bytes — not that those bytes are the message you asked for. Compare the two before you verify the signature, so a signer that signed the wrong thing is reported as a content mismatch rather than as a confusing cryptographic failure.
Addresses = Solana::Ruby::Kit::Addresses
expected = OffChain::MessageV1.new(
content: 'Transfer 1 SOL to Alice',
required_signatories: [OffChain::Signatory.new(address: Addresses::Address.new(my_address))]
)
# Raises SolanaError if the content or the required signatories differ.
# Required signatories are compared ignoring order; content is compared exactly.
OffChain.(received, expected)
The error context reports content lengths in UTF-8 bytes rather than the content itself, so message text never reaches your logs or error reporting.
Note:
MessageV1mirrors the newer@solana/offchain-messagesv1 shape (UTF-8contentplusrequired_signatories) and is a distinct type fromMessageabove, which models the older@solana/signersdomain form. The v1 wire codec is not yet translated, soreceivedmust come from your own decoding for now.
Solana::Ruby::Kit::ResourceLimitEstimation — @solana/kit
Estimate and set compute unit limits and loaded accounts data size limits by simulating the transaction before sending.
RLE = Solana::Ruby::Kit::ResourceLimitEstimation
# 1. Fill provisory (0) limits as placeholders during message construction.
# This reserves space in the transaction for the limit instructions so the
# size estimate used by compile_transaction_message is accurate.
msg = RLE.(msg)
# 2. After construction, estimate actual resource usage via simulation and
# stamp the real values onto the message.
estimator = RLE.estimate_resource_limits_factory(rpc: rpc)
# estimate returns { compute_unit_limit: Integer, loaded_accounts_data_size_limit?: Integer }
estimate = estimator.call(msg)
# 3. Or combine steps 2 + set in one call.
setter = RLE.estimate_and_set_resource_limits_factory(estimator)
msg = setter.call(msg)
Solana::Ruby::Kit::TransactionConfirmation — @solana/transaction-confirmation
Poll for transaction confirmation with timeout.
Confirm = Solana::Ruby::Kit::TransactionConfirmation
Confirm.wait_for_confirmation(
rpc,
sig,
commitment: :confirmed,
timeout_secs: 60
)
Solana::Ruby::Kit::InstructionPlans — @solana/instruction-plans
Plan and execute instruction sequences that may span multiple transactions. The planner packs instructions into messages respecting the 1232-byte transaction size limit; the executor walks the resulting plan tree and sends each transaction.
require 'base64'
Plans = Solana::Ruby::Kit::InstructionPlans
Kit = Solana::Ruby::Kit
# ── 1. Build an instruction plan ─────────────────────────────────────────────
# Instructions are auto-wrapped in SingleInstructionPlan.
# Use parallel_instruction_plan for instructions that can run concurrently.
plan = Plans.sequential_instruction_plan([ix1, ix2, ix3])
# ── 2. Create a planner ───────────────────────────────────────────────────────
# create_transaction_message is called whenever a new (empty) transaction is
# needed. It must return a message with a fee payer and blockhash already set.
#
# max_instructions_per_transaction caps how many top-level instructions the
# planner packs into a single message. Must be a positive integer no greater
# than 64 (the transaction format's hard limit); defaults to 16, leaving
# headroom for inner (CPI) instructions the planner can't see ahead of time.
planner = Plans.create_transaction_planner(
create_transaction_message: -> {
Kit::Functional.pipe(
Kit::TransactionMessages.(version: :legacy),
->(tx) { Kit::TransactionMessages.set_fee_payer(signer.address, tx) },
->(tx) { Kit::TransactionMessages.set_blockhash_lifetime(constraint, tx) }
)
},
max_instructions_per_transaction: 12 # optional; omit for the default of 16
)
# plan! distributes instructions across as few transactions as possible.
# The cap can also be overridden per call:
transaction_plan = planner.call(plan, max_instructions_per_transaction: 8)
# ── 3. Create an executor and run it ─────────────────────────────────────────
# execute_transaction_message receives a fresh, mutable context Hash and each
# fully-packed TransactionMessage, and returns the context a successful result
# should carry. The two serve different outcomes: what you *store* on the context
# reaches a failed or canceled result, what you *return* reaches a successful one
# (merged over what was stored, the returned value winning).
# If it raises, the executor cancels all remaining messages and re-raises — and the
# context as it stood at that moment is preserved on the failed result.
executor = Plans.create_transaction_plan_executor(
execute_transaction_message: ->(context, ) {
transaction = Kit::Transactions.()
signed = Kit::Transactions.sign_transaction([signer.key_pair.signing_key], transaction)
context[:transaction] = signed # recorded now, so it survives a failure below
wire = Base64.strict_encode64(Kit::Transactions.wire_encode_transaction(signed))
rpc.send_transaction(wire)
{ transaction: signed }
}
)
result = executor.call(transaction_plan)
# result is a TransactionPlanResult tree mirroring the transaction_plan structure.
# Each leaf is a SingleTransactionPlanResult with status :successful, :failed, or :canceled.
# Every status carries a #context; a :successful one also exposes #transaction.
# A one-argument lambda returning { transaction:, context: } — the shape this method
# required before v7.1.0 — is still accepted and adapted onto the flow above.
# ── 4. Plan types ─────────────────────────────────────────────────────────────
Plans.single_instruction_plan(ix) # wrap one instruction
Plans.sequential_instruction_plan([ix1, ix2]) # ordered, divisible
Plans.non_divisible_sequential_instruction_plan([ix1, ix2]) # must be atomic
Plans.parallel_instruction_plan([ix1, ix2]) # any order / same tx
# MessagePacker plans for instructions of variable size (e.g. large data writes)
Plans.(
total_length: data.bytesize,
get_instruction: ->(offset, length) { build_write_ix(offset, data[offset, length]) }
)
Solana::Ruby::Kit::WalletStandard — @solana/wallet-standard
Server-side handling of the Wallet Standard
signTransaction interface. A browser wallet (Phantom, Backpack, Solflare, …) signs a
transaction and returns wire bytes; Rails decodes those bytes and verifies every Ed25519
signature without broadcasting — because Solana addresses are Ed25519 public keys, no
additional key lookup is required.
Full worked examples: Verify a Wallet-Signed Transaction in Rails and Build a Transaction for Browser Signing.
Wallet Standard feature constants
Use these when building frontend metadata or documenting required wallet capabilities:
WS = Solana::Ruby::Kit::WalletStandard
WS::SIGN_TRANSACTION # => 'solana:signTransaction'
WS::SIGN_AND_SEND_TRANSACTION # => 'solana:signAndSendTransaction'
WS::SIGN_MESSAGE # => 'solana:signMessage'
WS::CONNECT # => 'standard:connect'
Solana::Ruby::Kit::TransactionIntrospection — @solana/transaction-introspection
Decode a confirmed getTransaction response and walk its outer and inner (CPI)
instructions — useful for indexers, explorers, or auditing what a transaction
actually did on-chain.
TI = Solana::Ruby::Kit::TransactionIntrospection
rpc = Solana::Ruby::Kit.rpc_client
rpc_tx = rpc.get_transaction(
signature,
encoding: 'base64',
max_supported_transaction_version: 0
)
# Decodes 'base64', 'base58', or 'json' getTransaction responses.
# `transaction` is only present for 'base64'/'base58' (a re-encodable
# Transactions::Transaction); 'json' responses carry no wire bytes to round-trip.
decoded = TI.decode_transaction_from_rpc_response(rpc_tx)
# Outer instructions only, with account indices resolved to full AccountMetas.
outer = TI.(
decoded.,
decoded.loaded_addresses
)
# Every instruction — outer and inner (CPI) — in the order an explorer
# displays them, each tagged with a `trace` describing its position.
instructions = TI.walk_instructions(
compiled_message: decoded.,
loaded_addresses: decoded.loaded_addresses,
meta: rpc_tx['meta']
)
instructions.each do |traced|
ix = traced.instruction
puts "#{traced.trace[:kind]} ix -> #{ix.program_address}"
end
Note
'jsonParsed'responses are not supported — their instructions are pre-parsed by the server and lack raw bytes, so they can't be resolved here; fetch with'json'or'base64'instead. Wire-format ('base64'/'base58') decoding only handles legacy and v0 transactions — Ruby's transaction compiler doesn't produce v1 transactions yet either, so there is nothing to decompile for that version.
Error handling
All errors inherit from Solana::Ruby::Kit::SolanaError.
rescue Solana::Ruby::Kit::SolanaError => e
puts e.code # => :SOLANA_ERROR__ADDRESSES__INVALID_BASE58_ENCODED_ADDRESS
puts e.message # => human-readable description
puts e.context # => Hash of structured context values
end
Error codes match the TypeScript @solana/errors package constants.
Type checking with Sorbet
Every public method has a sig block. To enable static type checking in your project:
bundle exec srb init
bundle exec srb tc
Development
bundle install
bundle exec rspec # run tests
bundle exec srb tc # type-check
bundle exec tapioca gems # regenerate gem RBI files (first time or after gem updates)
License
MIT