diff --git a/lib/solana/lookup_table.ex b/lib/solana/lookup_table.ex new file mode 100644 index 0000000..de9c0bf --- /dev/null +++ b/lib/solana/lookup_table.ex @@ -0,0 +1,335 @@ +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(%{"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), + 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 + + @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" + ], + 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 """ + 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?: params.authority_should_sign?, + 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: [ + %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: [ + %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, []}, + 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: [ + %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: [ + %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 +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/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 43a7b4f..5e120ce 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 @@ -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, 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 in invoked_accounts)) + + {writable_indices, readonly_indices} = + 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 |> 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, 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) 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)), @@ -172,6 +236,23 @@ defmodule Solana.Transaction do |> :erlang.list_to_binary() end + defp encode_message(accounts, blockhash, ixs, lookup_tables) do + 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) + + [ + encode_version(0), + 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(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..3ba97a8 --- /dev/null +++ b/test/solana/lookup_table_test.exs @@ -0,0 +1,332 @@ +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} = + 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} = + 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 "freeze_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} = + 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_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 + 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