Skip to content
Merged
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
17 changes: 17 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

---

## 0.1.8 - 2026-07-19

### Added

1. Added address lookup table (v0) support to `Solace::TransactionComposer`: `#add_address_lookup_table(account:, addresses:)` registers a table and opts the transaction into the v0 format, and `#compose_transaction` loads every eligible account (a non-signer that is not the fee payer and not an invoked program id) through the tables instead of a static account slot. `#merge` folds another composer's tables in, deduped by account.
2. Added `Solace::Accounts` for on-chain account models, with `Solace::Accounts::AddressLookupTable` — a table's address and stored addresses, with `.deserialize` for reading on-chain table state and `#reference` for building the v0 message reference it contributes.
3. Added Address Lookup Table program composers and instruction builders: `Solace::Composers::AddressLookupTableProgram{Create,Extend}Composer` and `Solace::Instructions::AddressLookupTableProgram::{CreateLookupTable,ExtendLookupTable}Instruction`, so tables can be created and extended on chain.
4. Added `Solace::Connection#get_slot`.

### Changed

1. `Solace::Utils::AccountContext#compile` accepts `loaded_accounts:`, rebuilding index resolution against the combined v0 account space (static keys followed by loaded addresses) while keeping only the static keys in the message. With no loaded accounts the legacy compilation is unchanged.

### Fixed

---

## 0.1.7 - 2026-07-03

### Added
Expand Down
6 changes: 6 additions & 0 deletions gem/.rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ Metrics/ParameterLists:
Metrics/MethodLength:
Max: 15

# Composers and utilities collect many small, cohesive methods (e.g. the
# TransactionComposer's account/lookup-table orchestration); 100 is a touch
# tight for them while still discouraging genuinely large classes.
Metrics/ClassLength:
Max: 125

# Utility modules (e.g. Solace::Utils::Codecs) legitimately collect many small
# related methods; the default of 100 is too low for them.
Metrics/ModuleLength:
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
4 changes: 4 additions & 0 deletions gem/lib/solace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ module Solace; end
require_relative 'solace/message'
require_relative 'solace/instruction'
require_relative 'solace/address_lookup_table'

# Accounts (on-chain account models)
require_relative 'solace/accounts/address_lookup_table'

require_relative 'solace/transaction_composer'

# Base Classes (Abstract classes)
Expand Down
134 changes: 134 additions & 0 deletions gem/lib/solace/accounts/address_lookup_table.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# frozen_string_literal: true

module Solace
# On-chain account models — the account state living at an address, as opposed
# to the wire-level structures that reference it. Conventions for these types
# (fetching, deserializing, deriving) are expected to grow here.
module Accounts
# Models an on-chain Address Lookup Table account: its address, the metadata
# the program stores (authority, activation slots), and the full, ordered
# list of addresses it holds.
#
# This is distinct from {Solace::AddressLookupTable}, which is the *reference*
# a v0 message carries (a table account plus the writable/readonly index
# positions into it). A composer decides which of this account's addresses
# load — a v0 concern that needs the account context — and this object owns
# the table-local part: turning the addresses it contributes into that
# message reference.
#
# @example Register a table on a composer
# table = Solace::Accounts::AddressLookupTable.new(account: address, addresses: on_chain_addresses)
# table.reference(loaded_writable, loaded_readonly) # => Solace::AddressLookupTable or nil
#
# @example Read a table's on-chain state
# data = Base64.decode64(connection.get_account_info(address)['data'][0])
# table = Solace::Accounts::AddressLookupTable.deserialize(StringIO.new(data))
# table.addresses # => the stored addresses
#
# @see Solace::AddressLookupTable
# @see Solace::TransactionComposer
# @since 0.1.8
class AddressLookupTable
# Byte offset at which the stored addresses begin — the program reserves a
# fixed-size metadata region ahead of them regardless of its contents.
META_SIZE = 56

# @!attribute [r] account
# @return [String, nil] The lookup table's on-chain address
attr_reader :account

# @!attribute [r] addresses
# @return [Array<String>] The full, ordered list of addresses stored in the table
attr_reader :addresses

