From 5cdc0381fabeb1e3eccbf7907ddd895da45c9298 Mon Sep 17 00:00:00 2001 From: Sorin Guga Date: Wed, 15 Jul 2026 17:33:34 +0300 Subject: [PATCH 1/4] Add address lookup table helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Utils::LookupTableContext: the lookup-table counterpart to AccountContext — table registry, v0 loading rules (never signers, the fee payer, or invoked program ids; first table wins; runtime loaded-address order), and the AddressLookupTable references a v0 message carries - AccountContext#relocate_loaded_accounts: rebuilds a compiled account list into the combined v0 account space [static, loaded writable, loaded readonly] and adjusts the readonly-unsigned header count (calculate_header and merge_from compacted to stay within the class-length limit) - Connection#get_slot Co-Authored-By: Claude Fable 5 --- gem/lib/solace.rb | 1 + gem/lib/solace/connection.rb | 10 ++ gem/lib/solace/utils/account_context.rb | 33 +++- gem/lib/solace/utils/lookup_table_context.rb | 144 ++++++++++++++++++ gem/test/solace/connection_test.rb | 17 +++ gem/test/solace/utils/account_context_test.rb | 46 ++++++ .../solace/utils/lookup_table_context_test.rb | 131 ++++++++++++++++ 7 files changed, 376 insertions(+), 6 deletions(-) create mode 100644 gem/lib/solace/utils/lookup_table_context.rb create mode 100644 gem/test/solace/utils/lookup_table_context_test.rb diff --git a/gem/lib/solace.rb b/gem/lib/solace.rb index 50fbd46..c270b71 100644 --- a/gem/lib/solace.rb +++ b/gem/lib/solace.rb @@ -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' diff --git a/gem/lib/solace/connection.rb b/gem/lib/solace/connection.rb index 4968c81..e9ea1cf 100644 --- a/gem/lib/solace/connection.rb +++ b/gem/lib/solace/connection.rb @@ -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 diff --git a/gem/lib/solace/utils/account_context.rb b/gem/lib/solace/utils/account_context.rb index 950bc84..5b088fc 100644 --- a/gem/lib/solace/utils/account_context.rb +++ b/gem/lib/solace/utils/account_context.rb @@ -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 @@ -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] The loaded writable account pubkeys + # @param readonly [Array] The loaded readonly account pubkeys + # @return [Array] 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 @@ -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 diff --git a/gem/lib/solace/utils/lookup_table_context.rb b/gem/lib/solace/utils/lookup_table_context.rb new file mode 100644 index 0000000..1fb7a7a --- /dev/null +++ b/gem/lib/solace/utils/lookup_table_context.rb @@ -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] 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] 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] 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] 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] 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] 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 diff --git a/gem/test/solace/connection_test.rb b/gem/test/solace/connection_test.rb index 17dc51a..d99a69d 100644 --- a/gem/test/solace/connection_test.rb +++ b/gem/test/solace/connection_test.rb @@ -23,4 +23,21 @@ assert_operator connection.get_block_height, :<=, last_valid_block_height end end + + describe '#get_slot' do + it 'returns the current slot' do + assert_kind_of Integer, connection.get_slot + end + + it 'accepts a commitment override' do + finalized = connection.get_slot(commitment: 'finalized') + processed = connection.get_slot(commitment: 'processed') + + assert_operator finalized, :<=, processed + end + + it 'stays at or above the block height' do + assert_operator connection.get_slot, :>=, connection.get_block_height + end + end end diff --git a/gem/test/solace/utils/account_context_test.rb b/gem/test/solace/utils/account_context_test.rb index 320653a..9b7ca69 100644 --- a/gem/test/solace/utils/account_context_test.rb +++ b/gem/test/solace/utils/account_context_test.rb @@ -232,6 +232,52 @@ end end + describe '#relocate_loaded_accounts' do + let(:pubkey4) { keypair4.address } + let(:keypair4) { Solace::Keypair.generate } + + before do + context.set_fee_payer(keypair1) + context.add_writable_nonsigner(pubkey2) + context.add_writable_nonsigner(pubkey3) + context.add_readonly_nonsigner(pubkey4) + context.add_readonly_nonsigner(program_id) + + context.compile + end + + it 'returns the static accounts remaining in the message' do + compiled = context.accounts.dup + + static = context.relocate_loaded_accounts([pubkey3], [pubkey4]) + + assert_equal compiled - [pubkey3, pubkey4], static + end + + it 'moves loaded accounts to the end of the account space in writable-then-readonly order' do + static = context.relocate_loaded_accounts([pubkey3], [pubkey4]) + + assert_equal static + [pubkey3, pubkey4], context.accounts + assert_equal context.accounts.length - 2, context.index_of(pubkey3) + assert_equal context.accounts.length - 1, context.index_of(pubkey4) + end + + it 'removes loaded readonly accounts from the readonly unsigned count' do + assert_equal [1, 0, 2], context.header + + context.relocate_loaded_accounts([pubkey3], [pubkey4]) + + assert_equal [1, 0, 1], context.header + end + + it 'keeps the header intact when only writable accounts are loaded' do + static = context.relocate_loaded_accounts([pubkey2, pubkey3], []) + + assert_equal [1, 0, 2], context.header + assert_equal static + [pubkey2, pubkey3], context.accounts + end + end + describe 'edge cases' do it 'handles empty context compilation' do context.compile diff --git a/gem/test/solace/utils/lookup_table_context_test.rb b/gem/test/solace/utils/lookup_table_context_test.rb new file mode 100644 index 0000000..05392fd --- /dev/null +++ b/gem/test/solace/utils/lookup_table_context_test.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'test_helper' + +describe Solace::Utils::LookupTableContext do + let(:lookup_tables) { Solace::Utils::LookupTableContext.new } + + let(:table_account) { Solace::Keypair.generate.address } + + let(:fee_payer) { Solace::Keypair.generate.address } + let(:signer) { Solace::Keypair.generate.address } + let(:writable) { Solace::Keypair.generate.address } + let(:readonly) { Solace::Keypair.generate.address } + let(:unrelated) { Solace::Keypair.generate.address } + let(:program_id) { Solace::Constants::TOKEN_PROGRAM_ID } + + let(:account_context) do + Solace::Utils::AccountContext.new.tap do |context| + context.set_fee_payer(fee_payer) + context.add_writable_signer(signer) + context.add_writable_nonsigner(writable) + context.add_readonly_nonsigner(readonly) + context.add_readonly_nonsigner(program_id) + + context.compile + end + end + + describe '#initialize' do + it 'starts with no tables' do + assert_empty lookup_tables.tables + assert lookup_tables.empty? + end + end + + describe '#add_table' do + it 'registers a table and returns self for chaining' do + result = lookup_tables.add_table(account: table_account, addresses: [writable]) + + assert_equal lookup_tables, result + assert_equal [{ account: table_account, addresses: [writable] }], lookup_tables.tables + refute lookup_tables.empty? + end + + it 'normalizes accounts and addresses to strings' do + keypair = Solace::Keypair.generate + + lookup_tables.add_table(account: keypair, addresses: [keypair]) + + assert_equal [{ account: keypair.address, addresses: [keypair.address] }], lookup_tables.tables + end + end + + describe '#select_loaded_accounts' do + before do + lookup_tables.add_table( + account: table_account, + addresses: [unrelated, writable, readonly, signer, fee_payer, program_id] + ) + end + + it 'splits loadable accounts into writable and readonly segments with table references' do + writable_segment, readonly_segment = lookup_tables.select_loaded_accounts(account_context, [program_id]) + + assert_equal({ writable => { table_index: 0, address_index: 1 } }, writable_segment) + assert_equal({ readonly => { table_index: 0, address_index: 2 } }, readonly_segment) + end + + it 'never loads signers, the fee payer, program ids, or unreferenced addresses' do + writable_segment, readonly_segment = lookup_tables.select_loaded_accounts(account_context, [program_id]) + + loaded = writable_segment.keys + readonly_segment.keys + + assert_empty loaded & [signer, fee_payer, program_id, unrelated] + end + + it 'chooses the first table when tables share an address' do + other_table = Solace::Keypair.generate.address + lookup_tables.add_table(account: other_table, addresses: [writable, readonly]) + + writable_segment, = lookup_tables.select_loaded_accounts(account_context, [program_id]) + + assert_equal({ table_index: 0, address_index: 1 }, writable_segment[writable]) + end + + it 'returns empty segments when nothing is loadable' do + other_context = Solace::Utils::AccountContext.new.tap do |context| + context.set_fee_payer(fee_payer) + context.compile + end + + assert_equal [{}, {}], lookup_tables.select_loaded_accounts(other_context, []) + end + end + + describe '#address_lookup_tables_for' do + let(:other_table) { Solace::Keypair.generate.address } + + before do + lookup_tables.add_table(account: table_account, addresses: [unrelated, writable]) + lookup_tables.add_table(account: other_table, addresses: [readonly]) + end + + it 'builds one reference per contributing table' do + references = lookup_tables.address_lookup_tables_for( + { writable => { table_index: 0, address_index: 1 } }, + { readonly => { table_index: 1, address_index: 0 } } + ) + + assert_equal 2, references.length + assert_instance_of Solace::AddressLookupTable, references.first + + assert_equal table_account, references.first.account + assert_equal [1], references.first.writable_indexes + assert_empty references.first.readonly_indexes + + assert_equal other_table, references.last.account + assert_empty references.last.writable_indexes + assert_equal [0], references.last.readonly_indexes + end + + it 'omits tables that contribute no loaded accounts' do + references = lookup_tables.address_lookup_tables_for( + { writable => { table_index: 0, address_index: 1 } }, + {} + ) + + assert_equal [table_account], references.map(&:account) + end + end +end From 3380146283f2473bfce60ace168c1b6514695038 Mon Sep 17 00:00:00 2001 From: Sorin Guga Date: Wed, 15 Jul 2026 17:33:45 +0300 Subject: [PATCH 2/4] Compose v0 transactions through lookup tables in TransactionComposer TransactionComposer#add_lookup_table registers an on-chain table (address + full ordered address list). Registering a table opts the transaction into the v0 format, matching @solana/web3.js: compose_transaction loads every eligible compiled account through the tables instead of a static account slot and rebuilds instruction indices against the combined v0 account space. With no tables the legacy path is untouched. Unit tests cover v0 emission, the static/loaded split, header adjustment, table references, index resolution, serialization round-trip, and the legacy path; a validator-backed test provisions a real lookup table (create + extend via test-support composers for the ALT program) and lands a v0 transfer through it. Co-Authored-By: Claude Fable 5 --- gem/lib/solace/transaction_composer.rb | 88 ++++++- gem/test/solace/transaction_composer_test.rb | 243 ++++++++++++++++++- gem/test/support/lookup_table_program.rb | 63 +++++ gem/test/test_helper.rb | 1 + 4 files changed, 390 insertions(+), 5 deletions(-) create mode 100644 gem/test/support/lookup_table_program.rb diff --git a/gem/lib/solace/transaction_composer.rb b/gem/lib/solace/transaction_composer.rb index 49a2b9e..cfaf35a 100644 --- a/gem/lib/solace/transaction_composer.rb +++ b/gem/lib/solace/transaction_composer.rb @@ -73,6 +73,10 @@ 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 @@ -80,6 +84,7 @@ def initialize(connection:) @connection = connection @instruction_composers = [] @context = Utils::AccountContext.new + @lookup_tables = Utils::LookupTableContext.new end # Add an instruction composer to the transaction @@ -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] The program id pubkeys + def program_ids + build_instructions.map { |instruction| context.accounts[instruction.program_index] }.uniq + end # Build all instructions with resolved indices # diff --git a/gem/test/solace/transaction_composer_test.rb b/gem/test/solace/transaction_composer_test.rb index 8572c9d..a2ce54a 100644 --- a/gem/test/solace/transaction_composer_test.rb +++ b/gem/test/solace/transaction_composer_test.rb @@ -23,7 +23,7 @@ # Test programs let(:system_program) { Solace::Constants::SYSTEM_PROGRAM_ID } - let(:spl_token_program) { Solace::Constants::SPL_TOKEN_PROGRAM_ID } + let(:spl_token_program) { Solace::Constants::TOKEN_PROGRAM_ID } # Test composers let(:transfer_composer1) do @@ -273,4 +273,245 @@ def connection.get_latest_blockhash assert_equal payer_keypair.address, tx.message.accounts[0] end end + + describe '#add_lookup_table' do + let(:table_account) { Solace::Keypair.generate.address } + + it 'registers the table on the lookup table context and returns self for chaining' do + result = composer.add_lookup_table(account: table_account, addresses: [bob_keypair.address]) + + assert_equal composer, result + assert_equal [{ account: table_account, addresses: [bob_keypair.address] }], composer.lookup_tables.tables + end + end + + describe '#compose_transaction with lookup tables' do + let(:table_account) { Solace::Keypair.generate.address } + + let(:mint_address) { mint_keypair.address } + let(:from_token_account) { Solace::Keypair.generate.address } + let(:to_token_account) { Solace::Keypair.generate.address } + let(:unrelated_address) { Solace::Keypair.generate.address } + + let(:transfer_checked_composer) do + Solace::Composers::SplTokenProgramTransferCheckedComposer.new( + from: from_token_account, + to: to_token_account, + mint: mint_address, + authority: anna_keypair, + amount: 1_000, + decimals: 6 + ) + end + + before do + # Mock connection to return a blockhash + def connection.get_latest_blockhash + ['EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N', 1000] + end + + composer + .add_instruction(transfer_checked_composer) + .set_fee_payer(payer_keypair) + end + + describe 'when the table covers loadable accounts' do + before do + composer.add_lookup_table( + account: table_account, + addresses: [unrelated_address, to_token_account, mint_address, anna_keypair.address, spl_token_program] + ) + + @transaction = composer.compose_transaction + @message = @transaction.message + end + + it 'emits a v0 message' do + assert_predicate @message, :versioned? + assert_equal 0, @message.version + end + + it 'moves loadable accounts out of the static account list' do + refute_includes @message.accounts, to_token_account + refute_includes @message.accounts, mint_address + + # Writable, but not present in the table — stays static + assert_includes @message.accounts, from_token_account + end + + it 'keeps signers, the fee payer, and program ids static even when listed in the table' do + assert_equal payer_keypair.address, @message.accounts[0] + assert_includes @message.accounts, anna_keypair.address + assert_includes @message.accounts, spl_token_program + end + + it 'drops loaded readonly accounts from the readonly unsigned count' do + # payer + authority sign; of the two readonly unsigned accounts + # (mint + token program) only the program remains static + assert_equal [2, 0, 1], @message.header + end + + it 'references loaded accounts through their table positions' do + assert_equal 1, @message.address_lookup_tables.length + + table = @message.address_lookup_tables.first + + assert_equal table_account, table.account + assert_equal [1], table.writable_indexes # to_token_account + assert_equal [2], table.readonly_indexes # mint + end + + it 'resolves instruction indices against the combined v0 account space' do + combined = @message.accounts + [to_token_account, mint_address] + + instruction = @message.instructions.first + + assert_equal spl_token_program, combined[instruction.program_index] + assert_equal( + [from_token_account, mint_address, to_token_account, anna_keypair.address], + instruction.accounts.map { |index| combined[index] } + ) + end + + it 'round-trips through serialization' do + decoded = Solace::Transaction.from(@transaction.serialize).message + + assert_equal 0, decoded.version + assert_equal @message.accounts, decoded.accounts + assert_equal @message.header, decoded.header + + table = decoded.address_lookup_tables.first + + assert_equal table_account, table.account + assert_equal [1], table.writable_indexes + assert_equal [2], table.readonly_indexes + end + end + + describe 'when no table address is loadable' do + before do + composer.add_lookup_table( + account: table_account, + addresses: [unrelated_address, anna_keypair.address, spl_token_program] + ) + + @message = composer.compose_transaction.message + end + + it 'composes a v0 message with no table references and every account static' do + assert_equal 0, @message.version + assert_empty @message.address_lookup_tables + assert_includes @message.accounts, to_token_account + assert_includes @message.accounts, mint_address + assert_equal [2, 0, 2], @message.header + end + end + + describe 'when no lookup tables were added' do + before do + @message = composer.compose_transaction.message + end + + it 'composes a legacy message' do + refute_predicate @message, :versioned? + assert_empty @message.address_lookup_tables + end + end + end + + describe 'composing a v0 transaction against the validator' do + before(:all) do + @connection = Solace::Connection.new(commitment: 'processed') + + bob = Fixtures.load_keypair('bob') + @recipient1 = Solace::Keypair.generate + @recipient2 = Solace::Keypair.generate + + # Provision an on-chain lookup table holding the recipients + recent_slot = @connection.get_slot - 1 + + @table_address, bump = Solace::Utils::PDA.find_program_address( + [bob.address, Solace::Utils::Codecs.encode_le_u64(recent_slot).bytes], + Solace::Constants::ADDRESS_LOOKUP_TABLE_PROGRAM_ID + ) + + provision_tx = Solace::TransactionComposer + .new(connection: @connection) + .add_instruction(LookupTableProgram::CreateComposer.new( + table: @table_address, + payer: bob.address, + recent_slot: recent_slot, + bump: bump + )) + .add_instruction(LookupTableProgram::ExtendComposer.new( + table: @table_address, + payer: bob.address, + addresses: [@recipient1.address, @recipient2.address] + )) + .set_fee_payer(bob) + .compose_transaction + + provision_tx.sign(bob) + + signature = @connection.send_transaction(provision_tx.serialize) + @connection.wait_for_confirmed_signature { signature['result'] } + + # A table extended in slot N becomes usable in slot N + 1 + extended_slot = @connection.get_slot + 50.times do + break if @connection.get_slot > extended_slot + + sleep 0.2 + end + + # Compose the transfers as a v0 transaction loading the recipients + # through the on-chain table + transaction = Solace::TransactionComposer + .new(connection: @connection) + .add_instruction(Solace::Composers::SystemProgramTransferComposer.new( + from: bob, + to: @recipient1, + lamports: 5_000_000 + )) + .add_instruction(Solace::Composers::SystemProgramTransferComposer.new( + from: bob, + to: @recipient2, + lamports: 6_000_000 + )) + .set_fee_payer(bob) + .add_lookup_table( + account: @table_address, + addresses: [@recipient1.address, @recipient2.address] + ) + .compose_transaction + + @message = transaction.message + + transaction.sign(bob) + + @signature = @connection.send_transaction(transaction.serialize) + end + + it 'emits a v0 message with the recipients loaded through the table' do + assert_equal 0, @message.version + + refute_includes @message.accounts, @recipient1.address + refute_includes @message.accounts, @recipient2.address + + assert_equal [@table_address], @message.address_lookup_tables.map(&:account) + assert_equal [0, 1], @message.address_lookup_tables.first.writable_indexes + assert_empty @message.address_lookup_tables.first.readonly_indexes + end + + it 'is confirmed by the node' do + assert(@connection.wait_for_confirmed_signature { @signature['result'] }) + end + + it 'credits the recipients through the loaded addresses' do + @connection.wait_for_confirmed_signature { @signature['result'] } + + assert_equal 5_000_000, @connection.get_balance(@recipient1.address) + assert_equal 6_000_000, @connection.get_balance(@recipient2.address) + end + end end diff --git a/gem/test/support/lookup_table_program.rb b/gem/test/support/lookup_table_program.rb new file mode 100644 index 0000000..43fc133 --- /dev/null +++ b/gem/test/support/lookup_table_program.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +# Test-only composers for the Address Lookup Table program, used to provision +# on-chain tables for the versioned-transaction tests. The gem ships no ALT +# program composers — composed transactions only reference tables that already +# exist on chain. +module LookupTableProgram + # Shared shape of the CreateLookupTable and ExtendLookupTable instructions: + # both take [table, authority, payer, system program], and in these tests the + # authority and payer are the same signer. Subclasses supply the data bytes. + class BaseComposer < Solace::Composers::Base + def setup_accounts + account_context.add_writable_nonsigner(params[:table]) + account_context.add_writable_signer(params[:payer]) + account_context.add_readonly_nonsigner(Solace::Constants::SYSTEM_PROGRAM_ID) + account_context.add_readonly_nonsigner(Solace::Constants::ADDRESS_LOOKUP_TABLE_PROGRAM_ID) + end + + def build_instruction(account_context) + Solace::Instruction.new.tap do |ix| + ix.program_index = account_context.index_of(Solace::Constants::ADDRESS_LOOKUP_TABLE_PROGRAM_ID) + ix.accounts = account_indices(account_context) + ix.data = instruction_data + end + end + + private + + # [table, authority, payer, system program] + def account_indices(account_context) + [ + account_context.index_of(params[:table]), + account_context.index_of(params[:payer]), + account_context.index_of(params[:payer]), + account_context.index_of(Solace::Constants::SYSTEM_PROGRAM_ID) + ] + end + end + + # Composer for the CreateLookupTable instruction + class CreateComposer < BaseComposer + private + + # [u32 discriminator = 0] + [u64 recent slot] + [u8 bump] + def instruction_data + [0, 0, 0, 0] + + Solace::Utils::Codecs.encode_le_u64(params[:recent_slot]).bytes + + [params[:bump]] + end + end + + # Composer for the ExtendLookupTable instruction + class ExtendComposer < BaseComposer + private + + # [u32 discriminator = 2] + [u64 number of addresses] + [addresses] + def instruction_data + [2, 0, 0, 0] + + Solace::Utils::Codecs.encode_le_u64(params[:addresses].length).bytes + + params[:addresses].flat_map { |address| Solace::Utils::Codecs.base58_to_bytes(address) } + end + end +end diff --git a/gem/test/test_helper.rb b/gem/test/test_helper.rb index fb9d8a2..833cb21 100644 --- a/gem/test/test_helper.rb +++ b/gem/test/test_helper.rb @@ -19,4 +19,5 @@ require_relative 'support/fixtures' require_relative 'support/factory_bot' +require_relative 'support/lookup_table_program' require_relative 'support/solana_test_validator' From de93a82e1589f14ab65ea5f2b9972a8877072c81 Mon Sep 17 00:00:00 2001 From: Sorin Guga Date: Wed, 15 Jul 2026 17:33:59 +0300 Subject: [PATCH 3/4] Document lookup table composition - Transaction composer page: add_lookup_table + lookup_tables entries and an "Address lookup tables (v0)" section - ALT concepts page: composer example; scope tip now reflects that composing v0 transactions from existing tables is covered - Account context page: the v0 relocation step - Connection page: get_slot Co-Authored-By: Claude Fable 5 --- site/building/transaction-composer.md | 37 ++++++++++++++++++++++++++ site/concepts/account-context.md | 12 +++++++++ site/concepts/address-lookup-tables.md | 30 ++++++++++++++++++--- site/concepts/connection-and-rpc.md | 1 + 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/site/building/transaction-composer.md b/site/building/transaction-composer.md index 9b8715e..4776d99 100644 --- a/site/building/transaction-composer.md +++ b/site/building/transaction-composer.md @@ -34,6 +34,7 @@ connection.send_transaction(tx.serialize) | `prepend_instruction(composer)` | `self` | Insert a composer at the front. | | `insert_instruction(index, composer)` | `self` | Insert at a position. | | `set_fee_payer(pubkey)` | `self` | Set the fee payer (`#to_s`); becomes account index 0. | +| `add_lookup_table(account:, addresses:)` | `self` | Make an [address lookup table](/concepts/address-lookup-tables) available; the composed transaction becomes v0. | | `merge(other, placement: :add, index: nil)` | `self` | Merge another `TransactionComposer` (`placement:` `:add`, `:prepend`, or `:insert` with `index:`). | | `compose_transaction` | `Solace::Transaction` | Compile accounts, fetch blockhash, build the message, return an unsigned transaction. | @@ -42,6 +43,7 @@ connection.send_transaction(tx.serialize) | `connection` | The bound `Connection`. | | `context` | The shared `AccountContext`. | | `instruction_composers` | The composers added so far. | +| `lookup_tables` | The shared `LookupTableContext` holding the registered lookup tables. | ## Batching several instructions @@ -74,3 +76,38 @@ connection.send_transaction(tx.serialize) This is the layer to reach for when you want several instructions in one atomic transaction, or precise control over the fee payer and signing — without dropping all the way down to hand-built [messages](/concepts/transactions-and-messages). + +## Address lookup tables (v0) + +When a transaction touches more accounts than the legacy format can carry, hand the +composer the [address lookup tables](/concepts/address-lookup-tables) it may load through +— each as the table's address plus its full, ordered on-chain address list: + +```ruby +tx = Solace::TransactionComposer.new(connection:) + .add_instruction(swap_composer) + .set_fee_payer(payer.address) + .add_lookup_table( + account: table_address, + addresses: table_addresses + ) + .compose_transaction +``` + +`compose_transaction` then emits a **v0 message**: every compiled account found in a table +that is allowed to load (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. +Signers, the fee payer, and program ids always stay static — those are runtime rules, not +options. Instruction indices resolve against the combined v0 account space +`[static..., loaded writable..., loaded readonly...]`, matching how the Solana runtime +flattens loaded addresses before execution. + +Add as many tables as you like — when an address appears in several, the first table +wins. Registering a table opts the transaction into the v0 format: even if nothing ends +up loadable the message is v0 (with an empty lookup list), mirroring +`@solana/web3.js`. With no tables registered the composer emits a legacy message, +exactly as before. + +The bookkeeping lives in `Solace::Utils::LookupTableContext` (exposed as +`composer.lookup_tables`), the lookup-table counterpart to +[`AccountContext`](/concepts/account-context). diff --git a/site/concepts/account-context.md b/site/concepts/account-context.md index 24e6ae2..c0dee3c 100644 --- a/site/concepts/account-context.md +++ b/site/concepts/account-context.md @@ -58,6 +58,18 @@ ix = Solace::Instructions::SystemProgram::TransferInstruction.build( ) ``` +## Relocating accounts loaded through lookup tables + +For v0 transactions, `relocate_loaded_accounts(writable, readonly)` rebuilds a compiled +account list as `[static..., loaded writable..., loaded readonly...]` — the combined +account space the Solana runtime uses once +[address lookup tables](/concepts/address-lookup-tables) are resolved — and removes the +loaded readonly accounts from the header's readonly-unsigned count. It returns the static +accounts that remain in the message, while `index_of` keeps resolving loaded accounts at +their combined-space positions. The +[`TransactionComposer`](/building/transaction-composer#address-lookup-tables-v0) calls +this for you when lookup tables are in play. + ## You usually don't touch it directly The [`TransactionComposer`](/building/transaction-composer) owns an `AccountContext` diff --git a/site/concepts/address-lookup-tables.md b/site/concepts/address-lookup-tables.md index 7910d83..37233b3 100644 --- a/site/concepts/address-lookup-tables.md +++ b/site/concepts/address-lookup-tables.md @@ -42,9 +42,33 @@ ALTs serialize and deserialize through the [serialization layer](/reference/seri (`AddressLookupTable.deserialize(io)` / `#serialize`), the same path used for the rest of the wire format. +## Composing v0 transactions + +You rarely build these references by hand. Register a table on the +[`TransactionComposer`](/building/transaction-composer#address-lookup-tables-v0) and it +selects the loadable accounts, computes the indexes, and emits a v0 message for you: + +```ruby +tx = Solace::TransactionComposer.new(connection:) + .add_instruction(swap_composer) + .set_fee_payer(payer.address) + .add_lookup_table( + account: lookup_table_address, + addresses: on_chain_table_addresses # the table's full address list + ) + .compose_transaction + +tx.message.versioned? # => true — registering a table opts into the v0 format +``` + +The selection follows the runtime rules: signers, the fee payer, and instruction program +ids always stay static; everything else referenced by the transaction and present in a +table is loaded by index. + ::: tip Scope -Solace models the lookup-table **reference** inside a transaction so it can serialize and -deserialize v0 transactions that use ALTs. Building examples target legacy transactions -unless versioned features are specifically needed; see +Solace models the lookup-table **reference** inside a transaction — plus the composer +support above for building v0 transactions from tables that already exist on chain. +Creating or extending the on-chain tables themselves (the Address Lookup Table program's +instructions) is not covered; see [Transactions & Messages](/concepts/transactions-and-messages#legacy-vs-versioned). ::: diff --git a/site/concepts/connection-and-rpc.md b/site/concepts/connection-and-rpc.md index 390788c..63e17c7 100644 --- a/site/concepts/connection-and-rpc.md +++ b/site/concepts/connection-and-rpc.md @@ -36,6 +36,7 @@ test suite and most examples assume. | `get_minimum_lamports_for_rent_exemption(space)` | `Integer` | Rent-exempt minimum for `space` bytes. | | `get_program_accounts(program_id, filters)` | `Array` | Accounts owned by a program. | | `get_block_height(commitment:)` | `Integer` | Current block height (defaults to the connection's commitment). | +| `get_slot(commitment:)` | `Integer` | Current slot (defaults to the connection's commitment). | | `get_version` / `get_health` / `get_genesis_hash` | varies | Node metadata. | ## Blockhash and rent From cbd91b0502714fdaef0cf56b2e34d879426a7e2c Mon Sep 17 00:00:00 2001 From: Sorin Guga Date: Wed, 15 Jul 2026 17:33:59 +0300 Subject: [PATCH 4/4] Bump version to 0.1.8 Co-Authored-By: Claude Fable 5 --- CHANGELOG | 15 +++++++++++++++ gem/Gemfile.lock | 2 +- gem/lib/solace/version.rb | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 49e59e1..7fd9afe 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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 diff --git a/gem/Gemfile.lock b/gem/Gemfile.lock index 0b0753b..7e8c6cd 100644 --- a/gem/Gemfile.lock +++ b/gem/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solace (0.1.7) + solace (0.1.8) base58 (~> 0.2) ffi (~> 1.15) rbnacl (~> 7.0) diff --git a/gem/lib/solace/version.rb b/gem/lib/solace/version.rb index c4be1f9..4938135 100644 --- a/gem/lib/solace/version.rb +++ b/gem/lib/solace/version.rb @@ -2,5 +2,5 @@ module Solace # Latest version of the Solace gem. - VERSION = '0.1.7' + VERSION = '0.1.8' end