0.0
The project is in a healthy, maintained state
Ruby port of the Anza TypeScript SDK (@anza-xyz/kit)
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 2.2
~> 3.3
~> 13.0
~> 3.13
~> 0.6
~> 0.16
~> 3.26

Runtime

 Project Readme

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:

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)
# verify_signature is strict: a small-order public key or signature R
# component returns false (as the Solana runtime does), and passing anything
# other than an RbNaCl::VerifyKey raises KEYS__VERIFICATION_REQUIRES_EXTRACTABLE_PUBLIC_KEY.

# 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(message_bytes)

# Sign a batch of messages for multiple signers
map = Signers.sign_message_bytes_with_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.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)
# The limit is validated: it must be an integer in [0, MAX_COMPUTE_UNIT_LIMIT] (1,400,000).
# The runtime silently clamps a larger request rather than failing, so asking for more than
# the maximum would run on a budget you did not ask for — this raises instead.
msg = TxMsg.set_transaction_message_compute_unit_limit(200_000, msg)
TxMsg.get_transaction_message_compute_unit_limit(msg)  # => 200_000

# The check is also available on its own, alongside one for heap frame sizes — a whole
# number of KiB in [MIN_HEAP_SIZE, MAX_HEAP_SIZE] (32 KiB to 256 KiB).
TxMsg.assert_is_valid_compute_unit_limit(200_000)
TxMsg.assert_is_valid_heap_size(64 * 1024)

# 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)

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.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)

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.get_ag_genesis_cert                      # Alpenglow genesis cert, or nil
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
  puts e.retry_after  # raw Retry-After header, or nil

Retries

Transient failures are retried automatically, for every RPC method, with full-jitter exponential backoff. Defaults: max_attempts: 5, base_delay: 0.25, max_delay: 8.0 (seconds). The wait before retry n is rand * min(base_delay * 2**(n - 1), max_delay); a Retry-After header overrides it (still capped at max_delay).

What gets retried depends on whether the request could have reached the node:

Failure Retried for
Connect-phase: Errno::ENETUNREACH, EHOSTUNREACH, ECONNREFUSED, Net::OpenTimeout, SocketError every method — the bytes never left the machine
In-flight: Errno::ECONNRESET, EPIPE, EOFError, Net::ReadTimeout, Net::WriteTimeout, OpenSSL::SSL::SSLError idempotent methods only
HTTP 429 / 502 / 503 / 504 idempotent methods only
JSON-RPC error in a 200 response (RpcError) never — the node answered

Every attempt resends the same bytes with the same JSON-RPC id; nothing is re-signed and no blockhash is refetched. Resending an identical signed sendTransaction is safe because the cluster dedupes by signature. When retries run out, the original error is re-raised.

requestAirdrop is not idempotent: each accepted request is a new airdrop, so it is retried only on connect-phase errors. Add any of your own non-idempotent methods to non_idempotent:.

# Customize, with logging
policy = Solana::Ruby::Kit::Rpc::RetryPolicy.new(
  max_attempts: 8,
  max_delay:    15.0,
  on_retry:     ->(method, attempt, error, wait) {
    logger.warn("#{method} attempt #{attempt} failed (#{error.class}: #{error.message}); retrying in #{wait.round(2)}s")
  }
)
rpc = Solana::Ruby::Kit::Rpc::Client.new(Solana::Ruby::Kit::RpcTypes.devnet, retry_policy: policy)

# Disable
rpc = Solana::Ruby::Kit::Rpc::Client.new(url, retry_policy: Solana::Ruby::Kit::Rpc::RetryPolicy.none)

Rpc::Transport.new takes the same retry_policy:, plus sleeper: (a callable receiving the wait in seconds, Kernel.method(:sleep) by default) so tests need not actually wait. The WebSocket subscriptions client is not covered.

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.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]) }
)

Solana::Ruby::Kit::Codecs — @solana/codecs

Binary encoder/decoder framework for Solana on-chain data.

Codecs = Solana::Ruby::Kit::Codecs

# Numbers. Every multi-byte codec takes `endian:`, defaulting to :little
# (which is what Solana uses on-chain).
u8   = Codecs.u8_codec
u16  = Codecs.u16_codec
u32  = Codecs.u32_codec
u64  = Codecs.u64_codec
u128 = Codecs.u128_codec
u256 = Codecs.u256_codec              # 32 bytes
i8   = Codecs.i8_codec
i256 = Codecs.i256_codec              # 32 bytes, two's complement
f32  = Codecs.f32_codec
be   = Codecs.u16_codec(endian: :big)

