Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

---

## 0.1.8 - 2026-07-14

### Added

1. Added address lookup table support to `Solace::TransactionComposer`: `#add_lookup_table(account:, addresses:)` registers a table, and `#compose_transaction` emits a v0 message loading every eligible account (non-signer, not the fee payer, not an instruction program id) through the tables
2. Added `Solace::Utils::LookupTableContext` — the lookup-table counterpart to `AccountContext`, managing registered tables, v0 loading rules, and the message's table references
3. Added `Solace::Utils::AccountContext#relocate_loaded_accounts` for rebuilding a compiled account list into the combined v0 account space `[static..., loaded writable..., loaded readonly...]`
4. Added `Solace::Connection#get_slot`

### Changed

### Fixed

---

## 0.1.7 - 2026-07-03

### Added
Expand Down
2 changes: 1 addition & 1 deletion gem/Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
solace (0.1.7)
solace (0.1.8)
base58 (~> 0.2)
ffi (~> 1.15)
rbnacl (~> 7.0)
Expand Down
1 change: 1 addition & 0 deletions gem/lib/solace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ module Solace; end
require_relative 'solace/utils/codecs'
require_relative 'solace/utils/pda'
require_relative 'solace/utils/account_context'
require_relative 'solace/utils/lookup_table_context'
require_relative 'solace/utils/curve25519_dalek'
require_relative 'solace/concerns/binary_serializable'

Expand Down
10 changes: 10 additions & 0 deletions gem/lib/solace/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,16 @@ def get_block_height(commitment: default_options[:commitment])
@rpc_client.rpc_request('getBlockHeight', [{ commitment: commitment }])['result']
end

# Get the current slot from the Solana node
#
# @param commitment [String] The commitment level for the request
# @return [Integer] The current slot
#
# @since 0.1.8
def get_slot(commitment: default_options[:commitment])
@rpc_client.rpc_request('getSlot', [{ commitment: commitment }])['result']
end

# Get the minimum required lamports for rent exemption
#
# @param space [Integer] Number of bytes to allocate for the account
Expand Down
88 changes: 84 additions & 4 deletions gem/lib/solace/transaction_composer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,18 @@ class TransactionComposer
# The instruction composers
attr_reader :instruction_composers

# @!attribute lookup_tables
# The lookup table context
attr_reader :lookup_tables

# Initialize the composer
#
# @param connection [Solace::Connection] The connection to the Solana cluster
def initialize(connection:)
@connection = connection
@instruction_composers = []
@context = Utils::AccountContext.new
@lookup_tables = Utils::LookupTableContext.new
end

# Add an instruction composer to the transaction
Expand Down Expand Up @@ -154,23 +159,98 @@ def set_fee_payer(pubkey)
self
end

# Make an address lookup table available to the transaction
#
# When at least one lookup table is added, `compose_transaction` emits a v0
# message: every compiled account that can be loaded through a table (a
# non-signer that is not the fee payer and not a program id of any
# instruction) is referenced by table index instead of occupying a static
# account slot.
#
# @example
# composer.add_lookup_table(
# account: table_address,
# addresses: on_chain_table_addresses
# )
#
# @param account [#to_s, PublicKey] The lookup table's on-chain address
# @param addresses [Array<#to_s>] The full, ordered list of addresses stored in the table
# @return [TransactionComposer] Self for chaining
#
# @since 0.1.8
def add_lookup_table(account:, addresses:)
lookup_tables.add_table(account: account, addresses: addresses)
self
end

# Compose the final transaction
#
# Emits a legacy message unless lookup tables were added, in which case a
# v0 message is emitted instead.
#
# @return [Transaction] The composed transaction (unsigned)
def compose_transaction
context.compile