# @!attribute [r] authority
# @return [String, nil] The authority allowed to extend/close the table
attr_reader :authority

# @!attribute [r] deactivation_slot
# @return [Integer, nil] The slot the table was deactivated in (max while active)
attr_reader :deactivation_slot

# @!attribute [r] last_extended_slot
# @return [Integer, nil] The slot the table was last extended in
attr_reader :last_extended_slot

# Deserialize an on-chain lookup table account
#
# The BufferLayout is:
# - [State type (4 bytes, u32 LE)]
# - [Deactivation slot (8 bytes, u64 LE)]
# - [Last extended slot (8 bytes, u64 LE)]
# - [Last extended start index (1 byte)]
# - [Authority (Borsh Option<Pubkey>)]
# - [Padding, up to {META_SIZE}]
# - [Addresses (32 bytes each, to end of data)]
#
# @param io [IO, StringIO] The account data to read from
# @return [AddressLookupTable] The parsed table
def self.deserialize(io)
Utils::Codecs.decode_le_u32(io) # state type (1 = lookup table); positional
deactivation_slot = Utils::Codecs.decode_le_u64(io)
last_extended_slot = Utils::Codecs.decode_le_u64(io)

Utils::Codecs.decode_u8(io) # last extended start index; positional
authority = Utils::Codecs.decode_option_pubkey(io)

io.seek(META_SIZE) # addresses begin after the fixed-size metadata region

addresses = []
addresses << Utils::Codecs.decode_pubkey(io) until io.eof?

new(
deactivation_slot: deactivation_slot,
last_extended_slot: last_extended_slot,
authority: authority,
addresses: addresses
)
end

# Initialize a lookup table account
#
# @param account [#to_s, PublicKey, nil] The lookup table's on-chain address
# @param addresses [Array<#to_s>, nil] The full, ordered address list; may be
# omitted (the composer then loads nothing through this table)
# @param authority [String, nil] The table authority
# @param deactivation_slot [Integer, nil] The deactivation slot
# @param last_extended_slot [Integer, nil] The last extended slot
def initialize(account: nil, addresses: nil, authority: nil, deactivation_slot: nil, last_extended_slot: nil)
@account = account&.to_s
@addresses = Array(addresses).map(&:to_s)
@authority = authority
@deactivation_slot = deactivation_slot
@last_extended_slot = last_extended_slot
end

# Build the v0 message reference for the addresses this table contributes
#
# @param writable [Array<String>] Loaded writable pubkeys drawn from this table
# @param readonly [Array<String>] Loaded readonly pubkeys drawn from this table
# @return [Solace::AddressLookupTable, nil] The reference, or nil if it loads nothing
def reference(writable, readonly)
writable_indexes = positions_of(writable)
readonly_indexes = positions_of(readonly)
return if writable_indexes.empty? && readonly_indexes.empty?

Solace::AddressLookupTable.new.tap do |reference|
reference.account = account
reference.writable_indexes = writable_indexes
reference.readonly_indexes = readonly_indexes
end
end

private

# Positions of the given pubkeys within this table's address list
#
# @param pubkeys [Array<String>] The pubkeys to locate
# @return [Array<Integer>] Their positions in {#addresses}
def positions_of(pubkeys)
pubkeys.filter_map { |pubkey| addresses.index(pubkey) }
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# frozen_string_literal: true

module Solace
module Composers
# Composer for creating an address lookup table.
#
# Resolves and orders the accounts for a `CreateLookupTable` instruction and
# delegates construction to
# `Instructions::AddressLookupTableProgram::CreateLookupTableInstruction`.
#
# The table address is a program-derived address of `[authority, recent_slot]`;
# derive it (and its bump) with {Solace::Utils::PDA} and pass both in.
#
# Required accounts:
# - **Table**: the uninitialized table account (writable, non-signer)
# - **Authority**: controls the table (readonly, signer)
# - **Payer**: funds the table's rent (writable, signer)
# - **System program** (readonly, non-signer)
#
# @example
# composer = AddressLookupTableProgramCreateComposer.new(
# table: table_address,
# authority: authority,
# payer: payer,
# recent_slot: recent_slot,
# bump: bump
# )
#
# @see Instructions::AddressLookupTableProgram::CreateLookupTableInstruction
# @since 0.1.8
class AddressLookupTableProgramCreateComposer < Base
# @return [String] The table's on-chain address
def table
params[:table].to_s
end

