From 1ab7011566bec2097cb1b0609a57606aff230fd1 Mon Sep 17 00:00:00 2001 From: Jochem Arends <77842807+jochemarends@users.noreply.github.com.> Date: Fri, 7 Nov 2025 19:10:18 +0100 Subject: [PATCH 1/4] add Address Lookup Table program and support for version 0 transactions (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since transactions can now use address lookup tables, this commit adds support for the version 0 transaction format. This format is implicitly used when the `:lookup_tables` option is non-empty: - `:lookup_tables` (default: `%{}`) — A map of address lookup tables to use. Each key is a `t:Solana.Key.t/0` representing the address of a lookup table account, and each value is a list of `t:Solana.Key.t/0` representing the addresses of that lookup table. When non-empty, the transaction is encoded using the [version 0 format](https://docs.anza.xyz/proposals/versioned-transactions#versioned-transactions). --- lib/solana/account.ex | 4 +- lib/solana/lookup_table.ex | 315 ++++++++++++++++++++++++++++++ lib/solana/rpc/request.ex | 11 ++ lib/solana/tx.ex | 87 ++++++++- test/solana/lookup_table_test.exs | 207 ++++++++++++++++++++ 5 files changed, 620 insertions(+), 4 deletions(-) create mode 100644 lib/solana/lookup_table.ex create mode 100644 test/solana/lookup_table_test.exs diff --git a/lib/solana/account.ex b/lib/solana/account.ex index 91b5eb4..a75ff43 100644 --- a/lib/solana/account.ex +++ b/lib/solana/account.ex @@ -10,12 +10,14 @@ defmodule Solana.Account do @type t :: %__MODULE__{ signer?: boolean(), writable?: boolean(), + invoked?: boolean(), key: Solana.key() | nil } defstruct [ :key, signer?: false, - writable?: false + writable?: false, + invoked?: false ] end diff --git a/lib/solana/lookup_table.ex b/lib/solana/lookup_table.ex new file mode 100644 index 0000000..4391937 --- /dev/null +++ b/lib/solana/lookup_table.ex @@ -0,0 +1,315 @@ +defmodule Solana.LookupTable do + @moduledoc """ + Functions for interacting with Solana's + [Address Lookup Table] + (https://solana.com/developers/guides/advanced/lookup-tables) Program. + """ + alias Solana.{Account, Instruction, Key, SystemProgram} + import Solana.Helpers + + @typedoc "Address lookup table account metadata." + @type t :: %__MODULE__{ + authority: Key.t() | nil, + keys: [Key.t()], + deactivation_slot: non_neg_integer(), + last_extended_slot: non_neg_integer(), + last_extended_slot_start_index: non_neg_integer() + } + + defstruct [ + :authority, + :keys, + :deactivation_slot, + :last_extended_slot, + :last_extended_slot_start_index + ] + + @doc """ + The on-chain size of an address lookup table containing the given number of keys. + """ + def byte_size(key_count \\ 0), do: 56 + key_count * 32 + + @doc """ + The Address Lookup Table Program's program ID. + """ + def id(), do: Solana.pubkey!("AddressLookupTab1e1111111111111111111111111") + + @doc """ + Finds the address lookup table account addresss associated with a given + authority and recent block's slot. + """ + def find_address(authority, recent_slot) do + Solana.Key.find_address([authority, <>], id()) + end + + @doc """ + Translates the result of a `Solana.RPC.Request.get_account_info/2` into a + `t:Solana.LookupTable.t/0`. + """ + @spec from_account_info(info :: map) :: t() | :error + def from_account_info(%{"data" => %{"parsed" => %{"info" => info}}}) do + from_lookup_table_account_info(info) + end + + def from_account_info(_), do: :error + + defp from_lookup_table_account_info(%{ + "addresses" => keys, + "deactivationSlot" => deactivation_slot, + "lastExtendedSlot" => last_extended_slot, + "lastExtendedSlotStartIndex" => last_extended_slot_start_index + } = info) do + authority = with encoded when encoded != nil <- Map.get(info, "authority") do + Solana.pubkey!(encoded) + end + %__MODULE__{ + authority: authority, + keys: Enum.map(keys, &Solana.pubkey!/1), + deactivation_slot: String.to_integer(deactivation_slot), + last_extended_slot: String.to_integer(last_extended_slot), + last_extended_slot_start_index: last_extended_slot_start_index + } + end + + defp from_lookup_table_account_info(_), do: :error + + @create_lookup_table_schema [ + authority: [ + type: {:custom, Solana.Key, :check, []}, + required: true, + doc: "Public key of the account used to derive and control the address lookup table" + ], + payer: [ + type: {:custom, Solana.Key, :check, []}, + required: true, + doc: "Public key of the account that will fund the created address lookup table" + ], + payer: [ + type: {:custom, Solana.Key, :check, []}, + required: true, + doc: "Account that will fund the created address lookup table" + ], + recent_slot: [ + type: :non_neg_integer, + required: true, + doc: "A recent slot must be used in the derivation path for each initialized table" + ] + ] + @doc """ + Generates the instructions for creating a new address lookup table. + + Returns a tuple containing the instructions and the public key of the derived + address lookup table account. + + ## Options + + #{NimbleOptions.docs(@create_lookup_table_schema)} + """ + def create_lookup_table(opts) do + with {:ok, params} <- validate(opts, @create_lookup_table_schema) do + create_lookup_table_ix(params) + end + end + + defp create_lookup_table_ix(params) do + with {:ok, lookup_table, bump_seed} + <- find_address(params.authority, params.recent_slot) do + ix = %Instruction{ + program: id(), + accounts: [ + %Account{key: lookup_table, signer?: false, writable?: true}, + %Account{key: params.authority, signer?: false, writable?: false}, + %Account{key: params.payer, signer?: true, writable?: true}, + %Account{key: SystemProgram.id(), signer?: false, writable?: false} + ], + data: + Instruction.encode_data([ + {0, 32}, + {params.recent_slot, 64}, + {bump_seed, 8} + ]) + } + {ix, lookup_table} + end + end + + @freeze_lookup_table_schema [ + lookup_table: [ + type: {:custom, Solana.Key, :check, []}, + required: true, + doc: "Public key of the address lookup table" + ], + authority: [ + type: {:custom, Solana.Key, :check, []}, + required: true, + doc: "Public key of the account used to derive and control the address lookup table" + ] + ] + @doc """ + Generates the instructions for freezing an address lookup table. + + Freezing an addess lookup table makes it immutable. It can never be closed or + extended again. Only non-empty lookup tables can be frozen. + + ## Options + + #{NimbleOptions.docs(@freeze_lookup_table_schema)} + """ + def freeze_lookup_table(opts) do + with {:ok, params} <- validate(opts, @freeze_lookup_table_schema) do + freeze_lookup_table_ix(params) + end + end + + defp freeze_lookup_table_ix(params) do + %Instruction{ + program: id(), + accounts: + List.flatten([ + %Account{key: params.lookup_table, signer?: false, writable?: true}, + %Account{key: params.authority, signer?: true, writable?: false}, + ]), + data: Instruction.encode_data([{1, 32}]) + } + end + + @extend_lookup_table_schema [ + lookup_table: [ + type: {:custom, Solana.Key, :check, []}, + required: true, + doc: "Public key of the address lookup table" + ], + authority: [ + type: {:custom, Solana.Key, :check, []}, + required: true, + doc: "Public key of the account used to derive and control the address lookup table" + ], + payer: [ + type: {:custom, Solana.Key, :check, []}, + required: false, + doc: "Public key of the account that will fund any fees needed to extend the lookup table" + ], + new_keys: [ + type: {:list, {:custom, Solana.Key, :check, []}}, + required: true, + doc: "Pubic keys of the accounts that will be added to the address lookup table" + ] + ] + @doc """ + Generates the instructions for extending an address lookup table. + + ## Options + + #{NimbleOptions.docs(@extend_lookup_table_schema)} + """ + def extend_lookup_table(opts) do + with {:ok, params} <- validate(opts, @extend_lookup_table_schema) do + extend_lookup_table_ix(params) + end + end + + defp extend_lookup_table_ix(params) do + %Instruction{ + program: id(), + accounts: + List.flatten([ + %Account{key: params.lookup_table, signer?: false, writable?: true}, + %Account{key: params.authority, signer?: true, writable?: false}, + if(params[:payer], do: [ + %Account{key: params.payer, signer?: true, writable?: true}, + %Account{key: SystemProgram.id(), signer?: false, writable?: false} + ], else: []) + ]), + data: Instruction.encode_data(List.flatten([ + {2, 32}, + {length(params.new_keys), 64}, + params.new_keys + ])) + } + end + + @deactivate_lookup_table_schema [ + lookup_table: [ + type: {:custom, Solana.Key, :check, []}, + required: true, + doc: "Public key of the address lookup table" + ], + authority: [ + type: {:custom, Solana.Key, :check, []}, + required: true, + doc: "Public key of the account used to derive and control the address lookup table" + ] + ] + @doc """ + Generates the instructions for deactivating an address lookup table. + + Once deactivated, an address lookup table can no longer be extended or used + for lookups in transactions. A lookup tables can only be closed once its + deactivation slot is no longer present in the + [SlotHashes](https://docs.anza.xyz/runtime/sysvars/#slothashes) sysvar. + + ## Options + + #{NimbleOptions.docs(@deactivate_lookup_table_schema)} + """ + def deactivate_lookup_table(opts) do + with {:ok, params} <- validate(opts, @deactivate_lookup_table_schema) do + deactivate_lookup_table_ix(params) + end + end + + defp deactivate_lookup_table_ix(params) do + %Instruction{ + program: id(), + accounts: + List.flatten([ + %Account{key: params.lookup_table, signer?: false, writable?: true}, + %Account{key: params.authority, signer?: true, writable?: false}, + ]), + data: Instruction.encode_data([{3, 32}]) + } + end + + @close_lookup_table_schema [ + lookup_table: [ + type: {:custom, Solana.Key, :check, []}, + required: true, + doc: "Public key of the address lookup table" + ], + authority: [ + type: {:custom, Solana.Key, :check, []}, + required: true, + doc: "Public key of the account used to derive and control the address lookup table" + ], + recipient: [ + type: {:custom, Solana.Key, :check, []}, + required: true, + doc: "Public key of the account to send the closed account's lamports to" + ] + ] + @doc """ + Generates the instructions for closing an address lookup table. + + ## Options + + #{NimbleOptions.docs(@close_lookup_table_schema)} + """ + def close_lookup_table(opts) do + with {:ok, params} <- validate(opts, @close_lookup_table_schema) do + close_lookup_table_ix(params) + end + end + + defp close_lookup_table_ix(params) do + %Instruction{ + program: id(), + accounts: + List.flatten([ + %Account{key: params.lookup_table, signer?: false, writable?: true}, + %Account{key: params.authority, signer?: true, writable?: false}, + %Account{key: params.recipient, signer?: false, writable?: true}, + ]), + data: Instruction.encode_data([{4, 32}]) + } + end +end diff --git a/lib/solana/rpc/request.ex b/lib/solana/rpc/request.ex index 6262ee6..2fd4c4c 100644 --- a/lib/solana/rpc/request.ex +++ b/lib/solana/rpc/request.ex @@ -67,6 +67,17 @@ defmodule Solana.RPC.Request do {"getBalance", [B58.encode58(account), encode_opts(opts)]} end + @doc """ + Returns the slot that has reached the given or default commitment level. + + For more information, see [the Solana + docs](https://solana.com/docs/rpc/http/getslot). + """ + @spec get_slot(opts :: keyword) :: t + def get_slot(opts \\ []) do + {"getSlot", [encode_opts(opts)]} + end + @doc """ Returns identity and transaction information about a confirmed block in the ledger. diff --git a/lib/solana/tx.ex b/lib/solana/tx.ex index 43a7b4f..1b86e5d 100644 --- a/lib/solana/tx.ex +++ b/lib/solana/tx.ex @@ -85,6 +85,14 @@ defmodule Solana.Transaction do mirrors the JavaScript solana/web3.js `serialize({ requireAllSignatures })` behavior. + - `:lookup_tables` (default: `%{}`) — A map of address lookup tables to use. + Each key is a `t:Solana.Key.t/0` representing the address of a lookup table + account, and each value is a list of `t:Solana.Key.t/0` representing the + addresses of that lookup table. + + When non-empty, the transaction is encoded using the [version 0 + format](https://docs.anza.xyz/proposals/versioned-transactions#versioned-transactions). + Returns `{:ok, encoded_transaction}` if the transaction was successfully encoded, or an error tuple if the encoding failed -- plus more error details via `Logger.error/1`. @@ -100,7 +108,9 @@ defmodule Solana.Transaction do accounts = compile_accounts(ixs, tx.payer), true <- signers_match?(accounts, signers, Keyword.get(opts, :require_all_signatures?, true)) do - message = encode_message(accounts, tx.blockhash, ixs) + lookup_tables = Keyword.get(opts, :lookup_tables, %{}) + + message = encode_message(accounts, tx.blockhash, ixs, lookup_tables) signatures = accounts @@ -137,7 +147,7 @@ defmodule Solana.Transaction do # https://docs.solana.com/developing/programming-model/transactions#account-addresses-format defp compile_accounts(ixs, payer) do ixs - |> Enum.map(fn ix -> [%Account{key: ix.program} | ix.accounts] end) + |> Enum.map(fn ix -> [%Account{key: ix.program, invoked?: true} | ix.accounts] end) |> List.flatten() |> Enum.reject(&(&1.key == payer)) |> Enum.sort_by(&{&1.signer?, &1.writable?}, &>=/2) @@ -161,8 +171,62 @@ defmodule Solana.Transaction do true end + defp encode_version(0), do: <<0x80>> + + # https://docs.anza.xyz/proposals/versioned-transactions#versioned-transactions + defp compile_lookup_table(accounts, {lookup_table, keys}) do + # Accounts that are signers, invoked, or not present in the lookup table + # must be stored in the message itself + {static_accounts, lookup_accounts} = + accounts + |> Enum.split_with(& &1.key not in keys or &1.signer? or &1.invoked?) + + {writable_indices, readonly_indices} = + lookup_accounts + |> Enum.map(fn %Account{key: key} = account -> + keys + |> Enum.find_index(& &1 == key) + |> then(&{account, &1}) + end) + |> Enum.split_with(fn {account, _index} -> account.writable? end) + |> then(fn {writable_accounts_with_index, readonly_accounts_with_index} -> + { + writable_accounts_with_index |> Enum.map(fn {_account, index} -> index end), + readonly_accounts_with_index |> Enum.map(fn {_account, index} -> index end) + } + end) + + compiled_lookup_table = + [ + lookup_table, + CompactArray.to_iolist(writable_indices), + CompactArray.to_iolist(readonly_indices) + ] + |> :erlang.list_to_binary() + + {static_accounts, lookup_accounts, compiled_lookup_table} + end + + defp compile_lookup_tables(accounts, lookup_tables) do + lookup_tables + |> Enum.reduce({accounts, [], []}, fn lookup_table, acc -> + {static_accounts, lookup_accounts, compiled_lookup_table} = + compile_lookup_table(elem(acc, 0), lookup_table) + { + static_accounts, + [lookup_accounts | elem(acc, 1)], + [compiled_lookup_table | elem(acc, 2)] + } + end) + |> then(fn {_, lookup_accounts, compiled_lookup_tables} = result -> + result + |> put_elem(1, lookup_accounts |> Enum.reverse() |> List.flatten()) + |> put_elem(2, compiled_lookup_tables |> Enum.reverse()) + end) + end + # https://docs.solana.com/developing/programming-model/transactions#message-format - defp encode_message(accounts, blockhash, ixs) do + defp encode_message(accounts, blockhash, ixs, %{} = _lookup_tables) do [ create_header(accounts), CompactArray.to_iolist(Enum.map(accounts, & &1.key)), @@ -172,6 +236,23 @@ defmodule Solana.Transaction do |> :erlang.list_to_binary() end + defp encode_message(accounts, blockhash, ixs, lookup_tables) do + {static_accounts, lookup_accounts, compiled_lookup_tables} = + compile_lookup_tables(accounts, lookup_tables) + + accounts = static_accounts ++ lookup_accounts + + [ + encode_version(0), + create_header(static_accounts), + CompactArray.to_iolist(Enum.map(static_accounts, & &1.key)), + blockhash, + CompactArray.to_iolist(encode_instructions(ixs, accounts)), + CompactArray.to_iolist(compiled_lookup_tables) + ] + |> :erlang.list_to_binary() + end + # https://docs.solana.com/developing/programming-model/transactions#message-header-format defp create_header(accounts) do accounts diff --git a/test/solana/lookup_table_test.exs b/test/solana/lookup_table_test.exs new file mode 100644 index 0000000..b852534 --- /dev/null +++ b/test/solana/lookup_table_test.exs @@ -0,0 +1,207 @@ +defmodule Solana.LookupTableTest do + use ExUnit.Case, async: true + + import Solana.TestHelpers, only: [create_payer: 3] + import Solana, only: [pubkey!: 1] + + alias Solana.{SystemProgram, LookupTable, RPC, Transaction} + + setup_all do + {:ok, tracker} = RPC.Tracker.start_link(network: "localhost", t: 100) + client = RPC.client(network: "localhost") + {:ok, payer} = create_payer(tracker, client, commitment: "confirmed") + + [tracker: tracker, client: client, payer: payer] + end + + describe "create_lookup_table/1" do + test "can create an address lookup table", %{tracker: tracker, client: client, payer: payer} do + new = Solana.keypair() + + tx_reqs = [ + RPC.Request.get_latest_blockhash(commitment: "confirmed"), + RPC.Request.get_slot(commitment: "finalized") + ] + + [{:ok, %{"blockhash" => blockhash}}, {:ok, slot}] = RPC.send(client, tx_reqs) + + {create_lookup_table_ix, lookup_table} = Solana.LookupTable.create_lookup_table( + authority: pubkey!(new), + payer: pubkey!(payer), + recent_slot: slot + ) + + tx = %Transaction{ + instructions: [create_lookup_table_ix], + signers: [payer], + blockhash: blockhash, + payer: pubkey!(payer) + } + + {:ok, _signature} = + RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) + + assert {:ok, %{}} = + RPC.send( + client, + RPC.Request.get_account_info(lookup_table, + commitment: "confirmed", + encoding: "jsonParsed" + ) + ) + end + end + + describe "extend_lookup_table/1" do + test "can extend an address lookup table", %{tracker: tracker, client: client, payer: payer} do + new = Solana.keypair() + + tx_reqs = [ + RPC.Request.get_latest_blockhash(commitment: "confirmed"), + RPC.Request.get_slot(commitment: "finalized") + ] + + [{:ok, %{"blockhash" => blockhash}}, {:ok, slot}] = RPC.send(client, tx_reqs) + + {create_lookup_table_ix, lookup_table} = Solana.LookupTable.create_lookup_table( + authority: pubkey!(new), + payer: pubkey!(payer), + recent_slot: slot + ) + + tx = %Transaction{ + instructions: [ + create_lookup_table_ix, + LookupTable.extend_lookup_table( + lookup_table: lookup_table, + authority: pubkey!(new), + payer: pubkey!(payer), + new_keys: [SystemProgram.id()] + ) + ], + signers: [new, payer], + blockhash: blockhash, + payer: pubkey!(payer) + } + + {:ok, _signature} = + RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) + + {:ok, info} = + RPC.send( + client, + RPC.Request.get_account_info(lookup_table, + commitment: "confirmed", + encoding: "jsonParsed" + ) + ) + + assert LookupTable.from_account_info(info).keys == [SystemProgram.id()] + end + end + + describe "feeze_lookup_table/1" do + test "can freeze an address lookup table", %{tracker: tracker, client: client, payer: payer} do + new = Solana.keypair() + + tx_reqs = [ + RPC.Request.get_latest_blockhash(commitment: "confirmed"), + RPC.Request.get_slot(commitment: "finalized") + ] + + [{:ok, %{"blockhash" => blockhash}}, {:ok, slot}] = RPC.send(client, tx_reqs) + + {create_lookup_table_ix, lookup_table} = Solana.LookupTable.create_lookup_table( + authority: pubkey!(new), + payer: pubkey!(payer), + recent_slot: slot + ) + + tx = %Transaction{ + instructions: [ + create_lookup_table_ix, + LookupTable.extend_lookup_table( + lookup_table: lookup_table, + authority: pubkey!(new), + payer: pubkey!(payer), + new_keys: [SystemProgram.id()] + ), + LookupTable.freeze_lookup_table( + lookup_table: lookup_table, + authority: pubkey!(new) + ) + ], + signers: [new, payer], + blockhash: blockhash, + payer: pubkey!(payer) + } + + {:ok, _signature} = + RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) + + {:ok, info} = + RPC.send( + client, + RPC.Request.get_account_info(lookup_table, + commitment: "confirmed", + encoding: "jsonParsed" + ) + ) + + assert LookupTable.from_account_info(info).authority == nil + end + end + + describe "deactivate_lookup_table/1" do + test "can deactivate an address lookup table", %{tracker: tracker, client: client, payer: payer} do + new = Solana.keypair() + + tx_reqs = [ + RPC.Request.get_latest_blockhash(commitment: "confirmed"), + RPC.Request.get_slot(commitment: "finalized") + ] + + [{:ok, %{"blockhash" => blockhash}}, {:ok, slot}] = RPC.send(client, tx_reqs) + + {create_lookup_table_ix, lookup_table} = Solana.LookupTable.create_lookup_table( + authority: pubkey!(new), + payer: pubkey!(payer), + recent_slot: slot + ) + + tx = %Transaction{ + instructions: [ + create_lookup_table_ix, + LookupTable.extend_lookup_table( + lookup_table: lookup_table, + authority: pubkey!(new), + payer: pubkey!(payer), + new_keys: [SystemProgram.id()] + ), + LookupTable.deactivate_lookup_table( + lookup_table: lookup_table, + authority: pubkey!(new) + ) + ], + signers: [new, payer], + blockhash: blockhash, + payer: pubkey!(payer) + } + + {:ok, _signature} = + RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) + + {:ok, info} = + RPC.send( + client, + RPC.Request.get_account_info(lookup_table, + commitment: "confirmed", + encoding: "jsonParsed" + ) + ) + + # When deactivated, the deactivation slot is set to the maximum unsigned 64-bit integer value + assert LookupTable.from_account_info(info).deactivation_slot != (:math.pow(2, 64) |> trunc()) - 1 + end + end +end From bf14adc2276d4c8225c1a5d55ed82ec36b9dade1 Mon Sep 17 00:00:00 2001 From: Jochem Arends <77842807+jochemarends@users.noreply.github.com.> Date: Sun, 22 Feb 2026 16:48:11 +0100 Subject: [PATCH 2/4] incorporate feedback and fix options for `Solana.TestValidator` - Change the default value for the `:bind_address` option of `Solana.TestValidator` to match the `--bind-address` option of `solana-test-validator` version 3.1.8. - Remove the `:gossip_host` option for `Solana.TestValidator` as it's not supported in version 3.1.8 of `solana-test-validator`. `--bind-address` now controls the Gossip port. - Add `:authority_should_sign?` option for `LookupTable.create_lookup_table/1` - Add test for `LookupTable.close_lookup_table/1`. - In the test for `LookupTable.deactivate_lookup_table/1` extend the lookup table without a payer to mimic the web3.js test suite. --- lib/solana/account.ex | 2 - lib/solana/lookup_table.ex | 120 +++++++++++-------- lib/solana/test/validator.ex | 3 +- lib/solana/tx.ex | 81 +++++++------ test/solana/lookup_table_test.exs | 191 ++++++++++++++++++++++++------ 5 files changed, 271 insertions(+), 126 deletions(-) diff --git a/lib/solana/account.ex b/lib/solana/account.ex index a75ff43..bfc630d 100644 --- a/lib/solana/account.ex +++ b/lib/solana/account.ex @@ -10,7 +10,6 @@ defmodule Solana.Account do @type t :: %__MODULE__{ signer?: boolean(), writable?: boolean(), - invoked?: boolean(), key: Solana.key() | nil } @@ -18,6 +17,5 @@ defmodule Solana.Account do :key, signer?: false, writable?: false, - invoked?: false ] end diff --git a/lib/solana/lookup_table.ex b/lib/solana/lookup_table.ex index 4391937..de9c0bf 100644 --- a/lib/solana/lookup_table.ex +++ b/lib/solana/lookup_table.ex @@ -9,12 +9,12 @@ defmodule Solana.LookupTable do @typedoc "Address lookup table account metadata." @type t :: %__MODULE__{ - authority: Key.t() | nil, - keys: [Key.t()], - deactivation_slot: non_neg_integer(), - last_extended_slot: non_neg_integer(), - last_extended_slot_start_index: non_neg_integer() - } + authority: Key.t() | nil, + keys: [Key.t()], + deactivation_slot: non_neg_integer(), + last_extended_slot: non_neg_integer(), + last_extended_slot_start_index: non_neg_integer() + } defstruct [ :authority, @@ -53,15 +53,21 @@ defmodule Solana.LookupTable do def from_account_info(_), do: :error - defp from_lookup_table_account_info(%{ - "addresses" => keys, - "deactivationSlot" => deactivation_slot, - "lastExtendedSlot" => last_extended_slot, - "lastExtendedSlotStartIndex" => last_extended_slot_start_index - } = info) do - authority = with encoded when encoded != nil <- Map.get(info, "authority") do - Solana.pubkey!(encoded) - end + defp from_lookup_table_account_info(%{"authority" => authority} = info) do + from_lookup_table_account_info(info, Solana.pubkey!(authority)) + end + + defp from_lookup_table_account_info(info), do: from_lookup_table_account_info(info, nil) + + defp from_lookup_table_account_info( + %{ + "addresses" => keys, + "deactivationSlot" => deactivation_slot, + "lastExtendedSlot" => last_extended_slot, + "lastExtendedSlotStartIndex" => last_extended_slot_start_index + }, + authority + ) do %__MODULE__{ authority: authority, keys: Enum.map(keys, &Solana.pubkey!/1), @@ -71,8 +77,6 @@ defmodule Solana.LookupTable do } end - defp from_lookup_table_account_info(_), do: :error - @create_lookup_table_schema [ authority: [ type: {:custom, Solana.Key, :check, []}, @@ -93,6 +97,12 @@ defmodule Solana.LookupTable do type: :non_neg_integer, required: true, doc: "A recent slot must be used in the derivation path for each initialized table" + ], + authority_should_sign?: [ + type: :boolean, + default: false, + doc: + "Whether the authority should be a signer, which was required before version 1.1 of the address lookup table program" ] ] @doc """ @@ -112,13 +122,17 @@ defmodule Solana.LookupTable do end defp create_lookup_table_ix(params) do - with {:ok, lookup_table, bump_seed} - <- find_address(params.authority, params.recent_slot) do + with {:ok, lookup_table, bump_seed} <- + find_address(params.authority, params.recent_slot) do ix = %Instruction{ program: id(), accounts: [ %Account{key: lookup_table, signer?: false, writable?: true}, - %Account{key: params.authority, signer?: false, writable?: false}, + %Account{ + key: params.authority, + signer?: params.authority_should_sign?, + writable?: false + }, %Account{key: params.payer, signer?: true, writable?: true}, %Account{key: SystemProgram.id(), signer?: false, writable?: false} ], @@ -129,6 +143,7 @@ defmodule Solana.LookupTable do {bump_seed, 8} ]) } + {ix, lookup_table} end end @@ -164,11 +179,10 @@ defmodule Solana.LookupTable do defp freeze_lookup_table_ix(params) do %Instruction{ program: id(), - accounts: - List.flatten([ - %Account{key: params.lookup_table, signer?: false, writable?: true}, - %Account{key: params.authority, signer?: true, writable?: false}, - ]), + accounts: [ + %Account{key: params.lookup_table, signer?: false, writable?: true}, + %Account{key: params.authority, signer?: true, writable?: false} + ], data: Instruction.encode_data([{1, 32}]) } end @@ -211,23 +225,31 @@ defmodule Solana.LookupTable do defp extend_lookup_table_ix(params) do %Instruction{ program: id(), - accounts: - List.flatten([ - %Account{key: params.lookup_table, signer?: false, writable?: true}, - %Account{key: params.authority, signer?: true, writable?: false}, - if(params[:payer], do: [ - %Account{key: params.payer, signer?: true, writable?: true}, - %Account{key: SystemProgram.id(), signer?: false, writable?: false} - ], else: []) - ]), - data: Instruction.encode_data(List.flatten([ - {2, 32}, - {length(params.new_keys), 64}, - params.new_keys - ])) + accounts: [ + %Account{key: params.lookup_table, signer?: false, writable?: true}, + %Account{key: params.authority, signer?: true, writable?: false} + | payer_keys(params) + ], + data: + Instruction.encode_data([ + {2, 32}, + {length(params.new_keys), 64} + | params.new_keys + ]) } end + defp payer_keys(%{payer: nil}), do: [] + + defp payer_keys(%{payer: payer}) do + [ + %Account{key: payer, signer?: true, writable?: true}, + %Account{key: SystemProgram.id(), signer?: false, writable?: false} + ] + end + + defp payer_keys(_), do: [] + @deactivate_lookup_table_schema [ lookup_table: [ type: {:custom, Solana.Key, :check, []}, @@ -261,11 +283,10 @@ defmodule Solana.LookupTable do defp deactivate_lookup_table_ix(params) do %Instruction{ program: id(), - accounts: - List.flatten([ - %Account{key: params.lookup_table, signer?: false, writable?: true}, - %Account{key: params.authority, signer?: true, writable?: false}, - ]), + accounts: [ + %Account{key: params.lookup_table, signer?: false, writable?: true}, + %Account{key: params.authority, signer?: true, writable?: false} + ], data: Instruction.encode_data([{3, 32}]) } end @@ -303,12 +324,11 @@ defmodule Solana.LookupTable do defp close_lookup_table_ix(params) do %Instruction{ program: id(), - accounts: - List.flatten([ - %Account{key: params.lookup_table, signer?: false, writable?: true}, - %Account{key: params.authority, signer?: true, writable?: false}, - %Account{key: params.recipient, signer?: false, writable?: true}, - ]), + accounts: [ + %Account{key: params.lookup_table, signer?: false, writable?: true}, + %Account{key: params.authority, signer?: true, writable?: true}, + %Account{key: params.recipient, signer?: false, writable?: true} + ], data: Instruction.encode_data([{4, 32}]) } end diff --git a/lib/solana/test/validator.ex b/lib/solana/test/validator.ex index 97a989c..a2c4f32 100644 --- a/lib/solana/test/validator.ex +++ b/lib/solana/test/validator.ex @@ -75,14 +75,13 @@ defmodule Solana.TestValidator do require Logger @schema [ - bind_address: [type: :string, default: "0.0.0.0"], + bind_address: [type: :string, default: "127.0.0.1"], bpf_program: [type: {:or, [:string, {:list, :string}]}], clone: [type: {:custom, Solana, :pubkey, []}], config: [type: :string, default: Path.expand("~/.config/solana/cli/config.yml")], dynamic_port_range: [type: :string, default: "8000-10000"], faucet_port: [type: :pos_integer, default: 9900], faucet_sol: [type: :pos_integer, default: 1_000_000], - gossip_host: [type: :string, default: "127.0.0.1"], gossip_port: [type: :pos_integer], url: [type: :string], ledger: [type: :string, default: "test-ledger"], diff --git a/lib/solana/tx.ex b/lib/solana/tx.ex index 1b86e5d..40a757d 100644 --- a/lib/solana/tx.ex +++ b/lib/solana/tx.ex @@ -147,7 +147,7 @@ defmodule Solana.Transaction do # https://docs.solana.com/developing/programming-model/transactions#account-addresses-format defp compile_accounts(ixs, payer) do ixs - |> Enum.map(fn ix -> [%Account{key: ix.program, invoked?: true} | ix.accounts] end) + |> Enum.map(fn ix -> [%Account{key: ix.program} | ix.accounts] end) |> List.flatten() |> Enum.reject(&(&1.key == payer)) |> Enum.sort_by(&{&1.signer?, &1.writable?}, &>=/2) @@ -174,59 +174,59 @@ defmodule Solana.Transaction do defp encode_version(0), do: <<0x80>> # https://docs.anza.xyz/proposals/versioned-transactions#versioned-transactions - defp compile_lookup_table(accounts, {lookup_table, keys}) do + defp compile_lookup_table(accounts, invoked_accounts, {lookup_table, keys}) do # Accounts that are signers, invoked, or not present in the lookup table # must be stored in the message itself {static_accounts, lookup_accounts} = accounts - |> Enum.split_with(& &1.key not in keys or &1.signer? or &1.invoked?) + |> Enum.split_with(&(&1.key not in keys or &1.signer? or &1 in invoked_accounts)) {writable_indices, readonly_indices} = - lookup_accounts - |> Enum.map(fn %Account{key: key} = account -> - keys - |> Enum.find_index(& &1 == key) - |> then(&{account, &1}) - end) - |> Enum.split_with(fn {account, _index} -> account.writable? end) - |> then(fn {writable_accounts_with_index, readonly_accounts_with_index} -> - { - writable_accounts_with_index |> Enum.map(fn {_account, index} -> index end), - readonly_accounts_with_index |> Enum.map(fn {_account, index} -> index end) - } - end) + Enum.reduce( + lookup_accounts, + {[], []}, + fn %Account{key: key, writable?: writable?}, {writable, readonly} -> + index = Enum.find_index(keys, &(&1 == key)) + + cond do + writable? -> {[index | writable], readonly} + true -> {writable, [index | readonly]} + end + end + ) compiled_lookup_table = [ lookup_table, - CompactArray.to_iolist(writable_indices), - CompactArray.to_iolist(readonly_indices) + CompactArray.to_iolist(writable_indices |> Enum.reverse()), + CompactArray.to_iolist(readonly_indices |> Enum.reverse()) ] |> :erlang.list_to_binary() {static_accounts, lookup_accounts, compiled_lookup_table} end - defp compile_lookup_tables(accounts, lookup_tables) do - lookup_tables - |> Enum.reduce({accounts, [], []}, fn lookup_table, acc -> - {static_accounts, lookup_accounts, compiled_lookup_table} = - compile_lookup_table(elem(acc, 0), lookup_table) - { - static_accounts, - [lookup_accounts | elem(acc, 1)], - [compiled_lookup_table | elem(acc, 2)] - } - end) - |> then(fn {_, lookup_accounts, compiled_lookup_tables} = result -> - result - |> put_elem(1, lookup_accounts |> Enum.reverse() |> List.flatten()) - |> put_elem(2, compiled_lookup_tables |> Enum.reverse()) - end) + defp compile_lookup_tables(accounts, invoked_accounts, lookup_tables) do + {static_accounts, lookup_accounts, compiled_tables} = + Enum.reduce( + lookup_tables, + {accounts, [], []}, + fn table, {static, lookup_acc, compiled_acc} -> + {static, lookup, compiled} = compile_lookup_table(static, invoked_accounts, table) + {static, [lookup | lookup_acc], [compiled | compiled_acc]} + end + ) + + { + static_accounts, + lookup_accounts |> Enum.reverse() |> List.flatten(), + compiled_tables |> Enum.reverse() + } end # https://docs.solana.com/developing/programming-model/transactions#message-format - defp encode_message(accounts, blockhash, ixs, %{} = _lookup_tables) do + defp encode_message(accounts, blockhash, ixs, lookup_tables) + when map_size(lookup_tables) == 0 do [ create_header(accounts), CompactArray.to_iolist(Enum.map(accounts, & &1.key)), @@ -237,17 +237,20 @@ defmodule Solana.Transaction do end defp encode_message(accounts, blockhash, ixs, lookup_tables) do - {static_accounts, lookup_accounts, compiled_lookup_tables} = - compile_lookup_tables(accounts, lookup_tables) + invoked_accounts = + ixs + |> Enum.map(& &1.program) + |> Enum.reject(&is_nil/1) - accounts = static_accounts ++ lookup_accounts + {static_accounts, lookup_accounts, compiled_lookup_tables} = + compile_lookup_tables(accounts, invoked_accounts, lookup_tables) [ encode_version(0), create_header(static_accounts), CompactArray.to_iolist(Enum.map(static_accounts, & &1.key)), blockhash, - CompactArray.to_iolist(encode_instructions(ixs, accounts)), + CompactArray.to_iolist(encode_instructions(ixs, static_accounts + lookup_accounts)), CompactArray.to_iolist(compiled_lookup_tables) ] |> :erlang.list_to_binary() diff --git a/test/solana/lookup_table_test.exs b/test/solana/lookup_table_test.exs index b852534..3ba97a8 100644 --- a/test/solana/lookup_table_test.exs +++ b/test/solana/lookup_table_test.exs @@ -25,11 +25,12 @@ defmodule Solana.LookupTableTest do [{:ok, %{"blockhash" => blockhash}}, {:ok, slot}] = RPC.send(client, tx_reqs) - {create_lookup_table_ix, lookup_table} = Solana.LookupTable.create_lookup_table( - authority: pubkey!(new), - payer: pubkey!(payer), - recent_slot: slot - ) + {create_lookup_table_ix, lookup_table} = + LookupTable.create_lookup_table( + authority: pubkey!(new), + payer: pubkey!(payer), + recent_slot: slot + ) tx = %Transaction{ instructions: [create_lookup_table_ix], @@ -42,13 +43,13 @@ defmodule Solana.LookupTableTest do RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) assert {:ok, %{}} = - RPC.send( - client, - RPC.Request.get_account_info(lookup_table, - commitment: "confirmed", - encoding: "jsonParsed" - ) - ) + RPC.send( + client, + RPC.Request.get_account_info(lookup_table, + commitment: "confirmed", + encoding: "jsonParsed" + ) + ) end end @@ -63,11 +64,12 @@ defmodule Solana.LookupTableTest do [{:ok, %{"blockhash" => blockhash}}, {:ok, slot}] = RPC.send(client, tx_reqs) - {create_lookup_table_ix, lookup_table} = Solana.LookupTable.create_lookup_table( - authority: pubkey!(new), - payer: pubkey!(payer), - recent_slot: slot - ) + {create_lookup_table_ix, lookup_table} = + LookupTable.create_lookup_table( + authority: pubkey!(new), + payer: pubkey!(payer), + recent_slot: slot + ) tx = %Transaction{ instructions: [ @@ -100,7 +102,7 @@ defmodule Solana.LookupTableTest do end end - describe "feeze_lookup_table/1" do + describe "freeze_lookup_table/1" do test "can freeze an address lookup table", %{tracker: tracker, client: client, payer: payer} do new = Solana.keypair() @@ -111,11 +113,12 @@ defmodule Solana.LookupTableTest do [{:ok, %{"blockhash" => blockhash}}, {:ok, slot}] = RPC.send(client, tx_reqs) - {create_lookup_table_ix, lookup_table} = Solana.LookupTable.create_lookup_table( - authority: pubkey!(new), - payer: pubkey!(payer), - recent_slot: slot - ) + {create_lookup_table_ix, lookup_table} = + LookupTable.create_lookup_table( + authority: pubkey!(new), + payer: pubkey!(payer), + recent_slot: slot + ) tx = %Transaction{ instructions: [ @@ -153,29 +156,42 @@ defmodule Solana.LookupTableTest do end describe "deactivate_lookup_table/1" do - test "can deactivate an address lookup table", %{tracker: tracker, client: client, payer: payer} do + test "can deactivate an address lookup table", %{ + tracker: tracker, + client: client, + payer: payer + } do new = Solana.keypair() tx_reqs = [ + RPC.Request.get_minimum_balance_for_rent_exemption(LookupTable.byte_size(2), + commitment: "confirmed" + ), RPC.Request.get_latest_blockhash(commitment: "confirmed"), RPC.Request.get_slot(commitment: "finalized") ] - [{:ok, %{"blockhash" => blockhash}}, {:ok, slot}] = RPC.send(client, tx_reqs) + [{:ok, lamports}, {:ok, %{"blockhash" => blockhash}}, {:ok, slot}] = + RPC.send(client, tx_reqs) - {create_lookup_table_ix, lookup_table} = Solana.LookupTable.create_lookup_table( - authority: pubkey!(new), - payer: pubkey!(payer), - recent_slot: slot - ) + {create_lookup_table_ix, lookup_table} = + LookupTable.create_lookup_table( + authority: pubkey!(new), + payer: pubkey!(payer), + recent_slot: slot + ) tx = %Transaction{ instructions: [ create_lookup_table_ix, + SystemProgram.transfer( + lamports: lamports, + from: pubkey!(payer), + to: lookup_table + ), LookupTable.extend_lookup_table( lookup_table: lookup_table, authority: pubkey!(new), - payer: pubkey!(payer), new_keys: [SystemProgram.id()] ), LookupTable.deactivate_lookup_table( @@ -200,8 +216,117 @@ defmodule Solana.LookupTableTest do ) ) - # When deactivated, the deactivation slot is set to the maximum unsigned 64-bit integer value - assert LookupTable.from_account_info(info).deactivation_slot != (:math.pow(2, 64) |> trunc()) - 1 + %{deactivation_slot: deactivation_slot} = LookupTable.from_account_info(info) + + # When deactivated, the deactivation slot is set to the maximum unsigned + # 64-bit integer value + assert deactivation_slot != 2 ** 64 - 1 + end + end + + describe "close_lookup_table/1" do + # 5 Minutes + @tag timeout: 5 * 60_000 + test "can close an address lookup table", %{tracker: tracker, client: client, payer: payer} do + new = Solana.keypair() + + tx_reqs = [ + RPC.Request.get_minimum_balance_for_rent_exemption(LookupTable.byte_size(2), + commitment: "confirmed" + ), + RPC.Request.get_latest_blockhash(commitment: "confirmed"), + RPC.Request.get_slot(commitment: "finalized") + ] + + [{:ok, lamports}, {:ok, %{"blockhash" => blockhash}}, {:ok, slot}] = + RPC.send(client, tx_reqs) + + {create_lookup_table_ix, lookup_table} = + LookupTable.create_lookup_table( + authority: pubkey!(new), + payer: pubkey!(payer), + recent_slot: slot + ) + + tx = %Transaction{ + instructions: [ + create_lookup_table_ix, + SystemProgram.transfer( + lamports: lamports, + from: pubkey!(payer), + to: lookup_table + ), + LookupTable.extend_lookup_table( + lookup_table: lookup_table, + authority: pubkey!(new), + new_keys: [SystemProgram.id()] + ), + LookupTable.deactivate_lookup_table( + lookup_table: lookup_table, + authority: pubkey!(new) + ) + ], + signers: [new, payer], + blockhash: blockhash, + payer: pubkey!(payer) + } + + {:ok, _signature} = + RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) + + {:ok, info} = + RPC.send( + client, + RPC.Request.get_account_info(lookup_table, + commitment: "confirmed", + encoding: "jsonParsed" + ) + ) + + %{deactivation_slot: deactivation_slot} = LookupTable.from_account_info(info) + + # When deactivated, the deactivation slot is set to the maximum unsigned + # 64-bit integer value + assert deactivation_slot != 2 ** 64 - 1 + + # A lookup tables can only be closed once its deactivation slot is no + # longer present in the SlotHashes sysvar + recent_slots = + Stream.repeatedly(fn -> + Process.sleep(1_000) + + {:ok, %{"data" => %{"parsed" => %{"info" => info}}}} = + RPC.send( + client, + RPC.Request.get_account_info(pubkey!("SysvarS1otHashes111111111111111111111111111"), + commitment: "confirmed", + encoding: "jsonParsed" + ) + ) + + Enum.map(info, & &1["slot"]) + end) + + Enum.find(recent_slots, &(deactivation_slot not in &1)) + + {:ok, %{"blockhash" => blockhash}} = + RPC.send(client, RPC.Request.get_latest_blockhash(commitment: "confirmed")) + + tx = %Transaction{ + instructions: [ + LookupTable.close_lookup_table( + lookup_table: lookup_table, + authority: pubkey!(new), + recipient: pubkey!(new) + ) + ], + signers: [new, payer], + blockhash: blockhash, + payer: pubkey!(payer) + } + + {:ok, _signature} = + RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) end end end From 94343eaba25de8bb22d50b35c2d1700841b2d393 Mon Sep 17 00:00:00 2001 From: Jochem Arends <77842807+jochemarends@users.noreply.github.com.> Date: Sun, 22 Feb 2026 19:09:19 +0100 Subject: [PATCH 3/4] use `MapSet` for tracking invoked accounts and remove trailing comma --- lib/solana/account.ex | 2 +- lib/solana/tx.ex | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/solana/account.ex b/lib/solana/account.ex index bfc630d..91b5eb4 100644 --- a/lib/solana/account.ex +++ b/lib/solana/account.ex @@ -16,6 +16,6 @@ defmodule Solana.Account do defstruct [ :key, signer?: false, - writable?: false, + writable?: false ] end diff --git a/lib/solana/tx.ex b/lib/solana/tx.ex index 40a757d..28f715f 100644 --- a/lib/solana/tx.ex +++ b/lib/solana/tx.ex @@ -237,10 +237,7 @@ defmodule Solana.Transaction do end defp encode_message(accounts, blockhash, ixs, lookup_tables) do - invoked_accounts = - ixs - |> Enum.map(& &1.program) - |> Enum.reject(&is_nil/1) + invoked_accounts = for ix <- ixs, !is_nil(ix.program), into: MapSet.new(), do: program {static_accounts, lookup_accounts, compiled_lookup_tables} = compile_lookup_tables(accounts, invoked_accounts, lookup_tables) From 9d603761a7cf8402f02a608716d3940867c9da09 Mon Sep 17 00:00:00 2001 From: Jochem Arends <77842807+jochemarends@users.noreply.github.com.> Date: Mon, 23 Feb 2026 13:43:30 +0100 Subject: [PATCH 4/4] fix bugs --- lib/solana/tx.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/solana/tx.ex b/lib/solana/tx.ex index 28f715f..5e120ce 100644 --- a/lib/solana/tx.ex +++ b/lib/solana/tx.ex @@ -237,7 +237,7 @@ defmodule Solana.Transaction do end defp encode_message(accounts, blockhash, ixs, lookup_tables) do - invoked_accounts = for ix <- ixs, !is_nil(ix.program), into: MapSet.new(), do: program + invoked_accounts = for ix <- ixs, !is_nil(ix.program), into: MapSet.new(), do: ix.program {static_accounts, lookup_accounts, compiled_lookup_tables} = compile_lookup_tables(accounts, invoked_accounts, lookup_tables) @@ -247,7 +247,7 @@ defmodule Solana.Transaction do create_header(static_accounts), CompactArray.to_iolist(Enum.map(static_accounts, & &1.key)), blockhash, - CompactArray.to_iolist(encode_instructions(ixs, static_accounts + lookup_accounts)), + CompactArray.to_iolist(encode_instructions(ixs, static_accounts ++ lookup_accounts)), CompactArray.to_iolist(compiled_lookup_tables) ] |> :erlang.list_to_binary()