message = Solace::Message.new(
return Solace::Transaction.new(message: legacy_message) if lookup_tables.empty?

Solace::Transaction.new(message: versioned_message)
end

private

# Build the legacy message
#
# @return [Solace::Message] The legacy message
def legacy_message
Solace::Message.new(
header: context.header,
accounts: context.accounts,
instructions: build_instructions,
recent_blockhash: connection.get_latest_blockhash[0]
recent_blockhash: recent_blockhash
)
end

# Build the v0 message
#
# Loaded accounts leave the static account list and are referenced through
# the lookup tables. Instructions are rebuilt after the relocation so their
# indices resolve against the combined v0 account space
# [static..., loaded writable..., loaded readonly...] — the order in which
# the Solana runtime flattens loaded addresses before execution.
#
# @return [Solace::Message] The v0 message
def versioned_message
writable, readonly = lookup_tables.select_loaded_accounts(context, program_ids)
static_accounts = context.relocate_loaded_accounts(writable.keys, readonly.keys)

Solace::Message.new(
version: 0,
header: context.header,
accounts: static_accounts,
instructions: build_instructions,
recent_blockhash: recent_blockhash,
address_lookup_tables: lookup_tables.address_lookup_tables_for(writable, readonly)
)
end

Solace::Transaction.new(message: message)
# Fetch a recent blockhash from the connection
#
# @return [String] The recent blockhash (base58)
def recent_blockhash
connection.get_latest_blockhash[0]
end

private
# Program ids referenced by the built instructions
#
# Resolved by building the instructions against the compiled static order,
# so any program an instruction actually invokes is covered — including
# composers that emit multiple instructions for different programs.
#
# @return [Array<String>] The program id pubkeys
def program_ids
build_instructions.map { |instruction| context.accounts[instruction.program_index] }.uniq
end

# Build all instructions with resolved indices
#
Expand Down
33 changes: 27 additions & 6 deletions gem/lib/solace/utils/account_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,7 @@ def readonly_nonsigner?(pubkey)
# @param other_context [AccountContext] The other context to merge from
def merge_from(other_context)
other_context.pubkey_account_map.each do |pubkey, data|
signer, writable, fee_payer = data.values_at(:signer, :writable, :fee_payer)
merge_account(pubkey, signer: signer, writable: writable, fee_payer: fee_payer)
merge_account(pubkey, **data.slice(:signer, :writable, :fee_payer))
end
end

Expand All @@ -180,6 +179,30 @@ def compile
self
end

# Relocate lookup-loaded accounts to the end of the account space
#
# Rebuilds the compiled account list as [static..., writable..., readonly...]
# so index resolution matches the combined v0 account space — the Solana
# runtime appends loaded writable then loaded readonly addresses after the
# static keys before execution. Loaded readonly accounts leave the header's
# readonly unsigned count since they no longer occupy a static slot.
#
# @note Must be called after {#compile}.
#
# @param writable [Array<String>] The loaded writable account pubkeys
# @param readonly [Array<String>] The loaded readonly account pubkeys
# @return [Array<String>] The static accounts remaining in the message
#
# @since 0.1.8
def relocate_loaded_accounts(writable, readonly)
static = accounts - (writable + readonly)

self.accounts = static + writable + readonly
header[2] -= readonly.size

static
end

# Index of a pubkey in the accounts array
#
# @param pubkey_str [String] The public key of the account
Expand Down Expand Up @@ -241,10 +264,8 @@ def order_accounts
def calculate_header
@pubkey_account_map.keys.each_with_object([0, 0, 0]) do |pubkey, acc|
acc[0] += 1 if signer?(pubkey)

if readonly_signer?(pubkey) then acc[1] += 1
elsif readonly_nonsigner?(pubkey) then acc[2] += 1
end
acc[1] += 1 if readonly_signer?(pubkey)
acc[2] += 1 if readonly_nonsigner?(pubkey)
end
end
end
Expand Down
144 changes: 144 additions & 0 deletions gem/lib/solace/utils/lookup_table_context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# frozen_string_literal: true

module Solace
module Utils
# Utility for managing address lookup tables for composers
#
# This utility holds the lookup tables made available to a transaction composer and
# encapsulates the v0 loading rules: which compiled accounts may be loaded through a
# table instead of occupying a static account slot, and which table references the
# final message must carry. Concerns like deduplication across tables and preserving
# the runtime's loaded-address order are handled by this utility.
#
# @example Usage
# # Create a new lookup table context
# lookup_tables = Solace::Utils::LookupTableContext.new
#
# # Register a table (address + its full on-chain address list)
# lookup_tables.add_table(account: table_address, addresses: addresses)
#
# # Split the loadable accounts of a compiled account context
# writable, readonly = lookup_tables.select_loaded_accounts(account_context, program_ids)
#
# # Build the table references for a v0 message
# lookup_tables.address_lookup_tables_for(writable, readonly)
#
# @see Solace::TransactionComposer
# @see Solace::Utils::AccountContext
# @since 0.1.8
class LookupTableContext
# @!attribute tables
# The registered lookup tables
#
# @return [Array<Hash>] The tables as { account:, addresses: } hashes
attr_reader :tables

# Initialize the lookup table context
def initialize
@tables = []
end

# Register a lookup table
#
# @param account [#to_s, PublicKey] The lookup table's on-chain address
# @param addresses [Array<#to_s>] The full, ordered list of addresses stored in the table
# @return [LookupTableContext] Self for chaining
def add_table(account:, addresses:)
tables << { account: account.to_s, addresses: addresses.map(&:to_s) }
self
end

# Predicate to check if any lookup tables are registered
#
# @return [Boolean] Whether the context has no tables
def empty?
tables.empty?
end

# Select the compiled accounts to load through the lookup tables
#
# An account is loadable when it is referenced by the compiled account context,
# can never sign (loaded accounts must not be signers or the fee payer), and is
# not one of the given program ids (top-level program ids must stay static —
# runtime rules). First-wins over tables and positions, so iteration order
# matches the runtime's loaded-address order (per table, ascending position)
# within each segment.
#
# @param account_context [AccountContext] The compiled account context
# @param program_ids [Array<String>] Program ids referenced by the instructions
# @return [Array(Hash, Hash)] The writable and readonly segments as
# pubkey => { table_index:, address_index: } hashes
def select_loaded_accounts(account_context, program_ids)
chosen = choose_accounts(account_context, program_ids)

chosen
.partition { |pubkey, _ref| account_context.writable?(pubkey) }
.map(&:to_h)
end

# Build the lookup table references carried by a v0 message
#
# Tables that contribute no loaded accounts are omitted.
#
# @param writable [Hash{String => Hash}] Loaded writable pubkey => { table_index:, address_index: }
# @param readonly [Hash{String => Hash}] Loaded readonly pubkey => { table_index:, address_index: }
# @return [Array<Solace::AddressLookupTable>] The lookup table references
def address_lookup_tables_for(writable, readonly)
tables.each_with_index.filter_map do |table, table_index|
writable_indexes = indexes_for(writable, table_index)
readonly_indexes = indexes_for(readonly, table_index)
next if writable_indexes.empty? && readonly_indexes.empty?

Solace::AddressLookupTable.new.tap do |alt|
alt.account = table[:account]
alt.writable_indexes = writable_indexes
alt.readonly_indexes = readonly_indexes
end
end
end

private

# Choose the accounts to load, keyed by pubkey with their table references
#
# @param account_context [AccountContext] The compiled account context
# @param program_ids [Array<String>] Program ids referenced by the instructions
# @return [Hash{String => Hash}] pubkey => { table_index:, address_index: }
def choose_accounts(account_context, program_ids)
chosen = {}

tables.each_with_index do |table, table_index|
table[:addresses].each_with_index do |pubkey, address_index|
next if chosen.key?(pubkey) || !loadable?(pubkey, account_context, program_ids)

chosen[pubkey] = { table_index: table_index, address_index: address_index }
end
end

chosen
end

# Predicate to check if an account may be loaded through a lookup table
#
# @param pubkey [String] The pubkey of the account
# @param account_context [AccountContext] The compiled account context
# @param program_ids [Array<String>] Program ids referenced by the instructions
# @return [Boolean] Whether the account may be loaded
def loadable?(pubkey, account_context, program_ids)
account_context.pubkey_account_map.key?(pubkey) &&
!account_context.signer?(pubkey) &&
!account_context.fee_payer?(pubkey) &&
!program_ids.include?(pubkey)
end

# Positions contributed by one lookup table for a segment of loaded accounts
#
# @param segment [Hash{String => Hash}] Loaded pubkey => { table_index:, address_index: }
# @param table_index [Integer] The lookup table's position in {#tables}
# @return [Array<Integer>] The address positions within the table
def indexes_for(segment, table_index)
segment.filter_map { |_pubkey, ref| ref[:address_index] if ref[:table_index] == table_index }
end
end
end
end
2 changes: 1 addition & 1 deletion gem/lib/solace/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

module Solace
# Latest version of the Solace gem.
VERSION = '0.1.7'
VERSION = '0.1.8'
end
Loading