u16.encode(1000)         # => "\xe8\x03"
u16.decode("\xe8\x03")   # => [1000, 2]   # [value, bytes consumed]

# Strings
utf8  = Codecs.utf8_codec
bytes = Codecs.bytes_codec(32)
# A borsh-style string: a u32 byte length, then that many UTF-8 bytes.
name  = Codecs.add_codec_size_prefix(utf8, u32)

# Data structures
struct_codec = Codecs.struct_codec([
  ['amount', u64],
  ['name',   name],
  ['mint',   bytes]
])
struct_codec.decode(struct_codec.encode({ amount: 5, name: 'gold', mint: "\x01".b * 32 }))
# => [{ amount: 5, name: "gold", mint: "\x01\x01..." }, 48]

UTF-8 options

By default the UTF-8 codec substitutes U+FFFD for malformed input, strips a leading byte order mark, and strips null characters - the last of which is what makes fixed-size padded strings read back cleanly, at the cost of being lossy for strings that legitimately contain nulls.

# Reject invalid UTF-8 instead of substituting the replacement character.
strict = Codecs.utf8_codec(fatal: true)
strict.encode("a\xC0\x80")   # raises SolanaError (invalid UTF-8 string)
strict.decode("\xFF".b)      # raises SolanaError (invalid UTF-8 bytes)

# Decode losslessly, keeping null characters and a leading byte order mark.
lossless = Codecs.utf8_codec(ignore_bom: true, remove_null_characters: false)

# The assertions are usable on their own.
Codecs.find_malformed_utf8_sequence_offset("a\xC0\x80".b)  # => 1 (overlong)
Codecs.assert_is_well_formed_utf8_bytes("\xE8\xAA\x9E".b)  # passes ("語")
Codecs.remove_null_characters("a\x00b")                        # => "ab"

Note that ignore_bom follows TextDecoder's confusing spelling: the default, false, strips the mark; true keeps it.

Collection sizes

array_codec, map_codec and set_codec take a size: that sets how the item count is stored:

Codecs.array_codec(u8).encode([1, 2, 3])                          # => "\x03\x00\x00\x00\x01\x02\x03" (u32 count)
Codecs.array_codec(u8, size: u8).encode([1, 2, 3])                # => "\x03\x01\x02\x03" (any number codec as the count)
Codecs.array_codec(u8, size: Codecs.compact_u16_codec).encode([1]) # => "\x01\x01" (Solana's shortU16)
Codecs.array_codec(u8, size: 3).encode([1, 2, 3])                 # => "\x01\x02\x03" (fixed count, no prefix)
Codecs.array_codec(u8, size: 3).encode([1, 2])                    # raises SolanaError (wrong number of items)
Codecs.array_codec(u8, size: :remainder).encode([1, 2, 3])        # => "\x01\x02\x03" (no prefix...)
Codecs.array_codec(u8, size: :remainder).decode("\x01\x02\x03".b)  # => [[1, 2, 3], 3] (...reads to the end)

A :remainder array must be the last thing in the buffer. Upstream spells it 'remainder'; Ruby uses the Symbol. The fifth option, a Codecs::SentinelSize, is described below.

Requiring a size prefix

A prefixed collection decodes an exhausted buffer to an empty collection rather than failing, so a program can append a collection to an existing account layout and still read accounts written before the change. Formats that cannot accept that leniency - borsh requires the prefix to be present - can opt into failing:

Codecs.array_codec(u8).decode(''.b)                              # => [[], 0]
Codecs.array_codec(u8, require_size_prefix: true).decode(''.b)   # raises SolanaError

map_codec and set_codec take the same option, and it applies to a custom count codec too. It has no effect on fixed-count, :remainder or sentinel-terminated collections, none of which carries a prefix.

Sentinel-terminated collections

Instead of a length prefix, size: can be a Codecs::SentinelSize: the collection ends where the bytes at the next item position match the sentinel. It is compared at item boundaries only, so its bytes may appear inside an item.

zero = Codecs::SentinelSize.new(sentinel: "\x00".b)
list = Codecs.array_codec(u8, size: zero)
list.encode([1, 2, 3])            # => "\x01\x02\x03\x00"
list.decode("\x01\x02\x03\x00".b)  # => [[1, 2, 3], 4]
list.decode("\x01\x02".b)          # raises SolanaError (sentinel missing)

