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.
- Ruby >= 3.2
- libsodium (required by
rbnacl)
# macOS
brew install libsodium
# Debian / Ubuntu
apt-get install libsodium-dev# Gemfile
gem 'solana-ruby-kit'bundle installFull 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.
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:installThis 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
endOr configure via config/application.rb:
config.ruby_kit.rpc_url = ENV['SOLANA_RPC_URL']
config.ruby_kit.commitment = :finalizedGet a pre-configured client anywhere in your app:
rpc = Solana::Ruby::Kit.rpc_client| 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 |
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)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) # => SignatureBytesHigh-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(message_bytes)
# Sign a batch of messages for multiple signers
map = Signers.sign_message_bytes_with_signers([signer1, signer2], bytes)
# => { "addr1" => SignatureBytes, "addr2" => SignatureBytes }Immutable transaction message builder. Every method returns a new struct; originals are unmodified.
TxMsg = Solana::Ruby::Kit::TransactionMessages
msg = TxMsg.create_transaction_message(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.set_transaction_message_compute_unit_limit(200_000, msg)
TxMsg.get_transaction_message_compute_unit_limit(msg) # => 200_000
# Loaded accounts data size limit
msg = TxMsg.set_transaction_message_loaded_accounts_data_size_limit(64_000, msg)
TxMsg.get_transaction_message_loaded_accounts_data_size_limit(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)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.compile_transaction_message(message)
# 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)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 onlyErrors:
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 codeCluster 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 / localnetRust-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] }Functional pipeline composition.
Kit = Solana::Ruby::Kit
result = Kit::Functional.pipe(
Kit::TransactionMessages.create_transaction_message(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]) }
)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]
])WebSocket-based subscription client.
ws = Solana::Ruby::Kit::RpcSubscriptions::Client.new(
'wss://api.devnet.solana.com'
)
sub = ws.account_subscribe(address, commitment: :confirmed)
sub.on_message { |notification| puts notification }
sub.on_error { |err| puts err }
ws.account_unsubscribe(sub.id)
ws.closeWell-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.unix_timestamp # => Integer
rent = Sysvars.fetch_sysvar_rent(rpc)
rent.lamports_per_byte_year # => Integer
rent.exemption_threshold # => FloatProgram 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 nilCreate 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
)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
)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.encode_offchain_message(msg)
decoded = OffChain.decode_offchain_message(encoded)
signature = OffChain.sign_offchain_message(signer, msg)
OffChain.verify_offchain_message_signature(verify_key_bytes, signature, msg) # => trueA 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.assert_offchain_message_v1_equal(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.
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.fill_transaction_message_provisory_resource_limits(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)Poll for transaction confirmation with timeout.
Confirm = Solana::Ruby::Kit::TransactionConfirmation
Confirm.wait_for_confirmation(
rpc,
sig,
commitment: :confirmed,
timeout_secs: 60
)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.create_transaction_message(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, message) {
transaction = Kit::Transactions.compile_transaction_message(message)
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.get_linear_message_packer_instruction_plan(
total_length: data.bytesize,
get_instruction: ->(offset, length) { build_write_ix(offset, data[offset, length]) }
)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.
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'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.get_instructions_from_compiled_transaction_message(
decoded.compiled_message,
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.compiled_message,
loaded_addresses: decoded.loaded_addresses,
meta: rpc_tx['meta']
)
instructions.each do |traced|
ix = traced.instruction
puts "#{traced.trace[:kind]} ix -> #{ix.program_address}"
endNote
'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.
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
endError codes match the TypeScript @solana/errors package constants.
Every public method has a sig block. To enable static type checking in your project:
bundle exec srb init
bundle exec srb tcbundle 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)MIT