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