# :optional still writes the sentinel but tolerates its absence on decode;
# :omitted never writes it. Both stop at the end of the bytes.
lenient = Codecs.array_codec(u8, size: Codecs::SentinelSize.new(sentinel: "\x00".b, strategy: :optional))
lenient.decode("\x01\x02".b)       # => [[1, 2], 2]

map_codec and set_codec accept the same size:. The codec does not check two invariants, so it is up to you to make sure they hold. First, no item may begin with the sentinel's bytes, or decoding stops early at that item. Second, under :optional / :omitted the sentinel must be no wider than the smallest item, or a short trailing item is silently dropped.

An item codec with a fixed size of zero (e.g. struct_codec([])) can never reach a sentinel or the end of the bytes, so pairing one with a SentinelSize or :remainder raises CODECS__UNEXPECTED_ZERO_FIXED_SIZE_ITEM_FOR_ARRAY_LIKE_SIZE_STRATEGY when the codec is built. Explicit counts and prefixes still allow it.

Tap combinators

Wrap a codec in one that observes a value - or the bytes - without changing it, for validation guards or logging. A tap that raises aborts the operation.

guarded = Codecs.tap_encoder(u8.encoder) do |value|
  raise ArgumentError, 'Value must not exceed 100' if value > 100
end
guarded.encode(42)   # => "\x2a"
guarded.encode(200)  # raises ArgumentError

# Both sides at once; decode_tap is optional.
Codecs.tap_codec(u8, encode_tap: ->(v) { log(v) }, decode_tap: ->(v) { log(v) })

# Observe the bytes instead of the value.
Codecs.tap_decoder_bytes(u8.decoder) do |bytes, offset|
  raise ArgumentError, 'Expected a 0 or a 1' if bytes.bytes[offset] > 1
end

Upstream's byte taps take (bytes, pre_offset, post_offset) because encoders there write into a shared buffer. A Ruby Encoder returns a standalone byte String, so the encode-side taps receive exactly the bytes that were written.

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.on_message { |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.unix_timestamp    # => 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.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)  # => 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.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: MessageV1 mirrors the newer @solana/offchain-messages v1 shape (UTF-8 content plus required_signatories) and is a distinct type from Message above, which models the older @solana/signers domain form. The v1 wire codec is not yet translated, so received must 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.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)

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.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.

# ── 3b. Executing every leaf concurrently ────────────────────────────────────
# For work that has no execution dependencies — signing or serializing, say —
# create_transaction_plan_executor_with_concurrent_leaves takes the same callback
# contract but starts every leaf immediately, each on its own thread. It preserves
# the plan's nesting, order and divisibility in the result, supports non-divisible
# sequential plans, and cancels nothing: a leaf that raises does not stop the others,
# so every leaf runs to completion before the failure is reported. Because the leaves
# run at the same time, the callback must be safe to call concurrently with itself.
concurrent = Plans.create_transaction_plan_executor_with_concurrent_leaves(
  execute_transaction_message: ->(_context, message) {
    { transaction: Kit::Transactions.compile_transaction_message(message) }
  }
)
result = concurrent.call(transaction_plan)

# ── 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]) }
)

# ── 5. Custom message packers ────────────────────────────────────────────────
# A custom packer enforces the same limits the built-in ones do, and can refuse a
# message for a reason of its own with MESSAGE_REJECTED_BY_PACKER. The planner
# treats all three errors as "try another message"
# (Plans.message_packer_error_that_requires_new_candidate?).
one_per_message = Plans::MessagePackerInstructionPlan.new(
  get_message_packer: -> {
    remaining = [ix1, ix2]
    Plans::MessagePacker.new(
      done_proc: -> { remaining.empty? },
      pack_proc: ->(message, max_instructions) {
        max = Plans.resolve_max_instructions_per_transaction(max_instructions)
        Plans.assert_max_instructions_per_transaction(message.instructions.length + 1, max)
        unless message.instructions.empty?
          raise Kit::SolanaError.new(
            Kit::SolanaError::INSTRUCTION_PLANS__MESSAGE_REJECTED_BY_PACKER,
            { reason: 'these instructions must each have a transaction to themselves' }
          )
        end
        next_message = Kit::TransactionMessages.append_instructions(message, [remaining.first])
        Plans.assert_message_can_accommodate_size(
          current_size: Kit::Transactions.get_transaction_message_size(message),
          next_size:    Kit::Transactions.get_transaction_message_size(next_message),
          size_limit:   Kit::Transactions::TRANSACTION_SIZE_LIMIT
        )
        remaining.shift
        next_message
      }
    )
  }
)

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.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}"
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