# @return [String] The table authority
def authority
params[:authority].to_s
end

# @return [String] The rent payer
def payer
params[:payer].to_s
end

# @return [String] The system program id
def system_program
Solace::Constants::SYSTEM_PROGRAM_ID.to_s
end

# @return [String] The address lookup table program id
def lookup_table_program
Solace::Constants::ADDRESS_LOOKUP_TABLE_PROGRAM_ID.to_s
end

# @return [Integer] The slot used to derive the table address
def recent_slot
params[:recent_slot]
end

# @return [Integer] The bump seed for the table's program-derived address
def bump
params[:bump]
end

# Setup accounts required for the create lookup table instruction
#
# @return [void]
def setup_accounts
account_context.add_writable_nonsigner(table)
account_context.add_readonly_signer(authority)
account_context.add_writable_signer(payer)
account_context.add_readonly_nonsigner(system_program)
account_context.add_readonly_nonsigner(lookup_table_program)
end

# Build instruction with resolved account indices
#
# @param account_context [Utils::AccountContext] The account context
# @return [Solace::Instruction]
def build_instruction(account_context)
Solace::Instructions::AddressLookupTableProgram::CreateLookupTableInstruction.build(
recent_slot: recent_slot,
bump: bump,
program_index: account_context.index_of(lookup_table_program),
table_index: account_context.index_of(table),
authority_index: account_context.index_of(authority),
payer_index: account_context.index_of(payer),
system_program_index: account_context.index_of(system_program)
)
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# frozen_string_literal: true

module Solace
module Composers
# Composer for extending an address lookup table with new addresses.
#
# Resolves and orders the accounts for an `ExtendLookupTable` instruction and
# delegates construction to
# `Instructions::AddressLookupTableProgram::ExtendLookupTableInstruction`.
#
# Addresses appended here become usable one slot after the extend lands.
#
# Required accounts:
# - **Table**: the table account being extended (writable, non-signer)
# - **Authority**: controls the table (readonly, signer)
# - **Payer**: funds any additional rent (writable, signer)
# - **System program** (readonly, non-signer)
#
# @example
# composer = AddressLookupTableProgramExtendComposer.new(
# table: table_address,
# authority: authority,
# payer: payer,
# addresses: [recipient1, recipient2]
# )
#
# @see Instructions::AddressLookupTableProgram::ExtendLookupTableInstruction
# @since 0.1.8
class AddressLookupTableProgramExtendComposer < Base
# @return [String] The table's on-chain address
def table
params[:table].to_s
end

# @return [String] The table authority
def authority
params[:authority].to_s
end

# @return [String] The rent payer
def payer
params[:payer].to_s
end

# @return [String] The system program id
def system_program
Solace::Constants::SYSTEM_PROGRAM_ID.to_s
end

# @return [String] The address lookup table program id
def lookup_table_program
Solace::Constants::ADDRESS_LOOKUP_TABLE_PROGRAM_ID.to_s
end

# @return [Array<String>] The addresses to append to the table
def addresses
params[:addresses].map(&:to_s)
end

# Setup accounts required for the extend lookup table instruction
#
# @return [void]
def setup_accounts
account_context.add_writable_nonsigner(table)
account_context.add_readonly_signer(authority)
account_context.add_writable_signer(payer)
account_context.add_readonly_nonsigner(system_program)
account_context.add_readonly_nonsigner(lookup_table_program)
end

# Build instruction with resolved account indices
#
# @param account_context [Utils::AccountContext] The account context
# @return [Solace::Instruction]
def build_instruction(account_context)
Solace::Instructions::AddressLookupTableProgram::ExtendLookupTableInstruction.build(
addresses: addresses,
program_index: account_context.index_of(lookup_table_program),
table_index: account_context.index_of(table),
authority_index: account_context.index_of(authority),
payer_index: account_context.index_of(payer),
system_program_index: account_context.index_of(system_program)
)
end
end
end
end
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
Loading