├── test ├── support │ ├── invalid2.json │ ├── wallet.json │ ├── invalid1.json │ └── test_helpers.ex ├── solana_test.exs ├── test_helper.exs └── solana │ ├── compact_array_test.exs │ ├── key_test.exs │ ├── system_program │ └── nonce_test.exs │ ├── tx_test.exs │ └── system_program_test.exs ├── .formatter.exs ├── lib ├── solana │ ├── test │ │ ├── bin │ │ │ └── wrapper-unix │ │ └── validator.ex │ ├── account.ex │ ├── helpers.ex │ ├── ix.ex │ ├── compact_array.ex │ ├── rpc │ │ ├── middleware.ex │ │ ├── tracker.ex │ │ └── request.ex │ ├── key.ex │ ├── rpc.ex │ ├── system_program │ │ └── nonce.ex │ ├── system_program.ex │ └── tx.ex └── solana.ex ├── .gitignore ├── LICENSE ├── mix.exs ├── README.md └── mix.lock /test/support/invalid2.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /test/solana_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SolanaTest do 2 | use ExUnit.Case 3 | end 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | alias Solana.TestValidator 2 | {:ok, validator} = TestValidator.start_link(ledger: "/tmp/test-ledger") 3 | ExUnit.after_suite(fn _ -> TestValidator.stop(validator) end) 4 | ExUnit.start() 5 | -------------------------------------------------------------------------------- /test/support/wallet.json: -------------------------------------------------------------------------------- 1 | [15,71,250,29,115,195,43,15,127,223,205,118,122,34,251,10,145,145,226,10,251,179,187,167,178,25,249,98,144,174,233,161,11,193,175,99,126,187,46,212,170,91,91,230,255,102,225,128,42,255,58,1,3,187,219,93,51,1,22,255,237,169,42,40] -------------------------------------------------------------------------------- /test/support/invalid1.json: -------------------------------------------------------------------------------- 1 | [15,71,250,29,115,195,43,15,127,223,205,118,122,34,251,10,145,145,226,10,251,179,187,167,178,25,249,98,144,174,233,161,11,193,175,99,126,187,46,212,170,91,91,230,255,102,225,128,42,255,58,1,3,187,219,93,51,1,22,255,237,169,42] 2 | -------------------------------------------------------------------------------- /lib/solana/test/bin/wrapper-unix: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Start the program in the background 4 | exec "$@" & 5 | pid1=$! 6 | 7 | # Silence warnings from here on 8 | exec >/dev/null 2>&1 9 | 10 | # Read from stdin in the background and 11 | # kill running program when stdin closes 12 | exec 0<&0 $( 13 | while read; do :; done 14 | kill -KILL $pid1 15 | ) & 16 | pid2=$! 17 | 18 | # Clean up 19 | wait $pid1 20 | ret=$? 21 | kill -KILL $pid2 22 | exit $ret 23 | -------------------------------------------------------------------------------- /lib/solana/account.ex: -------------------------------------------------------------------------------- 1 | defmodule Solana.Account do 2 | @moduledoc """ 3 | Functions, types, and structures related to Solana 4 | [accounts](https://docs.solana.com/developing/programming-model/accounts). 5 | """ 6 | 7 | @typedoc """ 8 | All the information needed to encode an account in a transaction message. 9 | """ 10 | @type t :: %__MODULE__{ 11 | signer?: boolean(), 12 | writable?: boolean(), 13 | key: Solana.key() | nil 14 | } 15 | 16 | defstruct [ 17 | :key, 18 | signer?: false, 19 | writable?: false 20 | ] 21 | end 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | solana-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /test/solana/compact_array_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solana.CompactArrayTest do 2 | use ExUnit.Case 3 | 4 | alias Solana.CompactArray, as: C 5 | 6 | test "encodes" do 7 | [ 8 | {0, [0]}, 9 | {5, [5]}, 10 | {0x7F, [0x7F]}, 11 | {0x80, [0x80, 0x01]}, 12 | {0xFF, [0xFF, 0x01]}, 13 | {0x100, [0x80, 0x02]}, 14 | {0x7FFF, [0xFF, 0xFF, 0x01]}, 15 | {0x200000, [0x80, 0x80, 0x80, 0x01]} 16 | ] 17 | |> Enum.each(fn {length, expected} -> 18 | assert C.encode_length(length) == expected 19 | end) 20 | end 21 | 22 | test "decodes" do 23 | [0, 5, 0x7F, 0x80, 0xFF, 0x100, 0x7FFF, 0x200000] 24 | |> Enum.each(fn length -> 25 | assert C.decode_length(C.encode_length(length)) == length 26 | end) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/solana/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Solana.Helpers do 2 | @moduledoc false 3 | def validate(params, schema) do 4 | case NimbleOptions.validate(params, schema) do 5 | {:ok, validated} -> {:ok, Enum.into(validated, %{})} 6 | error -> error 7 | end 8 | end 9 | 10 | def chunk(string, size), do: chunk(string, size, []) 11 | 12 | def chunk(<<>>, _size, acc), do: Enum.reverse(acc) 13 | 14 | def chunk(string, [size | sizes], acc) when byte_size(string) > size do 15 | <> = string 16 | chunk(rest, sizes, [c | acc]) 17 | end 18 | 19 | def chunk(string, size, acc) when byte_size(string) > size do 20 | <> = string 21 | chunk(rest, size, [c | acc]) 22 | end 23 | 24 | def chunk(leftover, size, acc) do 25 | chunk(<<>>, size, [leftover | acc]) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2021 Derek Meer 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the “Software”), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /test/support/test_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Solana.TestHelpers do 2 | @moduledoc """ 3 | Some helper functions for testing Solana programs. 4 | """ 5 | alias Solana.RPC 6 | 7 | @doc """ 8 | Creates an account and airdrops some SOL to it. This is useful when creating 9 | other accounts and you need an account to pay the rent fees. 10 | """ 11 | @spec create_payer(tracker :: pid, Solana.RPC.client(), keyword) :: 12 | {:ok, Solana.keypair()} | {:error, :timeout} 13 | def create_payer(tracker, client, opts \\ []) do 14 | payer = Solana.keypair() 15 | 16 | sol = Keyword.get(opts, :amount, 5) 17 | timeout = Keyword.get(opts, :timeout, 5_000) 18 | request_opts = Keyword.take(opts, [:commitment]) 19 | 20 | {:ok, tx} = 21 | RPC.send(client, RPC.Request.request_airdrop(Solana.pubkey!(payer), sol, request_opts)) 22 | 23 | :ok = RPC.Tracker.start_tracking(tracker, tx, request_opts) 24 | 25 | receive do 26 | {:ok, [^tx]} -> {:ok, payer} 27 | after 28 | timeout -> {:error, :timeout} 29 | end 30 | end 31 | 32 | @doc """ 33 | Generates a list of `n` keypairs. 34 | """ 35 | @spec keypairs(n :: pos_integer) :: [Solana.keypair()] 36 | def keypairs(n) do 37 | Enum.map(1..n, fn _ -> Solana.keypair() end) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/solana/ix.ex: -------------------------------------------------------------------------------- 1 | defmodule Solana.Instruction do 2 | @moduledoc """ 3 | Functions, types, and structures related to Solana 4 | [instructions](https://docs.solana.com/developing/programming-model/transactions#instructions). 5 | """ 6 | alias Solana.Account 7 | 8 | @typedoc """ 9 | All the details needed to encode an instruction. 10 | """ 11 | @type t :: %__MODULE__{ 12 | program: Solana.key() | nil, 13 | accounts: [Account.t()], 14 | data: binary | nil 15 | } 16 | 17 | defstruct [ 18 | :data, 19 | :program, 20 | accounts: [] 21 | ] 22 | 23 | @doc false 24 | def encode_data(data) do 25 | Enum.into(data, <<>>, &encode_value/1) 26 | end 27 | 28 | # encodes a string in Rust's expected format 29 | defp encode_value({value, "str"}) when is_binary(value) do 30 | <> 31 | end 32 | 33 | # encodes a string in Borsh's expected format 34 | # https://borsh.io/#pills-specification 35 | defp encode_value({value, "borsh"}) when is_binary(value) do 36 | <> 37 | end 38 | 39 | defp encode_value({value, size}), do: encode_value({value, size, :little}) 40 | defp encode_value({value, size, :big}), do: <> 41 | defp encode_value({value, size, :little}), do: <> 42 | defp encode_value(value) when is_binary(value), do: value 43 | defp encode_value(value) when is_integer(value), do: <> 44 | defp encode_value(value) when is_boolean(value), do: <> 45 | 46 | defp unary(val), do: if(val, do: 1, else: 0) 47 | end 48 | -------------------------------------------------------------------------------- /lib/solana.ex: -------------------------------------------------------------------------------- 1 | defmodule Solana do 2 | @moduledoc """ 3 | A library for interacting with the Solana blockchain. 4 | """ 5 | 6 | @typedoc "See `t:Solana.Key.t/0`" 7 | @type key :: Solana.Key.t() 8 | 9 | @typedoc "See `t:Solana.Key.pair/0`" 10 | @type keypair :: Solana.Key.pair() 11 | 12 | @doc """ 13 | See `Solana.Key.pair/0` 14 | """ 15 | defdelegate keypair(), to: Solana.Key, as: :pair 16 | 17 | @doc """ 18 | Decodes or extracts a `t:Solana.Key.t/0` from a Base58-encoded string or a 19 | `t:Solana.Key.pair/0`. 20 | 21 | Returns `{:ok, key}` if the key is valid, or an error tuple if it's not. 22 | """ 23 | def pubkey(pair_or_encoded) 24 | def pubkey({_sk, pk}), do: Solana.Key.check(pk) 25 | defdelegate pubkey(encoded), to: Solana.Key, as: :decode 26 | 27 | @doc """ 28 | Decodes or extracts a `t:Solana.Key.t/0` from a Base58-encoded string or a 29 | `t:Solana.Key.pair/0`. 30 | 31 | Throws an `ArgumentError` if it fails to retrieve the public key. 32 | """ 33 | def pubkey!(pair_or_encoded) 34 | 35 | def pubkey!(pair = {_sk, _pk}) do 36 | case pubkey(pair) do 37 | {:ok, key} -> key 38 | _ -> raise ArgumentError, "invalid keypair: #{inspect(pair)}" 39 | end 40 | end 41 | 42 | defdelegate pubkey!(encoded), to: Solana.Key, as: :decode! 43 | 44 | @doc """ 45 | The public key for the [Rent system 46 | variable](https://docs.solana.com/developing/runtime-facilities/sysvars#rent). 47 | """ 48 | def rent(), do: pubkey!("SysvarRent111111111111111111111111111111111") 49 | 50 | @doc """ 51 | The public key for the [RecentBlockhashes system 52 | variable](https://docs.solana.com/developing/runtime-facilities/sysvars#recentblockhashes) 53 | """ 54 | def recent_blockhashes(), do: pubkey!("SysvarRecentB1ockHashes11111111111111111111") 55 | 56 | @doc """ 57 | The public key for the [Clock system 58 | variable](https://docs.solana.com/developing/runtime-facilities/sysvars#clock) 59 | """ 60 | def clock(), do: pubkey!("SysvarC1ock11111111111111111111111111111111") 61 | 62 | @doc """ 63 | The public key for the [BPF Loader 64 | program](https://docs.solana.com/developing/runtime-facilities/programs#bpf-loader) 65 | """ 66 | def bpf_loader(), do: pubkey!("BPFLoaderUpgradeab1e11111111111111111111111") 67 | 68 | @doc false 69 | def lamports_per_sol(), do: 1_000_000_000 70 | end 71 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Solana.MixProject do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/dcrck/solana-elixir" 5 | @version "0.2.0" 6 | 7 | def project do 8 | [ 9 | app: :solana, 10 | description: description(), 11 | version: @version, 12 | elixir: "~> 1.12", 13 | package: package(), 14 | elixirc_paths: elixirc_paths(Mix.env()), 15 | start_permanent: Mix.env() == :prod, 16 | name: "Solana", 17 | source_url: @source_url, 18 | homepage_url: @source_url, 19 | deps: deps(), 20 | docs: docs() 21 | ] 22 | end 23 | 24 | def application do 25 | [ 26 | extra_applications: [:logger] 27 | ] 28 | end 29 | 30 | def elixirc_paths(:test), do: ["lib", "test/support"] 31 | def elixirc_paths(_), do: ["lib"] 32 | 33 | defp description do 34 | "A library for interacting with the Solana blockchain." 35 | end 36 | 37 | defp package do 38 | [ 39 | name: "solana", 40 | maintainers: ["Derek Meer"], 41 | licenses: ["MIT"], 42 | links: %{ 43 | "SourceHut" => "https://git.sr.ht/~dcrck/solana", 44 | "GitHub" => @source_url 45 | } 46 | ] 47 | end 48 | 49 | defp deps do 50 | [ 51 | # base client 52 | {:tesla, "~> 1.15.3"}, 53 | # json library 54 | {:jason, ">= 1.0.0"}, 55 | # keys and signatures 56 | {:ed25519, "~> 1.4.3"}, 57 | # base58 encoding 58 | {:basefiftyeight, "~> 0.1.0"}, 59 | # validating parameters 60 | {:nimble_options, "~> 1.1.1"}, 61 | # docs and testing 62 | {:ex_doc, "~> 0.38.2", only: :dev, runtime: false}, 63 | {:dialyxir, "~> 1.4.6", only: [:dev, :test], runtime: false} 64 | ] 65 | end 66 | 67 | defp docs do 68 | [ 69 | main: "readme", 70 | source_ref: "v#{@version}", 71 | source_url: @source_url, 72 | extras: ["README.md", "LICENSE"], 73 | groups_for_modules: [ 74 | Client: [ 75 | Solana.RPC, 76 | Solana.RPC.Request, 77 | Solana.RPC.Tracker 78 | ], 79 | Transactions: [ 80 | Solana.Transaction, 81 | Solana.Instruction, 82 | Solana.Account, 83 | Solana.Key 84 | ], 85 | "System Program": [ 86 | Solana.SystemProgram, 87 | Solana.SystemProgram.Nonce 88 | ], 89 | Testing: [ 90 | Solana.TestValidator 91 | ] 92 | ], 93 | nest_modules_by_prefix: [ 94 | Solana.RPC, 95 | Solana.SystemProgram 96 | ] 97 | ] 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/solana/compact_array.ex: -------------------------------------------------------------------------------- 1 | defmodule Solana.CompactArray do 2 | @moduledoc false 3 | import Bitwise, except: ["~~~": 1, &&&: 2, |||: 2, "^^^": 2, <<<: 2, >>>: 2] 4 | 5 | # https://docs.solana.com/developing/programming-model/transactions#compact-array-format 6 | @spec to_iolist(arr :: iolist | nil) :: iolist 7 | def to_iolist(nil), do: [] 8 | 9 | def to_iolist(arr) when is_list(arr) do 10 | [encode_length(length(arr)) | arr] 11 | end 12 | 13 | def to_iolist(bin) when is_binary(bin) do 14 | [encode_length(byte_size(bin)) | [bin]] 15 | end 16 | 17 | @spec encode_length(length :: non_neg_integer) :: list 18 | def encode_length(length) when bsr(length, 7) == 0, do: [encode_bits(length)] 19 | 20 | def encode_length(length) do 21 | [bor(encode_bits(length), 0x80) | encode_length(bsr(length, 7))] 22 | end 23 | 24 | defp encode_bits(bits), do: band(bits, 0x7F) 25 | 26 | @spec decode_and_split(encoded :: binary) :: {binary, non_neg_integer} | :error 27 | def decode_and_split(""), do: :error 28 | 29 | def decode_and_split(encoded) do 30 | count = decode_length(encoded) 31 | count_size = compact_length_bytes(count) 32 | 33 | case encoded do 34 | <> -> {rest, length} 35 | _ -> :error 36 | end 37 | end 38 | 39 | @spec decode_and_split(encoded :: binary, item_size :: non_neg_integer) :: 40 | {[binary], binary, non_neg_integer} | :error 41 | def decode_and_split("", _), do: :error 42 | 43 | def decode_and_split(encoded, item_size) do 44 | count = decode_length(encoded) 45 | count_size = compact_length_bytes(count) 46 | data_size = count * item_size 47 | 48 | case encoded do 49 | <> -> 50 | {Solana.Helpers.chunk(data, item_size), rest, length} 51 | 52 | _ -> 53 | :error 54 | end 55 | end 56 | 57 | def decode_length(bytes), do: decode_length(bytes, 0) 58 | 59 | def decode_length(<>, size) when band(elem, 0x80) == 0 do 60 | decode_bits(elem, size) 61 | end 62 | 63 | def decode_length([elem | _], size) when band(elem, 0x80) == 0 do 64 | decode_bits(elem, size) 65 | end 66 | 67 | def decode_length(<>, size) do 68 | bor(decode_bits(elem, size), decode_length(rest, size + 1)) 69 | end 70 | 71 | def decode_length([elem | rest], size) do 72 | bor(decode_bits(elem, size), decode_length(rest, size + 1)) 73 | end 74 | 75 | defp decode_bits(bits, size), do: bits |> band(0x7F) |> bsl(7 * size) 76 | 77 | defp compact_length_bytes(length) when length < 0x7F, do: 1 78 | defp compact_length_bytes(length) when length < 0x3FFF, do: 2 79 | defp compact_length_bytes(_), do: 3 80 | end 81 | -------------------------------------------------------------------------------- /lib/solana/rpc/middleware.ex: -------------------------------------------------------------------------------- 1 | defmodule Solana.RPC.Middleware do 2 | @behaviour Tesla.Middleware 3 | 4 | @moduledoc false 5 | 6 | @success 200..299 7 | 8 | def call(env = %{body: request}, next, _) do 9 | env 10 | |> Tesla.run(next) 11 | |> handle_response(request) 12 | end 13 | 14 | defp handle_response({:ok, response = %{status: status}}, request) 15 | when status in @success do 16 | response_content(response, request) 17 | end 18 | 19 | defp handle_response({:ok, %{status: status}}, _), do: {:error, status} 20 | defp handle_response(other, _), do: other 21 | 22 | defp response_content(%{body: body}, requests) when is_list(body) do 23 | responses = body |> Enum.sort_by(& &1["id"]) |> Enum.map(&extract_result/1) 24 | 25 | requests 26 | |> Enum.sort_by(& &1.id) 27 | |> Enum.map(& &1.method) 28 | |> Enum.zip(responses) 29 | |> Enum.map(&decode_result/1) 30 | end 31 | 32 | defp response_content(%{body: response}, request) do 33 | decode_result({request.method, extract_result(response)}) 34 | end 35 | 36 | defp extract_result(%{"result" => %{"value" => value}}), do: value 37 | defp extract_result(%{"result" => result}), do: result 38 | defp extract_result(other), do: other 39 | 40 | defp decode_result({_, %{"error" => error}}), do: {:error, error} 41 | 42 | defp decode_result({"requestAirdrop", airdrop_tx}) do 43 | {:ok, B58.decode58!(airdrop_tx)} 44 | end 45 | 46 | defp decode_result({"getSignaturesForAddress", signature_responses}) do 47 | responses = 48 | Enum.map(signature_responses, fn response -> 49 | update_in(response, ["signature"], &B58.decode58!/1) 50 | end) 51 | 52 | {:ok, responses} 53 | end 54 | 55 | defp decode_result({"getLatestBlockhash", blockhash_result}) do 56 | {:ok, update_in(blockhash_result, ["blockhash"], &B58.decode58!/1)} 57 | end 58 | 59 | defp decode_result({"getRecentBlockhash", blockhash_result}) do 60 | {:ok, update_in(blockhash_result, ["blockhash"], &B58.decode58!/1)} 61 | end 62 | 63 | defp decode_result({"sendTransaction", signature}) do 64 | {:ok, B58.decode58!(signature)} 65 | end 66 | 67 | defp decode_result({"getTransaction", %{"transaction" => tx} = result}) when is_map(tx) do 68 | tx = 69 | tx 70 | |> update_in(["message", "accountKeys"], &decode_b58_list/1) 71 | |> update_in(["message", "recentBlockhash"], &B58.decode58!/1) 72 | |> Map.update!("signatures", &decode_b58_list/1) 73 | 74 | {:ok, Map.put(result, "transaction", tx)} 75 | end 76 | 77 | defp decode_result({"getAccountInfo", %{} = result}) do 78 | {:ok, Map.update!(result, "owner", &B58.decode58!/1)} 79 | end 80 | 81 | # just run the decoding for getAccountInfo for each item in the list 82 | defp decode_result({"getMultipleAccounts", result}) when is_list(result) do 83 | {:ok, Enum.map(result, &elem(decode_result({"getAccountInfo", &1}), 1))} 84 | end 85 | 86 | defp decode_result({_method, result}), do: {:ok, result} 87 | 88 | defp decode_b58_list(list), do: Enum.map(list, &B58.decode58!/1) 89 | end 90 | -------------------------------------------------------------------------------- /lib/solana/rpc/tracker.ex: -------------------------------------------------------------------------------- 1 | defmodule Solana.RPC.Tracker do 2 | @moduledoc """ 3 | A GenServer you can use to track the status of transaction signatures. 4 | 5 | ## Example 6 | 7 | iex> key = Solana.keypair() |> Solana.pubkey!() 8 | iex> {:ok, tracker} = Solana.RPC.Tracker.start_link(network: "localhost") 9 | iex> client = Solana.RPC.client(network: "localhost") 10 | iex> {:ok, tx} = Solana.RPC.send(client, Solana.RPC.Request.request_airdrop(key, 1)) 11 | iex> Solana.Tracker.start_tracking(tracker, tx) 12 | iex> receive do 13 | ...> {:ok, [^tx]} -> IO.puts("confirmed!") 14 | ...> end 15 | confirmed! 16 | 17 | """ 18 | use GenServer 19 | 20 | alias Solana.RPC 21 | 22 | @doc """ 23 | Starts a `Solana.RPC.Tracker` process linked to the current process. 24 | """ 25 | def start_link(opts) do 26 | GenServer.start_link(__MODULE__, opts) 27 | end 28 | 29 | @doc """ 30 | Starts tracking a transaction signature or list of transaction signatures. 31 | 32 | Sends messages back to the calling process as transactions from the list 33 | are confirmed. Stops tracking automatically once transactions have been 34 | confirmed. 35 | """ 36 | def start_tracking(tracker, signatures, opts) do 37 | GenServer.cast(tracker, {:track, List.wrap(signatures), opts, self()}) 38 | end 39 | 40 | @doc false 41 | def init(opts) do 42 | client_opts = Keyword.take(opts, [:network, :retry_options, :adapter]) 43 | {:ok, %{client: Solana.RPC.client(client_opts), t: Keyword.get(opts, :t, 500)}} 44 | end 45 | 46 | @doc false 47 | def handle_cast({:track, signatures, opts, from}, state) do 48 | Process.send_after(self(), {:check, signatures, opts, from}, 0) 49 | {:noreply, state} 50 | end 51 | 52 | @doc false 53 | def handle_info({:check, signatures, opts, from}, state) do 54 | request = RPC.Request.get_signature_statuses(signatures) 55 | commitment = Keyword.get(opts, :commitment, "finalized") 56 | 57 | {:ok, results} = RPC.send(state.client, request) 58 | 59 | mapped_results = signatures |> Enum.zip(results) |> Enum.into(%{}) 60 | 61 | {_failed, not_failed} = 62 | Enum.split_with(signatures, fn signature -> 63 | result = Map.get(mapped_results, signature) 64 | !is_nil(result) && !is_nil(result["err"]) 65 | end) 66 | 67 | {done, to_retry} = 68 | Enum.split_with(not_failed, fn signature -> 69 | result = Map.get(mapped_results, signature) 70 | !is_nil(result) && commitment_done?(result, commitment) 71 | end) 72 | 73 | if done != [], do: send(from, {:ok, done}) 74 | if to_retry != [], do: Process.send_after(self(), {:check, to_retry, opts, from}, state.t) 75 | 76 | {:noreply, state} 77 | end 78 | 79 | defp commitment_done?(%{"confirmationStatus" => "finalized"}, _), do: true 80 | defp commitment_done?(%{"confirmationStatus" => "confirmed"}, "finalized"), do: false 81 | defp commitment_done?(%{"confirmationStatus" => "confirmed"}, _), do: true 82 | defp commitment_done?(%{"confirmationStatus" => "processed"}, "processed"), do: true 83 | defp commitment_done?(%{"confirmationStatus" => "processed"}, _), do: false 84 | end 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Solana 2 | 3 | The unofficial Elixir package for interacting with the 4 | [Solana](https://solana.com) blockchain. 5 | 6 | > Note that this README refers to the master branch of `solana`, not the latest 7 | > released version on Hex. See [the documentation](https://hexdocs.pm/solana) 8 | > for the documentation of the version you're using. 9 | 10 | ## Installation 11 | 12 | Add `solana` to your list of dependencies in `mix.exs`: 13 | 14 | ```elixir 15 | def deps do 16 | [ 17 | {:solana, "~> 0.2.0"} 18 | ] 19 | end 20 | ``` 21 | 22 | ## Documentation 23 | 24 | - [JSON-RPC API client](#json-rpc-api-client) 25 | - [Using a custom HTTP client](#using-a-custom-http-client) 26 | - [On-chain program interaction](#solana-program-interaction) 27 | - [Writing a custom program client](#writing-a-custom-program-client) 28 | - [Testing custom programs](#testing-custom-programs) 29 | 30 | ## JSON-RPC API Client 31 | 32 | `solana` provides a simple interface for interacting with Solana's [JSON-RPC 33 | API](https://docs.solana.com/developing/clients/jsonrpc-api). Here's an example 34 | of requesting an airdrop to a new Solana account via the `requestAirdrop` 35 | method: 36 | 37 | ```elixir 38 | key = Solana.keypair() |> Solana.pubkey!() 39 | client = Solana.RPC.client(network: "localhost") 40 | {:ok, signature} = Solana.RPC.send(client, Solana.RPC.Request.request_airdrop(key, 1)) 41 | 42 | Solana.Transaction.check(signature) # {:ok, ^signature} 43 | ``` 44 | 45 | To see the full list of supported methods, check the `Solana.RPC.Request` 46 | module. 47 | 48 | ### Using a custom HTTP client 49 | 50 | Since this module uses `Tesla` for its API client, you can use whichever 51 | HTTP client you wish, just be sure to include it in your dependencies: 52 | 53 | ```elixir 54 | def deps do 55 | [ 56 | # Gun, for example 57 | {:gun, "~> 1.3"}, 58 | {:idna, "~> 6.0"}, 59 | {:castore, "~> 0.1"}, 60 | # SSL verification 61 | {:ssl_verify_hostname, "~> 1.0"}, 62 | ] 63 | end 64 | ``` 65 | 66 | Then, specify the corresponding `Tesla.Adapter` when creating your client: 67 | 68 | ```elixir 69 | client = Solana.RPC.client(network: "localhost", adapter: {Tesla.Adapter.Gun, certificates_verification: true}) 70 | ``` 71 | 72 | See the `Solana.RPC` module for more details about which options are available 73 | when creating an API client. 74 | 75 | ## On-chain program interaction 76 | 77 | Since `solana`'s JSON-RPC API client supports `sendTransaction`, you can use it 78 | to interact with on-chain Solana programs. `solana` provides utilities to craft 79 | transactions, send them, and confirm them on-chain. It also includes the 80 | `Solana.SystemProgram` module, which allows you to create 81 | [SystemProgram](https://docs.solana.com/developing/runtime-facilities/programs#system-program) 82 | instructions. 83 | 84 | Also check out the `solana_spl` package 85 | [documentation](https://hexdocs.pm/solana_spl) to interact with the [Solana 86 | Program Library](https://spl.solana.com). 87 | 88 | ## Writing a custom program client 89 | 90 | By providing an interface for the `Solana.SystemProgram`, `solana` provides 91 | guidelines for how to build interfaces to your own programs. For more examples, 92 | see the [`solana_spl` package](https://hexdocs.pm/solana_spl). 93 | 94 | ### Testing custom programs 95 | 96 | Once you've built your custom program's client, you should probably write some 97 | tests for it. `solana` provides example tests for the `Solana.SystemProgram` in 98 | `test/solana/system_program_test.exs`, along with an Elixir-managed [Solana Test 99 | Validator](https://docs.solana.com/developing/test-validator) process to test 100 | your program locally. See `Solana.TestValidator` for more details about how to 101 | set this up. 102 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "basefiftyeight": {:hex, :basefiftyeight, "0.1.0", "3d48544743bf9aab7ab02aed803ac42af77acf268c7d8c71d4f39e7fa85ee8d3", [:mix], [], "hexpm", "af12f551429528c711e98628c029ad48d1e5ba5a284f40b2d91029a65381837a"}, 3 | "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 5 | "ed25519": {:hex, :ed25519, "1.4.3", "d1422c643fb691f8efc65e66c733bcc92338485858a9469f24a528b915809377", [:mix], [], "hexpm", "37f9de6be4a0e67d56f1b69ec2b79d4d96fea78365f45f5d5d344c48cf81d487"}, 6 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 7 | "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, 8 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 9 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 10 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 11 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 12 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 13 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 15 | "tesla": {:hex, :tesla, "1.15.3", "3a2b5c37f09629b8dcf5d028fbafc9143c0099753559d7fe567eaabfbd9b8663", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "98bb3d4558abc67b92fb7be4cd31bb57ca8d80792de26870d362974b58caeda7"}, 16 | } 17 | -------------------------------------------------------------------------------- /lib/solana/key.ex: -------------------------------------------------------------------------------- 1 | defmodule Solana.Key do 2 | @moduledoc """ 3 | Functions for creating and validating Solana 4 | [keys](https://docs.solana.com/terminology#public-key-pubkey) and 5 | [keypairs](https://docs.solana.com/terminology#keypair). 6 | """ 7 | 8 | @typedoc "Solana public or private key" 9 | @type t :: Ed25519.key() 10 | 11 | @typedoc "a public/private keypair" 12 | @type pair :: {t(), t()} 13 | 14 | @spec pair() :: pair 15 | @doc """ 16 | Generates a public/private key pair in the format `{private_key, public_key}` 17 | """ 18 | defdelegate pair, to: Ed25519, as: :generate_key_pair 19 | 20 | @doc """ 21 | Reads a public/private key pair from a [file system 22 | wallet](https://docs.solana.com/wallet-guide/file-system-wallet) in the format 23 | `{private_key, public_key}`. Returns `{:ok, pair}` if successful, or `{:error, 24 | reason}` if not. 25 | """ 26 | @spec pair_from_file(String.t()) :: {:ok, pair} | {:error, term} 27 | def pair_from_file(path) do 28 | with {:ok, contents} <- File.read(path), 29 | {:ok, list} when is_list(list) <- Jason.decode(contents), 30 | <> <- :erlang.list_to_binary(list) do 31 | {:ok, {sk, pk}} 32 | else 33 | {:error, _} = error -> error 34 | _contents -> {:error, "invalid wallet format"} 35 | end 36 | end 37 | 38 | @doc """ 39 | decodes a base58-encoded key and returns it in a tuple. 40 | 41 | If it fails, return an error tuple. 42 | """ 43 | @spec decode(encoded :: binary) :: {:ok, t} | {:error, binary} 44 | def decode(encoded) when is_binary(encoded) do 45 | case B58.decode58(encoded) do 46 | {:ok, decoded} -> check(decoded) 47 | _ -> {:error, "invalid public key"} 48 | end 49 | end 50 | 51 | def decode(_), do: {:error, "invalid public key"} 52 | 53 | @doc """ 54 | decodes a base58-encoded key and returns it. 55 | 56 | Throws an `ArgumentError` if it fails. 57 | """ 58 | @spec decode!(encoded :: binary) :: t 59 | def decode!(encoded) when is_binary(encoded) do 60 | case decode(encoded) do 61 | {:ok, key} -> 62 | key 63 | 64 | {:error, _} -> 65 | raise ArgumentError, "invalid public key input: #{encoded}" 66 | end 67 | end 68 | 69 | @doc """ 70 | Checks to see if a `t:Solana.Key.t/0` is valid. 71 | """ 72 | @spec check(key :: binary) :: {:ok, t} | {:error, binary} 73 | def check(key) 74 | def check(<>), do: {:ok, key} 75 | def check(_), do: {:error, "invalid public key"} 76 | 77 | @doc """ 78 | Derive a public key from another key, a seed, and a program ID. 79 | 80 | The program ID will also serve as the owner of the public key, giving it 81 | permission to write data to the account. 82 | """ 83 | @spec with_seed(base :: t, seed :: binary, program_id :: t) :: 84 | {:ok, t} | {:error, binary} 85 | def with_seed(base, seed, program_id) do 86 | with {:ok, base} <- check(base), 87 | {:ok, program_id} <- check(program_id) do 88 | [base, seed, program_id] 89 | |> hash() 90 | |> check() 91 | else 92 | err -> err 93 | end 94 | end 95 | 96 | @doc """ 97 | Derives a program address from seeds and a program ID. 98 | """ 99 | @spec derive_address(seeds :: [binary], program_id :: t) :: 100 | {:ok, t} | {:error, term} 101 | def derive_address(seeds, program_id) do 102 | with {:ok, program_id} <- check(program_id), 103 | true <- Enum.all?(seeds, &is_valid_seed?/1) do 104 | [seeds, program_id, "ProgramDerivedAddress"] 105 | |> hash() 106 | |> verify_off_curve() 107 | else 108 | err = {:error, _} -> err 109 | false -> {:error, :invalid_seeds} 110 | end 111 | end 112 | 113 | defp is_valid_seed?(seed) do 114 | (is_binary(seed) && byte_size(seed) <= 32) || seed in 0..255 115 | end 116 | 117 | defp hash(data), do: :crypto.hash(:sha256, data) 118 | 119 | defp verify_off_curve(hash) do 120 | if Ed25519.on_curve?(hash), do: {:error, :invalid_seeds}, else: {:ok, hash} 121 | end 122 | 123 | @doc """ 124 | Finds a valid program address. 125 | 126 | Valid addresses must fall off the ed25519 curve; generate a series of nonces, 127 | then combine each one with the given seeds and program ID until a valid 128 | address is found. If a valid address is found, return the address and the 129 | nonce in a tuple. Otherwise, return an error tuple. 130 | """ 131 | @spec find_address(seeds :: [binary], program_id :: t) :: 132 | {:ok, t, nonce :: byte} | {:error, :no_nonce} 133 | def find_address(seeds, program_id) do 134 | case check(program_id) do 135 | {:ok, program_id} -> 136 | Enum.reduce_while(255..1//-1, {:error, :no_nonce}, fn nonce, acc -> 137 | case derive_address(List.flatten([seeds, nonce]), program_id) do 138 | {:ok, address} -> {:halt, {:ok, address, nonce}} 139 | _err -> {:cont, acc} 140 | end 141 | end) 142 | 143 | error -> 144 | error 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/solana/rpc.ex: -------------------------------------------------------------------------------- 1 | defmodule Solana.RPC do 2 | @moduledoc """ 3 | Functions for dealing with Solana's [JSON-RPC 4 | API](https://docs.solana.com/developing/clients/jsonrpc-api). 5 | """ 6 | require Logger 7 | 8 | alias Solana.RPC 9 | import Solana.Helpers 10 | 11 | @typedoc "Solana JSON-RPC API client." 12 | @type client :: Tesla.Client.t() 13 | 14 | @client_schema [ 15 | adapter: [ 16 | type: :any, 17 | default: Tesla.Adapter.Httpc, 18 | doc: "Which `Tesla` adapter to use." 19 | ], 20 | network: [ 21 | type: {:custom, __MODULE__, :cluster_url, []}, 22 | required: true, 23 | doc: "Which [Solana cluster](https://docs.solana.com/clusters) to connect to." 24 | ], 25 | retry_options: [ 26 | type: :keyword_list, 27 | default: [], 28 | doc: "Options to pass to `Tesla.Middleware.Retry`." 29 | ] 30 | ] 31 | @doc """ 32 | Creates an API client used to interact with Solana's [JSON-RPC 33 | API](https://docs.solana.com/developing/clients/jsonrpc-api). 34 | 35 | ## Example 36 | 37 | iex> key = Solana.keypair() |> Solana.pubkey!() 38 | iex> client = Solana.RPC.client(network: "localhost") 39 | iex> {:ok, signature} = Solana.RPC.send(client, Solana.RPC.Request.request_airdrop(key, 1)) 40 | iex> is_binary(signature) 41 | true 42 | 43 | ## Options 44 | 45 | #{NimbleOptions.docs(@client_schema)} 46 | """ 47 | @spec client(keyword) :: client 48 | def client(opts) do 49 | case validate(opts, @client_schema) do 50 | {:ok, config} -> 51 | middleware = [ 52 | {Tesla.Middleware.BaseUrl, config.network}, 53 | RPC.Middleware, 54 | Tesla.Middleware.JSON, 55 | {Tesla.Middleware.Retry, retry_opts(config)} 56 | ] 57 | 58 | Tesla.client(middleware, config.adapter) 59 | 60 | error -> 61 | error 62 | end 63 | end 64 | 65 | @doc """ 66 | Sends the provided requests to the configured Solana RPC endpoint. 67 | """ 68 | def send(client, requests) do 69 | Tesla.post(client, "/", Solana.RPC.Request.encode(requests)) 70 | end 71 | 72 | @doc """ 73 | Sends the provided transactions to the configured RPC endpoint, then confirms them. 74 | 75 | Returns a tuple containing all the transactions in the order they were confirmed, OR 76 | an error tuple containing the list of all the transactions that were confirmed 77 | before the error occurred. 78 | """ 79 | @spec send_and_confirm(client, pid, [Solana.Transaction.t()] | Solana.Transaction.t(), keyword) :: 80 | {:ok, [binary]} | {:error, :timeout, [binary]} 81 | def send_and_confirm(client, tracker, txs, opts \\ []) do 82 | timeout = Keyword.get(opts, :timeout, 5_000) 83 | request_opts = Keyword.take(opts, [:commitment]) 84 | requests = Enum.map(List.wrap(txs), &RPC.Request.send_transaction(&1, request_opts)) 85 | 86 | client 87 | |> RPC.send(requests) 88 | |> Enum.flat_map(fn 89 | {:ok, signature} -> 90 | [signature] 91 | 92 | {:error, %{"data" => %{"logs" => logs}, "message" => message}} -> 93 | [message | logs] 94 | |> Enum.join("\n") 95 | |> Logger.error() 96 | 97 | [] 98 | 99 | {:error, error} -> 100 | Logger.error("error sending transaction: #{inspect(error)}") 101 | [] 102 | end) 103 | |> case do 104 | [] -> 105 | :error 106 | 107 | signatures -> 108 | :ok = RPC.Tracker.start_tracking(tracker, signatures, request_opts) 109 | await_confirmations(signatures, timeout, []) 110 | end 111 | end 112 | 113 | defp await_confirmations([], _, confirmed), do: {:ok, confirmed} 114 | 115 | defp await_confirmations(signatures, timeout, done) do 116 | receive do 117 | {:ok, confirmed} -> 118 | MapSet.new(signatures) 119 | |> MapSet.difference(MapSet.new(confirmed)) 120 | |> MapSet.to_list() 121 | |> await_confirmations(timeout, List.flatten([done, confirmed])) 122 | after 123 | timeout -> {:error, :timeout, done} 124 | end 125 | end 126 | 127 | @doc false 128 | def cluster_url(network) when network in ["devnet", "mainnet-beta", "testnet"] do 129 | {:ok, "https://api.#{network}.solana.com"} 130 | end 131 | 132 | def cluster_url("localhost"), do: {:ok, "http://127.0.0.1:8899"} 133 | 134 | def cluster_url(other) when is_binary(other) do 135 | case URI.parse(other) do 136 | %{scheme: nil, host: nil} -> {:error, "invalid cluster"} 137 | _ -> {:ok, other} 138 | end 139 | end 140 | 141 | def cluster_url(_), do: {:error, "invalid cluster"} 142 | 143 | defp retry_opts(%{retry_options: retry_options}) do 144 | Keyword.merge(retry_defaults(), retry_options) 145 | end 146 | 147 | defp retry_defaults() do 148 | [max_retries: 10, max_delay: 4_000, should_retry: &should_retry?/1] 149 | end 150 | 151 | defp should_retry?({:ok, %{status: status}}) when status in 500..599, do: true 152 | defp should_retry?({:ok, _}), do: false 153 | defp should_retry?({:error, _}), do: true 154 | end 155 | -------------------------------------------------------------------------------- /test/solana/key_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solana.KeyTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Solana.Key 5 | 6 | describe "decode/1" do 7 | test "fails for keys which are too short" do 8 | encoded = B58.encode58(Enum.into(1..31, <<>>, &<<&1::8>>)) 9 | assert {:error, _} = Key.decode(encoded) 10 | assert {:error, _} = Key.decode("12345") 11 | end 12 | 13 | test "fails for keys which are too long" do 14 | encoded = B58.encode58(<<3, 0::32*8>>) 15 | assert {:error, _} = Key.decode(encoded) 16 | end 17 | 18 | test "fails for keys which aren't base58-encoded" do 19 | assert {:error, _} = 20 | Key.decode( 21 | "0x300000000000000000000000000000000000000000000000000000000000000000000" 22 | ) 23 | 24 | assert {:error, _} = 25 | Key.decode("0x300000000000000000000000000000000000000000000000000000000000000") 26 | 27 | assert {:error, _} = 28 | Key.decode( 29 | "135693854574979916511997248057056142015550763280047535983739356259273198796800000" 30 | ) 31 | end 32 | 33 | test "works for the default key" do 34 | assert {:ok, <<0::32*8>>} = Key.decode("11111111111111111111111111111111") 35 | end 36 | 37 | test "works for regular keys" do 38 | assert {:ok, <<3, 0::31*8>>} = Key.decode("CiDwVBFgWV9E5MvXWoLgnEgn2hK7rJikbvfWavzAQz3") 39 | end 40 | end 41 | 42 | describe "decode!/1" do 43 | test "throws for keys which aren't base58-encoded" do 44 | assert_raise ArgumentError, fn -> 45 | Key.decode!("0x300000000000000000000000000000000000000000000000000000000000000000000") 46 | end 47 | 48 | assert_raise ArgumentError, fn -> 49 | Key.decode!("0x300000000000000000000000000000000000000000000000000000000000000") 50 | end 51 | 52 | assert_raise ArgumentError, fn -> 53 | Key.decode!( 54 | "135693854574979916511997248057056142015550763280047535983739356259273198796800000" 55 | ) 56 | end 57 | end 58 | 59 | test "works for the default key" do 60 | assert <<0::32*8>> == Key.decode!("11111111111111111111111111111111") 61 | end 62 | end 63 | 64 | describe "with_seed/3" do 65 | test "works as expected" do 66 | expected = Key.decode!("9h1HyLCW5dZnBVap8C5egQ9Z6pHyjsh5MNy83iPqqRuq") 67 | default = <<0::32*8>> 68 | assert {:ok, ^expected} = Key.with_seed(default, "limber chicken: 4/45", default) 69 | end 70 | end 71 | 72 | describe "derive_address/2" do 73 | setup do 74 | [program_id: Key.decode!("BPFLoader1111111111111111111111111111111111")] 75 | end 76 | 77 | test "works with strings as seeds", %{program_id: program_id} do 78 | [ 79 | {"3gF2KMe9KiC6FNVBmfg9i267aMPvK37FewCip4eGBFcT", ["", <<1>>]}, 80 | {"HwRVBufQ4haG5XSgpspwKtNd3PC9GM9m1196uJW36vds", ["Talking", "Squirrels"]}, 81 | {"7ytmC1nT1xY4RfxCV2ZgyA7UakC93do5ZdyhdF3EtPj7", ["☉"]} 82 | ] 83 | |> Enum.each(fn {encoded, seeds} -> 84 | assert Key.decode(encoded) == Key.derive_address(seeds, program_id) 85 | end) 86 | end 87 | 88 | test "works with public keys and strings as seeds", %{program_id: program_id} do 89 | key = Key.decode!("SeedPubey1111111111111111111111111111111111") 90 | 91 | expected = Key.decode("GUs5qLUfsEHkcMB9T38vjr18ypEhRuNWiePW2LoK4E3K") 92 | assert Key.derive_address([key], program_id) == expected 93 | assert Key.derive_address(["Talking"], program_id) != expected 94 | end 95 | 96 | test "does not work when seeds are too long", %{program_id: program_id} do 97 | assert {:error, :invalid_seeds} = Key.derive_address([<<0::33*8>>], program_id) 98 | end 99 | 100 | test "does not lop off leading zeros" do 101 | seeds = [ 102 | Key.decode!("H4snTKK9adiU15gP22ErfZYtro3aqR9BTMXiH3AwiUTQ"), 103 | <<2::little-size(64)>> 104 | ] 105 | 106 | program_id = Key.decode!("4ckmDgGdxQoPDLUkDT3vHgSAkzA3QRdNq5ywwY4sUSJn") 107 | 108 | assert Key.decode("12rqwuEgBYiGhBrDJStCiqEtzQpTTiZbh7teNVLuYcFA") == 109 | Key.derive_address(seeds, program_id) 110 | end 111 | end 112 | 113 | describe "find_address/2" do 114 | test "finds a program address" do 115 | program_id = Key.decode!("BPFLoader1111111111111111111111111111111111") 116 | {:ok, address, nonce} = Key.find_address([""], program_id) 117 | assert {:ok, ^address} = Key.derive_address(["", nonce], program_id) 118 | end 119 | end 120 | 121 | describe "pair_from_file/1" do 122 | test "loads a keypair from a valid file system wallet" do 123 | pk = Solana.pubkey!("ntnHWe6sXd1SZaLa5gqHndAsyRwEfoR21ggNxkuyBtK") 124 | assert {:ok, {_sk, ^pk}} = Key.pair_from_file("test/support/wallet.json") 125 | end 126 | 127 | test "does not load a keypair from a non-existant files" do 128 | assert {:error, _} = Key.pair_from_file("test/support/nonexistant.json") 129 | end 130 | 131 | test "does not load a keypair from invalid files" do 132 | ["invalid1", "invalid2"] 133 | |> Enum.each(fn name -> 134 | assert {:error, "invalid wallet format"} = Key.pair_from_file("test/support/#{name}.json") 135 | end) 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /lib/solana/system_program/nonce.ex: -------------------------------------------------------------------------------- 1 | defmodule Solana.SystemProgram.Nonce do 2 | @moduledoc """ 3 | Functions for interacting with the [System 4 | Program](https://docs.solana.com/developing/runtime-facilities/programs#system-program)'s 5 | nonce accounts, required for [durable transaction 6 | nonces](https://docs.solana.com/offline-signing/durable-nonce). 7 | 8 | These accounts can be useful for offline transactions, as well as transactions 9 | that require more time to generate a transaction signature than the normal 10 | `recent_blockhash` transaction mechanism gives them (~2 minutes). 11 | """ 12 | alias Solana.{Instruction, Account, SystemProgram} 13 | import Solana.Helpers 14 | 15 | @doc """ 16 | The size of a serialized nonce account. 17 | """ 18 | def byte_size(), do: 80 19 | 20 | @doc """ 21 | Translates the result of a `Solana.RPC.Request.get_account_info/2` into a 22 | nonce account's information. 23 | """ 24 | @spec from_account_info(info :: map) :: map | :error 25 | def from_account_info(info) 26 | 27 | def from_account_info(%{"data" => %{"parsed" => %{"info" => info}}}) do 28 | from_nonce_account_info(info) 29 | end 30 | 31 | def from_account_info(_), do: :error 32 | 33 | defp from_nonce_account_info(%{ 34 | "authority" => authority, 35 | "blockhash" => blockhash, 36 | "feeCalculator" => calculator 37 | }) do 38 | %{ 39 | authority: Solana.pubkey!(authority), 40 | blockhash: B58.decode58!(blockhash), 41 | calculator: calculator 42 | } 43 | end 44 | 45 | defp from_nonce_account_info(_), do: :error 46 | 47 | @init_schema [ 48 | nonce: [ 49 | type: {:custom, Solana.Key, :check, []}, 50 | required: true, 51 | doc: "Public key of the nonce account" 52 | ], 53 | authority: [ 54 | type: {:custom, Solana.Key, :check, []}, 55 | required: true, 56 | doc: "Public key of the nonce authority" 57 | ] 58 | ] 59 | @doc """ 60 | Generates the instructions for initializing a nonce account. 61 | 62 | ## Options 63 | 64 | #{NimbleOptions.docs(@init_schema)} 65 | """ 66 | def init(opts) do 67 | case validate(opts, @init_schema) do 68 | {:ok, params} -> 69 | %Instruction{ 70 | program: SystemProgram.id(), 71 | accounts: [ 72 | %Account{key: params.nonce, writable?: true}, 73 | %Account{key: Solana.recent_blockhashes()}, 74 | %Account{key: Solana.rent()} 75 | ], 76 | data: Instruction.encode_data([{6, 32}, params.authority]) 77 | } 78 | 79 | error -> 80 | error 81 | end 82 | end 83 | 84 | @authorize_schema [ 85 | nonce: [ 86 | type: {:custom, Solana.Key, :check, []}, 87 | required: true, 88 | doc: "Public key of the nonce account" 89 | ], 90 | authority: [ 91 | type: {:custom, Solana.Key, :check, []}, 92 | required: true, 93 | doc: "Public key of the nonce authority" 94 | ], 95 | new_authority: [ 96 | type: {:custom, Solana.Key, :check, []}, 97 | required: true, 98 | doc: "Public key to set as the new nonce authority" 99 | ] 100 | ] 101 | @doc """ 102 | Generates the instructions for re-assigning the authority of a nonce account. 103 | 104 | ## Options 105 | 106 | #{NimbleOptions.docs(@authorize_schema)} 107 | """ 108 | def authorize(opts) do 109 | case validate(opts, @authorize_schema) do 110 | {:ok, params} -> 111 | %Instruction{ 112 | program: SystemProgram.id(), 113 | accounts: [ 114 | %Account{key: params.nonce, writable?: true}, 115 | %Account{key: params.authority, signer?: true} 116 | ], 117 | data: Instruction.encode_data([{7, 32}, params.new_authority]) 118 | } 119 | 120 | error -> 121 | error 122 | end 123 | end 124 | 125 | @advance_schema [ 126 | nonce: [ 127 | type: {:custom, Solana.Key, :check, []}, 128 | required: true, 129 | doc: "Public key of the nonce account" 130 | ], 131 | authority: [ 132 | type: {:custom, Solana.Key, :check, []}, 133 | required: true, 134 | doc: "Public key of the nonce authority" 135 | ] 136 | ] 137 | @doc """ 138 | Generates the instructions for advancing a nonce account's stored nonce value. 139 | 140 | ## Options 141 | 142 | #{NimbleOptions.docs(@advance_schema)} 143 | """ 144 | def advance(opts) do 145 | case validate(opts, @advance_schema) do 146 | {:ok, params} -> 147 | %Instruction{ 148 | program: SystemProgram.id(), 149 | accounts: [ 150 | %Account{key: params.nonce, writable?: true}, 151 | %Account{key: Solana.recent_blockhashes()}, 152 | %Account{key: params.authority, signer?: true} 153 | ], 154 | data: Instruction.encode_data([{4, 32}]) 155 | } 156 | 157 | error -> 158 | error 159 | end 160 | end 161 | 162 | @withdraw_schema [ 163 | nonce: [ 164 | type: {:custom, Solana.Key, :check, []}, 165 | required: true, 166 | doc: "Public key of the nonce account" 167 | ], 168 | authority: [ 169 | type: {:custom, Solana.Key, :check, []}, 170 | required: true, 171 | doc: "Public key of the nonce authority" 172 | ], 173 | to: [ 174 | type: {:custom, Solana.Key, :check, []}, 175 | required: true, 176 | doc: "Public key of the account which will get the withdrawn lamports" 177 | ], 178 | lamports: [ 179 | type: :pos_integer, 180 | required: true, 181 | doc: "Amount of lamports to transfer to the created account" 182 | ] 183 | ] 184 | @doc """ 185 | Generates the instructions for withdrawing funds form a nonce account. 186 | 187 | ## Options 188 | 189 | #{NimbleOptions.docs(@withdraw_schema)} 190 | """ 191 | def withdraw(opts) do 192 | case validate(opts, @withdraw_schema) do 193 | {:ok, params} -> 194 | %Instruction{ 195 | program: SystemProgram.id(), 196 | accounts: [ 197 | %Account{key: params.nonce, writable?: true}, 198 | %Account{key: params.to, writable?: true}, 199 | %Account{key: Solana.recent_blockhashes()}, 200 | %Account{key: Solana.rent()}, 201 | %Account{key: params.authority, signer?: true} 202 | ], 203 | data: Instruction.encode_data([{5, 32}, {params.lamports, 64}]) 204 | } 205 | 206 | error -> 207 | error 208 | end 209 | end 210 | end 211 | -------------------------------------------------------------------------------- /lib/solana/test/validator.ex: -------------------------------------------------------------------------------- 1 | defmodule Solana.TestValidator do 2 | @moduledoc """ 3 | A [Solana Test Validator](https://docs.solana.com/developing/test-validator) 4 | managed by an Elixir process. This allows you to run unit tests as if you had 5 | the `solana-test-validator` tool running in another process. 6 | 7 | ## Requirements 8 | 9 | Since `Solana.TestValidator` uses the `solana-test-validator` binary, you'll 10 | need to have the [Solana tool 11 | suite](https://docs.solana.com/cli/install-solana-cli-tools) installed. 12 | 13 | ## How to Use 14 | 15 | You can use the `Solana.TestValidator` directly or in a supervision tree. 16 | 17 | To use it directly, add the following lines to the beginning of your 18 | `test/test_helper.exs` file: 19 | 20 | ```elixir 21 | alias Solana.TestValidator 22 | {:ok, validator} = TestValidator.start_link(ledger: "/tmp/test-ledger") 23 | ExUnit.after_suite(fn _ -> TestValidator.stop(validator) end) 24 | ``` 25 | 26 | This will start and stop the `solana-test-validator` before and after your 27 | tests run. 28 | 29 | ### In a supervision tree 30 | 31 | Alternatively, you can add it to your application's supervision tree during 32 | tests. Modify your `mix.exs` file to make the current environment available to 33 | your application: 34 | 35 | ```elixir 36 | def application do 37 | [mod: {MyApp, env: Mix.env()}] 38 | end 39 | ``` 40 | 41 | Then, adjust your application's `children` depending on the environment: 42 | 43 | ```elixir 44 | defmodule MyApp do 45 | use Application 46 | 47 | def start(_type, env: env) do 48 | Supervisor.start_link(children(env), strategy: :one_for_one) 49 | end 50 | 51 | defp children(:test) do 52 | [ 53 | {Solana.TestValidator, ledger: "/tmp/test_ledger"}, 54 | # ... other children 55 | ] 56 | end 57 | 58 | defp children(_) do 59 | # ...other children 60 | end 61 | end 62 | ``` 63 | 64 | ### Options 65 | 66 | You can pass any of the **long-form** options you would pass to a 67 | `solana-test-validator` here. 68 | 69 | For example, to add your own program to the validator, set the `bpf_program` 70 | option as the path to your program's [build 71 | artifact](https://docs.solana.com/developing/on-chain-programs/developing-rust#how-to-build). 72 | See `Solana.TestValidator.start_link/1` for more details. 73 | """ 74 | use GenServer 75 | require Logger 76 | 77 | @schema [ 78 | bind_address: [type: :string, default: "0.0.0.0"], 79 | bpf_program: [type: {:or, [:string, {:list, :string}]}], 80 | clone: [type: {:custom, Solana, :pubkey, []}], 81 | config: [type: :string, default: Path.expand("~/.config/solana/cli/config.yml")], 82 | dynamic_port_range: [type: :string, default: "8000-10000"], 83 | faucet_port: [type: :pos_integer, default: 9900], 84 | faucet_sol: [type: :pos_integer, default: 1_000_000], 85 | gossip_host: [type: :string, default: "127.0.0.1"], 86 | gossip_port: [type: :pos_integer], 87 | url: [type: :string], 88 | ledger: [type: :string, default: "test-ledger"], 89 | limit_ledger_size: [type: :pos_integer, default: 10_000], 90 | mint: [type: {:custom, Solana, :pubkey, []}], 91 | rpc_port: [type: :pos_integer, default: 8899], 92 | slots_per_epoch: [type: :pos_integer], 93 | warp_slot: [type: :string] 94 | ] 95 | 96 | @doc """ 97 | Starts a `Solana.TestValidator` process linked to the current process. 98 | 99 | This process runs and monitors a `solana-test-validator` in the background. 100 | 101 | ## Options 102 | 103 | #{NimbleOptions.docs(@schema)} 104 | """ 105 | def start_link(config) do 106 | case NimbleOptions.validate(config, @schema) do 107 | {:ok, opts} -> GenServer.start_link(__MODULE__, opts, name: __MODULE__) 108 | error -> error 109 | end 110 | end 111 | 112 | @doc """ 113 | Stops a `Solana.TestValidator` process. 114 | 115 | Should be called when you want to stop the `solana-test-validator`. 116 | """ 117 | def stop(validator), do: GenServer.stop(validator, :normal) 118 | 119 | @doc """ 120 | Gets the state of a `Solana.TestValidator` process. 121 | 122 | This is useful when you want to check the latest output of the 123 | `solana-test-validator`. 124 | """ 125 | def get_state(validator), do: :sys.get_state(validator) 126 | 127 | # Callbacks 128 | @doc false 129 | def init(opts) do 130 | with ex_path when not is_nil(ex_path) <- System.find_executable("solana-test-validator"), 131 | ledger = Keyword.get(opts, :ledger), 132 | true <- File.exists?(Path.dirname(ledger)) do 133 | Process.flag(:trap_exit, true) 134 | 135 | port = 136 | Port.open({:spawn_executable, wrapper_path()}, [ 137 | :binary, 138 | :exit_status, 139 | args: [ex_path | to_arg_list(opts)] 140 | ]) 141 | 142 | Port.monitor(port) 143 | {:ok, %{port: port, latest_output: nil, exit_status: nil, ledger: ledger}} 144 | else 145 | false -> 146 | Logger.error("requested ledger directory does not exist") 147 | {:stop, :no_dir} 148 | 149 | nil -> 150 | Logger.error("solana-test-validator executable not found, make sure it's in your PATH") 151 | {:stop, :no_validator} 152 | end 153 | end 154 | 155 | defp to_arg_list(args) do 156 | args 157 | |> Enum.map(fn {k, v} -> {Atom.to_string(k), v} end) 158 | |> Enum.map(&handle_multiples/1) 159 | |> List.flatten() 160 | |> Enum.map(fn {k, v} -> ["--", String.replace(k, "_", "-"), " ", to_string(v), " "] end) 161 | |> IO.iodata_to_binary() 162 | |> String.trim() 163 | |> String.split() 164 | end 165 | 166 | defp handle_multiples({name, list}) when is_list(list) do 167 | Enum.map(list, &{name, &1}) 168 | end 169 | 170 | defp handle_multiples(other), do: other 171 | 172 | @doc false 173 | def terminate(reason, %{port: port}) do 174 | os_pid = port |> Port.info() |> Keyword.get(:os_pid) 175 | # if reason == :normal, do: File.rm_rf(ledger) 176 | Logger.info("** stopped solana-test-validator (pid #{os_pid}): #{inspect(reason)}") 177 | :normal 178 | end 179 | 180 | @doc false 181 | def handle_info({port, {:data, text}}, state = %{port: port}) do 182 | {:noreply, %{state | latest_output: String.trim(text)}} 183 | end 184 | 185 | @doc false 186 | def handle_info({port, {:exit_status, status}}, state = %{port: port}) do 187 | {:noreply, %{state | exit_status: status}} 188 | end 189 | 190 | @doc false 191 | def handle_info({:DOWN, _ref, :port, _port, :normal}, state) do 192 | {:noreply, state} 193 | end 194 | 195 | @doc false 196 | def handle_info({:EXIT, _port, :normal}, state) do 197 | {:noreply, state} 198 | end 199 | 200 | @doc false 201 | def handle_info(other, state) do 202 | Logger.info("unhandled message: #{inspect(other)}") 203 | {:noreply, state} 204 | end 205 | 206 | defp wrapper_path() do 207 | Path.expand(Path.join(Path.dirname(__ENV__.file), "./bin/wrapper-unix")) 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /test/solana/system_program/nonce_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solana.SystemProgram.NonceTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Solana.TestHelpers, only: [create_payer: 3, keypairs: 1] 5 | import Solana, only: [pubkey!: 1] 6 | 7 | alias Solana.{SystemProgram, RPC, Transaction} 8 | 9 | setup_all do 10 | {:ok, tracker} = RPC.Tracker.start_link(network: "localhost", t: 100) 11 | client = RPC.client(network: "localhost") 12 | {:ok, payer} = create_payer(tracker, client, commitment: "confirmed") 13 | 14 | [tracker: tracker, client: client, payer: payer] 15 | end 16 | 17 | describe "init/1" do 18 | test "can create a nonce account", %{tracker: tracker, client: client, payer: payer} do 19 | new = Solana.keypair() 20 | space = SystemProgram.Nonce.byte_size() 21 | 22 | tx_reqs = [ 23 | RPC.Request.get_minimum_balance_for_rent_exemption(space, commitment: "confirmed"), 24 | RPC.Request.get_latest_blockhash(commitment: "confirmed") 25 | ] 26 | 27 | [{:ok, lamports}, {:ok, %{"blockhash" => blockhash}}] = RPC.send(client, tx_reqs) 28 | 29 | tx = %Transaction{ 30 | instructions: [ 31 | SystemProgram.create_account( 32 | lamports: lamports, 33 | space: space, 34 | program_id: SystemProgram.id(), 35 | from: pubkey!(payer), 36 | new: pubkey!(new) 37 | ), 38 | SystemProgram.Nonce.init( 39 | nonce: pubkey!(new), 40 | authority: pubkey!(payer) 41 | ) 42 | ], 43 | signers: [payer, new], 44 | blockhash: blockhash, 45 | payer: pubkey!(payer) 46 | } 47 | 48 | {:ok, _signature} = 49 | RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) 50 | 51 | assert {:ok, %{}} = 52 | RPC.send( 53 | client, 54 | RPC.Request.get_account_info(pubkey!(new), 55 | commitment: "confirmed", 56 | encoding: "jsonParsed" 57 | ) 58 | ) 59 | end 60 | end 61 | 62 | describe "authorize/1" do 63 | test "can set a new authority for a nonce account", %{ 64 | tracker: tracker, 65 | client: client, 66 | payer: payer 67 | } do 68 | [new, auth] = keypairs(2) 69 | space = SystemProgram.Nonce.byte_size() 70 | 71 | tx_reqs = [ 72 | RPC.Request.get_minimum_balance_for_rent_exemption(space, commitment: "confirmed"), 73 | RPC.Request.get_latest_blockhash(commitment: "confirmed") 74 | ] 75 | 76 | [{:ok, lamports}, {:ok, %{"blockhash" => blockhash}}] = RPC.send(client, tx_reqs) 77 | 78 | tx = %Transaction{ 79 | instructions: [ 80 | SystemProgram.create_account( 81 | lamports: lamports, 82 | space: space, 83 | program_id: SystemProgram.id(), 84 | from: pubkey!(payer), 85 | new: pubkey!(new) 86 | ), 87 | SystemProgram.Nonce.init( 88 | nonce: pubkey!(new), 89 | authority: pubkey!(payer) 90 | ), 91 | SystemProgram.Nonce.authorize( 92 | nonce: pubkey!(new), 93 | authority: pubkey!(payer), 94 | new_authority: pubkey!(auth) 95 | ) 96 | ], 97 | signers: [payer, new], 98 | blockhash: blockhash, 99 | payer: pubkey!(payer) 100 | } 101 | 102 | {:ok, _signature} = 103 | RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) 104 | 105 | {:ok, account_info} = 106 | RPC.send( 107 | client, 108 | RPC.Request.get_account_info(pubkey!(new), 109 | commitment: "confirmed", 110 | encoding: "jsonParsed" 111 | ) 112 | ) 113 | 114 | %{authority: authority} = SystemProgram.Nonce.from_account_info(account_info) 115 | 116 | assert authority == pubkey!(auth) 117 | end 118 | end 119 | 120 | describe "advance/1" do 121 | test "can change a nonce account's nonce", %{tracker: tracker, client: client, payer: payer} do 122 | new = Solana.keypair() 123 | space = SystemProgram.Nonce.byte_size() 124 | 125 | tx_reqs = [ 126 | RPC.Request.get_minimum_balance_for_rent_exemption(space, commitment: "confirmed"), 127 | RPC.Request.get_latest_blockhash(commitment: "confirmed") 128 | ] 129 | 130 | [{:ok, lamports}, {:ok, %{"blockhash" => blockhash}}] = RPC.send(client, tx_reqs) 131 | 132 | tx = %Transaction{ 133 | instructions: [ 134 | SystemProgram.create_account( 135 | lamports: lamports, 136 | space: space, 137 | program_id: SystemProgram.id(), 138 | from: pubkey!(payer), 139 | new: pubkey!(new) 140 | ), 141 | SystemProgram.Nonce.init( 142 | nonce: pubkey!(new), 143 | authority: pubkey!(payer) 144 | ) 145 | ], 146 | signers: [payer, new], 147 | blockhash: blockhash, 148 | payer: pubkey!(payer) 149 | } 150 | 151 | {:ok, _signature} = 152 | RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) 153 | 154 | {:ok, info} = 155 | RPC.send( 156 | client, 157 | RPC.Request.get_account_info(pubkey!(new), 158 | commitment: "confirmed", 159 | encoding: "jsonParsed" 160 | ) 161 | ) 162 | 163 | tx = %Transaction{ 164 | instructions: [ 165 | SystemProgram.Nonce.advance( 166 | nonce: pubkey!(new), 167 | authority: pubkey!(payer) 168 | ) 169 | ], 170 | signers: [payer], 171 | blockhash: blockhash, 172 | payer: pubkey!(payer) 173 | } 174 | 175 | {:ok, _signature} = 176 | RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) 177 | 178 | {:ok, info2} = 179 | RPC.send( 180 | client, 181 | RPC.Request.get_account_info(pubkey!(new), 182 | commitment: "confirmed", 183 | encoding: "jsonParsed" 184 | ) 185 | ) 186 | 187 | assert Map.get(SystemProgram.Nonce.from_account_info(info), :blockhash) != 188 | Map.get(SystemProgram.Nonce.from_account_info(info2), :blockhash) 189 | end 190 | end 191 | 192 | describe "withdraw/1" do 193 | test "can withdraw lamports from a nonce account", %{ 194 | tracker: tracker, 195 | client: client, 196 | payer: payer 197 | } do 198 | new = Solana.keypair() 199 | space = SystemProgram.Nonce.byte_size() 200 | 201 | tx_reqs = [ 202 | RPC.Request.get_latest_blockhash(commitment: "confirmed") 203 | ] 204 | 205 | [{:ok, %{"blockhash" => blockhash}}] = RPC.send(client, tx_reqs) 206 | 207 | tx = %Transaction{ 208 | instructions: [ 209 | SystemProgram.create_account( 210 | lamports: Solana.lamports_per_sol(), 211 | space: space, 212 | program_id: SystemProgram.id(), 213 | from: pubkey!(payer), 214 | new: pubkey!(new) 215 | ), 216 | SystemProgram.Nonce.init( 217 | nonce: pubkey!(new), 218 | authority: pubkey!(payer) 219 | ), 220 | SystemProgram.Nonce.withdraw( 221 | nonce: pubkey!(new), 222 | authority: pubkey!(payer), 223 | to: pubkey!(payer), 224 | lamports: div(Solana.lamports_per_sol(), 2) 225 | ) 226 | ], 227 | signers: [payer, new], 228 | blockhash: blockhash, 229 | payer: pubkey!(payer) 230 | } 231 | 232 | {:ok, _signature} = 233 | RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) 234 | 235 | {:ok, info} = 236 | RPC.send( 237 | client, 238 | RPC.Request.get_account_info(pubkey!(new), 239 | commitment: "confirmed", 240 | encoding: "jsonParsed" 241 | ) 242 | ) 243 | 244 | assert info["lamports"] == div(Solana.lamports_per_sol(), 2) 245 | end 246 | end 247 | end 248 | -------------------------------------------------------------------------------- /lib/solana/rpc/request.ex: -------------------------------------------------------------------------------- 1 | defmodule Solana.RPC.Request do 2 | @moduledoc """ 3 | Functions for creating Solana JSON-RPC API requests. 4 | 5 | This client only implements the most common methods (see the function 6 | documentation below). If you need a method that's on the [full 7 | list](https://docs.solana.com/developing/clients/jsonrpc-api#json-rpc-api-reference) 8 | but is not implemented here, please open an issue or contact the maintainers. 9 | """ 10 | 11 | @typedoc "JSON-RPC API request (pre-encoding)" 12 | @type t :: {String.t(), [String.t() | map]} 13 | 14 | @typedoc "JSON-RPC API request (JSON encoding)" 15 | @type json :: %{ 16 | jsonrpc: String.t(), 17 | id: term, 18 | method: String.t(), 19 | params: list 20 | } 21 | 22 | @doc """ 23 | Encodes a `t:Solana.RPC.Request.t/0` (or a list of them) in the [required 24 | format](https://docs.solana.com/developing/clients/jsonrpc-api#request-formatting). 25 | """ 26 | @spec encode(requests :: [t]) :: [json] 27 | def encode(requests) when is_list(requests) do 28 | requests 29 | |> Enum.with_index() 30 | |> Enum.map(&to_json_rpc/1) 31 | end 32 | 33 | @spec encode(request :: t) :: json 34 | def encode(request), do: to_json_rpc({request, 0}) 35 | 36 | defp to_json_rpc({{method, []}, id}) do 37 | %{jsonrpc: "2.0", id: id, method: method} 38 | end 39 | 40 | defp to_json_rpc({{method, params}, id}) do 41 | %{jsonrpc: "2.0", id: id, method: method, params: check_params(params)} 42 | end 43 | 44 | defp check_params([]), do: [] 45 | defp check_params([map = %{} | rest]) when map_size(map) == 0, do: check_params(rest) 46 | defp check_params([elem | rest]), do: [elem | check_params(rest)] 47 | 48 | @doc """ 49 | Returns all information associated with the account of the provided Pubkey. 50 | 51 | For more information, see [the Solana 52 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#getaccountinfo). 53 | """ 54 | @spec get_account_info(account :: Solana.key(), opts :: keyword) :: t 55 | def get_account_info(account, opts \\ []) do 56 | {"getAccountInfo", [B58.encode58(account), encode_opts(opts, %{"encoding" => "base64"})]} 57 | end 58 | 59 | @doc """ 60 | Returns the balance of the provided pubkey's account. 61 | 62 | For more information, see [the Solana 63 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#getbalance). 64 | """ 65 | @spec get_balance(account :: Solana.key(), opts :: keyword) :: t 66 | def get_balance(account, opts \\ []) do 67 | {"getBalance", [B58.encode58(account), encode_opts(opts)]} 68 | end 69 | 70 | @doc """ 71 | Returns identity and transaction information about a confirmed block in the 72 | ledger. 73 | 74 | For more information, see [the Solana 75 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#getblock). 76 | """ 77 | @spec get_block(start_slot :: non_neg_integer, opts :: keyword) :: t 78 | def get_block(start_slot, opts \\ []) do 79 | {"getBlock", [start_slot, encode_opts(opts)]} 80 | end 81 | 82 | @doc """ 83 | Returns the latest blockhash. 84 | 85 | For more information, see [the Solana 86 | docs](https://solana.com/docs/rpc/http/getlatestblockhash). 87 | """ 88 | @spec get_latest_blockhash(opts :: keyword) :: t 89 | def get_latest_blockhash(opts \\ []) do 90 | {"getLatestBlockhash", [encode_opts(opts)]} 91 | end 92 | 93 | @doc """ 94 | Returns a recent block hash from the ledger, and a fee schedule that can be 95 | used to compute the cost of submitting a transaction using it. 96 | 97 | For more information, see [the Solana 98 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#getrecentblockhash). 99 | """ 100 | @deprecated "Use Solana.RPC.Request.get_latest_blockhash/1 instead" 101 | @spec get_recent_blockhash(opts :: keyword) :: t 102 | def get_recent_blockhash(opts \\ []) do 103 | {"getRecentBlockhash", [encode_opts(opts)]} 104 | end 105 | 106 | @doc """ 107 | Returns minimum balance required to make an account rent exempt. 108 | 109 | For more information, see [the Solana 110 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#getminimumbalanceforrentexemption). 111 | """ 112 | @spec get_minimum_balance_for_rent_exemption(length :: non_neg_integer, opts :: keyword) :: t 113 | def get_minimum_balance_for_rent_exemption(length, opts \\ []) do 114 | {"getMinimumBalanceForRentExemption", [length, encode_opts(opts)]} 115 | end 116 | 117 | @doc """ 118 | Submits a signed transaction to the cluster for processing. 119 | 120 | For more information, see [the Solana 121 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#sendtransaction). 122 | """ 123 | @spec send_transaction(transaction :: Solana.Transaction.t(), opts :: keyword) :: t 124 | def send_transaction(tx = %Solana.Transaction{}, opts \\ []) do 125 | {:ok, tx_bin} = Solana.Transaction.to_binary(tx) 126 | opts = opts |> fix_tx_opts() |> encode_opts(%{"encoding" => "base64"}) 127 | {"sendTransaction", [Base.encode64(tx_bin), opts]} 128 | end 129 | 130 | defp fix_tx_opts(opts) do 131 | opts 132 | |> Enum.map(fn 133 | {:commitment, commitment} -> {:preflight_commitment, commitment} 134 | other -> other 135 | end) 136 | |> Enum.into([]) 137 | end 138 | 139 | @doc """ 140 | Requests an airdrop of lamports to an account. 141 | 142 | For more information, see [the Solana 143 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#requestairdrop). 144 | """ 145 | @spec request_airdrop(account :: Solana.key(), sol :: pos_integer, opts :: keyword) :: t 146 | def request_airdrop(account, sol, opts \\ []) do 147 | {"requestAirdrop", 148 | [B58.encode58(account), sol * Solana.lamports_per_sol(), encode_opts(opts)]} 149 | end 150 | 151 | @doc """ 152 | Returns confirmed signatures for transactions involving an address backwards 153 | in time from the provided signature or most recent confirmed block. 154 | 155 | For more information, see [the Solana 156 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#getsignaturesforaddress). 157 | """ 158 | @spec get_signatures_for_address(account :: Solana.key(), opts :: keyword) :: t 159 | def get_signatures_for_address(account, opts \\ []) do 160 | {"getSignaturesForAddress", [B58.encode58(account), encode_opts(opts)]} 161 | end 162 | 163 | @doc """ 164 | Returns the statuses of a list of signatures. 165 | 166 | Unless the `searchTransactionHistory` configuration parameter is included, 167 | this method only searches the recent status cache of signatures, which retains 168 | statuses for all active slots plus `MAX_RECENT_BLOCKHASHES` rooted slots. 169 | 170 | For more information, see [the Solana 171 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#getsignaturestatuses). 172 | """ 173 | @spec get_signature_statuses(signatures :: [Solana.key()], opts :: keyword) :: t 174 | def get_signature_statuses(signatures, opts \\ []) when is_list(signatures) do 175 | {"getSignatureStatuses", [Enum.map(signatures, &B58.encode58/1), encode_opts(opts)]} 176 | end 177 | 178 | @doc """ 179 | Returns transaction details for a confirmed transaction. 180 | 181 | For more information, see [the Solana 182 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#gettransaction). 183 | """ 184 | @spec get_transaction(signature :: Solana.key(), opts :: keyword) :: t 185 | def get_transaction(signature, opts \\ []) do 186 | {"getTransaction", [B58.encode58(signature), encode_opts(opts)]} 187 | end 188 | 189 | @doc """ 190 | Returns the total supply of an SPL Token. 191 | 192 | For more information, see [the Solana 193 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#gettokensupply). 194 | """ 195 | @spec get_token_supply(mint :: Solana.key(), opts :: keyword) :: t 196 | def get_token_supply(mint, opts \\ []) do 197 | {"getTokenSupply", [B58.encode58(mint), encode_opts(opts)]} 198 | end 199 | 200 | @doc """ 201 | Returns the 20 largest accounts of a particular SPL Token type. 202 | 203 | For more information, see [the Solana 204 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#gettokenlargestaccounts). 205 | """ 206 | @spec get_token_largest_accounts(mint :: Solana.key(), opts :: keyword) :: t 207 | def get_token_largest_accounts(mint, opts \\ []) do 208 | {"getTokenLargestAccounts", [B58.encode58(mint), encode_opts(opts)]} 209 | end 210 | 211 | @doc """ 212 | Returns the account information for a list of pubkeys. 213 | 214 | For more information, see [the Solana 215 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#getmultipleaccounts). 216 | """ 217 | @spec get_multiple_accounts(accounts :: [Solana.key()], opts :: keyword) :: t 218 | def get_multiple_accounts(accounts, opts \\ []) when is_list(accounts) do 219 | {"getMultipleAccounts", 220 | [Enum.map(accounts, &B58.encode58/1), encode_opts(opts, %{"encoding" => "base64"})]} 221 | end 222 | 223 | defp encode_opts(opts, defaults \\ %{}) do 224 | Enum.into(opts, defaults, fn {k, v} -> {camelize(k), encode_value(v)} end) 225 | end 226 | 227 | defp camelize(word) do 228 | case Regex.split(~r/(?:^|[-_])|(?=[A-Z])/, to_string(word)) do 229 | words -> 230 | words 231 | |> Enum.filter(&(&1 != "")) 232 | |> camelize_list(:lower) 233 | |> Enum.join() 234 | end 235 | end 236 | 237 | defp camelize_list([], _), do: [] 238 | 239 | defp camelize_list([h | tail], :lower) do 240 | [String.downcase(h)] ++ camelize_list(tail, :upper) 241 | end 242 | 243 | defp camelize_list([h | tail], :upper) do 244 | [String.capitalize(h)] ++ camelize_list(tail, :upper) 245 | end 246 | 247 | defp encode_value(v) do 248 | cond do 249 | :ok == elem(Solana.Key.check(v), 0) -> B58.encode58(v) 250 | :ok == elem(Solana.Transaction.check(v), 0) -> B58.encode58(v) 251 | true -> v 252 | end 253 | end 254 | end 255 | -------------------------------------------------------------------------------- /lib/solana/system_program.ex: -------------------------------------------------------------------------------- 1 | defmodule Solana.SystemProgram do 2 | @moduledoc """ 3 | Functions for interacting with Solana's [System 4 | Program](https://docs.solana.com/developing/runtime-facilities/programs#system-program) 5 | """ 6 | 7 | alias Solana.{Instruction, Account} 8 | import Solana.Helpers 9 | 10 | @doc """ 11 | The System Program's program ID. 12 | """ 13 | def id(), do: Solana.pubkey!("11111111111111111111111111111111") 14 | 15 | @create_account_schema [ 16 | lamports: [ 17 | type: :pos_integer, 18 | required: true, 19 | doc: "Amount of lamports to transfer to the created account" 20 | ], 21 | space: [ 22 | type: :non_neg_integer, 23 | required: true, 24 | doc: "Amount of space in bytes to allocate to the created account" 25 | ], 26 | from: [ 27 | type: {:custom, Solana.Key, :check, []}, 28 | required: true, 29 | doc: "The account that will transfer lamports to the created account" 30 | ], 31 | new: [ 32 | type: {:custom, Solana.Key, :check, []}, 33 | required: true, 34 | doc: "Public key of the created account" 35 | ], 36 | program_id: [ 37 | type: {:custom, Solana.Key, :check, []}, 38 | required: true, 39 | doc: "Public key of the program which will own the created account" 40 | ], 41 | base: [ 42 | type: {:custom, Solana.Key, :check, []}, 43 | doc: "Base public key to use to derive the created account's address" 44 | ], 45 | seed: [ 46 | type: :string, 47 | doc: "Seed to use to derive the created account's address" 48 | ] 49 | ] 50 | @doc """ 51 | Generates instructions to create a new account. 52 | 53 | Accepts a `new` address generated via `Solana.Key.with_seed/3`, as long as the 54 | `base` key and `seed` used to generate that address are provided. 55 | 56 | ## Options 57 | 58 | #{NimbleOptions.docs(@create_account_schema)} 59 | """ 60 | def create_account(opts) do 61 | case validate(opts, @create_account_schema) do 62 | {:ok, params} -> 63 | maybe_with_seed( 64 | params, 65 | &create_account_ix/1, 66 | &create_account_with_seed_ix/1, 67 | [:base, :seed] 68 | ) 69 | 70 | error -> 71 | error 72 | end 73 | end 74 | 75 | @transfer_schema [ 76 | lamports: [ 77 | type: :pos_integer, 78 | required: true, 79 | doc: "Amount of lamports to transfer" 80 | ], 81 | from: [ 82 | type: {:custom, Solana.Key, :check, []}, 83 | required: true, 84 | doc: "Account that will transfer lamports" 85 | ], 86 | to: [ 87 | type: {:custom, Solana.Key, :check, []}, 88 | required: true, 89 | doc: "Account that will receive the transferred lamports" 90 | ], 91 | base: [ 92 | type: {:custom, Solana.Key, :check, []}, 93 | doc: "Base public key to use to derive the funding account address" 94 | ], 95 | seed: [ 96 | type: :string, 97 | doc: "Seed to use to derive the funding account address" 98 | ], 99 | program_id: [ 100 | type: {:custom, Solana.Key, :check, []}, 101 | doc: "Program ID to use to derive the funding account address" 102 | ] 103 | ] 104 | @doc """ 105 | Generates instructions to transfer lamports from one account to another. 106 | 107 | Accepts a `from` address generated via `Solana.Key.with_seed/3`, as long as the 108 | `base` key, `program_id`, and `seed` used to generate that address are 109 | provided. 110 | 111 | ## Options 112 | 113 | #{NimbleOptions.docs(@transfer_schema)} 114 | """ 115 | def transfer(opts) do 116 | case validate(opts, @transfer_schema) do 117 | {:ok, params} -> 118 | maybe_with_seed( 119 | params, 120 | &transfer_ix/1, 121 | &transfer_with_seed_ix/1 122 | ) 123 | 124 | error -> 125 | error 126 | end 127 | end 128 | 129 | @assign_schema [ 130 | account: [ 131 | type: {:custom, Solana.Key, :check, []}, 132 | required: true, 133 | doc: "Public key for the account which will receive a new owner" 134 | ], 135 | program_id: [ 136 | type: {:custom, Solana.Key, :check, []}, 137 | required: true, 138 | doc: "Program ID to assign as the owner" 139 | ], 140 | base: [ 141 | type: {:custom, Solana.Key, :check, []}, 142 | doc: "Base public key to use to derive the assigned account address" 143 | ], 144 | seed: [ 145 | type: :string, 146 | doc: "Seed to use to derive the assigned account address" 147 | ] 148 | ] 149 | @doc """ 150 | Generates instructions to assign account ownership to a program. 151 | 152 | Accepts an `account` address generated via `Solana.Key.with_seed/3`, as long 153 | as the `base` key and `seed` used to generate that address are provided. 154 | 155 | ## Options 156 | 157 | #{NimbleOptions.docs(@assign_schema)} 158 | """ 159 | def assign(opts) do 160 | case validate(opts, @assign_schema) do 161 | {:ok, params} -> 162 | maybe_with_seed( 163 | params, 164 | &assign_ix/1, 165 | &assign_with_seed_ix/1, 166 | [:base, :seed] 167 | ) 168 | 169 | error -> 170 | error 171 | end 172 | end 173 | 174 | @allocate_schema [ 175 | account: [ 176 | type: {:custom, Solana.Key, :check, []}, 177 | required: true, 178 | doc: "Public key for the account to allocate" 179 | ], 180 | space: [ 181 | type: :non_neg_integer, 182 | required: true, 183 | doc: "Amount of space in bytes to allocate" 184 | ], 185 | program_id: [ 186 | type: {:custom, Solana.Key, :check, []}, 187 | doc: "Program ID to assign as the owner of the allocated account" 188 | ], 189 | base: [ 190 | type: {:custom, Solana.Key, :check, []}, 191 | doc: "Base public key to use to derive the allocated account address" 192 | ], 193 | seed: [ 194 | type: :string, 195 | doc: "Seed to use to derive the allocated account address" 196 | ] 197 | ] 198 | @doc """ 199 | Generates instructions to allocate space to an account. 200 | 201 | Accepts an `account` address generated via `Solana.Key.with_seed/3`, as long 202 | as the `base` key, `program_id`, and `seed` used to generate that address are 203 | provided. 204 | 205 | ## Options 206 | 207 | #{NimbleOptions.docs(@allocate_schema)} 208 | """ 209 | def allocate(opts) do 210 | case validate(opts, @allocate_schema) do 211 | {:ok, params} -> 212 | maybe_with_seed( 213 | params, 214 | &allocate_ix/1, 215 | &allocate_with_seed_ix/1, 216 | [:base, :seed] 217 | ) 218 | 219 | error -> 220 | error 221 | end 222 | end 223 | 224 | defp maybe_with_seed(opts, ix_fn, ix_seed_fn, keys \\ [:base, :seed, :program_id]) do 225 | key_check = Enum.map(keys, &Map.has_key?(opts, &1)) 226 | 227 | cond do 228 | Enum.all?(key_check) -> ix_seed_fn.(opts) 229 | !Enum.any?(key_check) -> ix_fn.(opts) 230 | true -> {:error, :missing_seed_params} 231 | end 232 | end 233 | 234 | defp create_account_ix(params) do 235 | %Instruction{ 236 | program: id(), 237 | accounts: [ 238 | %Account{key: params.from, signer?: true, writable?: true}, 239 | %Account{key: params.new, signer?: true, writable?: true} 240 | ], 241 | data: 242 | Instruction.encode_data([ 243 | {0, 32}, 244 | {params.lamports, 64}, 245 | {params.space, 64}, 246 | params.program_id 247 | ]) 248 | } 249 | end 250 | 251 | defp create_account_with_seed_ix(params) do 252 | %Instruction{ 253 | program: id(), 254 | accounts: create_account_with_seed_accounts(params), 255 | data: 256 | Instruction.encode_data([ 257 | {3, 32}, 258 | params.base, 259 | {params.seed, "str"}, 260 | {params.lamports, 64}, 261 | {params.space, 64}, 262 | params.program_id 263 | ]) 264 | } 265 | end 266 | 267 | defp create_account_with_seed_accounts(params = %{from: from, base: from}) do 268 | [ 269 | %Account{key: from, signer?: true, writable?: true}, 270 | %Account{key: params.new, writable?: true} 271 | ] 272 | end 273 | 274 | defp create_account_with_seed_accounts(params) do 275 | [ 276 | %Account{key: params.from, signer?: true, writable?: true}, 277 | %Account{key: params.new, writable?: true}, 278 | %Account{key: params.base, signer?: true} 279 | ] 280 | end 281 | 282 | defp transfer_ix(params) do 283 | %Instruction{ 284 | program: id(), 285 | accounts: [ 286 | %Account{key: params.from, signer?: true, writable?: true}, 287 | %Account{key: params.to, writable?: true} 288 | ], 289 | data: Instruction.encode_data([{2, 32}, {params.lamports, 64}]) 290 | } 291 | end 292 | 293 | defp transfer_with_seed_ix(params) do 294 | %Instruction{ 295 | program: id(), 296 | accounts: [ 297 | %Account{key: params.from, writable?: true}, 298 | %Account{key: params.base, signer?: true}, 299 | %Account{key: params.to, writable?: true} 300 | ], 301 | data: 302 | Instruction.encode_data([ 303 | {11, 32}, 304 | {params.lamports, 64}, 305 | {params.seed, "str"}, 306 | params.program_id 307 | ]) 308 | } 309 | end 310 | 311 | defp assign_ix(params) do 312 | %Instruction{ 313 | program: id(), 314 | accounts: [ 315 | %Account{key: params.account, signer?: true, writable?: true} 316 | ], 317 | data: Instruction.encode_data([{1, 32}, params.program_id]) 318 | } 319 | end 320 | 321 | defp assign_with_seed_ix(params) do 322 | %Instruction{ 323 | program: id(), 324 | accounts: [ 325 | %Account{key: params.account, writable?: true}, 326 | %Account{key: params.base, signer?: true} 327 | ], 328 | data: 329 | Instruction.encode_data([ 330 | {10, 32}, 331 | params.base, 332 | {params.seed, "str"}, 333 | params.program_id 334 | ]) 335 | } 336 | end 337 | 338 | defp allocate_ix(params) do 339 | %Instruction{ 340 | program: id(), 341 | accounts: [ 342 | %Account{key: params.account, signer?: true, writable?: true} 343 | ], 344 | data: Instruction.encode_data([{8, 32}, {params.space, 64}]) 345 | } 346 | end 347 | 348 | defp allocate_with_seed_ix(params) do 349 | %Instruction{ 350 | program: id(), 351 | accounts: [ 352 | %Account{key: params.account, writable?: true}, 353 | %Account{key: params.base, signer?: true} 354 | ], 355 | data: 356 | Instruction.encode_data([ 357 | {9, 32}, 358 | params.base, 359 | {params.seed, "str"}, 360 | {params.space, 64}, 361 | params.program_id 362 | ]) 363 | } 364 | end 365 | end 366 | -------------------------------------------------------------------------------- /lib/solana/tx.ex: -------------------------------------------------------------------------------- 1 | defmodule Solana.Transaction do 2 | @moduledoc """ 3 | Functions for building and encoding Solana 4 | [transactions](https://docs.solana.com/developing/programming-model/transactions) 5 | """ 6 | require Logger 7 | alias Solana.{Account, CompactArray, Instruction} 8 | 9 | @typedoc """ 10 | All the details needed to encode a transaction. 11 | """ 12 | @type t :: %__MODULE__{ 13 | payer: Solana.key() | nil, 14 | blockhash: binary | nil, 15 | instructions: [Instruction.t()], 16 | signers: [Solana.keypair()] 17 | } 18 | 19 | @typedoc """ 20 | The possible errors encountered when encoding a transaction. 21 | """ 22 | @type encoding_err :: 23 | :no_payer 24 | | :no_blockhash 25 | | :no_program 26 | | :no_instructions 27 | | :mismatched_signers 28 | 29 | defstruct [ 30 | :payer, 31 | :blockhash, 32 | instructions: [], 33 | signers: [] 34 | ] 35 | 36 | @doc """ 37 | decodes a base58-encoded signature and returns it in a tuple. 38 | 39 | If it fails, return an error tuple. 40 | """ 41 | @spec decode(encoded :: binary) :: {:ok, binary} | {:error, binary} 42 | def decode(encoded) when is_binary(encoded) do 43 | case B58.decode58(encoded) do 44 | {:ok, decoded} -> check(decoded) 45 | _ -> {:error, "invalid signature"} 46 | end 47 | end 48 | 49 | def decode(_), do: {:error, "invalid signature"} 50 | 51 | @doc """ 52 | decodes a base58-encoded signature and returns it. 53 | 54 | Throws an `ArgumentError` if it fails. 55 | """ 56 | @spec decode!(encoded :: binary) :: binary 57 | def decode!(encoded) when is_binary(encoded) do 58 | case decode(encoded) do 59 | {:ok, key} -> 60 | key 61 | 62 | {:error, _} -> 63 | raise ArgumentError, "invalid signature input: #{encoded}" 64 | end 65 | end 66 | 67 | @doc """ 68 | Checks to see if a transaction's signature is valid. 69 | 70 | Returns `{:ok, signature}` if it is, and an error tuple if it isn't. 71 | """ 72 | @spec check(binary) :: {:ok, binary} | {:error, :invalid_signature} 73 | def check(signature) 74 | def check(<>), do: {:ok, signature} 75 | def check(_), do: {:error, :invalid_signature} 76 | 77 | @doc """ 78 | Encodes a `t:Solana.Transaction.t/0` into a [binary 79 | format](https://docs.solana.com/developing/programming-model/transactions#anatomy-of-a-transaction) 80 | 81 | Options: 82 | - `:require_all_signatures?` (default: `true`) — when `false`, allows 83 | serialization without providing all required signer keypairs; any missing 84 | signatures will be encoded as 64 zero bytes in the signatures array. This 85 | mirrors the JavaScript solana/web3.js `serialize({ requireAllSignatures })` 86 | behavior. 87 | 88 | Returns `{:ok, encoded_transaction}` if the transaction was successfully 89 | encoded, or an error tuple if the encoding failed -- plus more error details 90 | via `Logger.error/1`. 91 | """ 92 | @spec to_binary(tx :: t, opts :: keyword) :: {:ok, binary()} | {:error, encoding_err()} 93 | def to_binary(tx, opts \\ []) 94 | def to_binary(%__MODULE__{payer: nil}, _opts), do: {:error, :no_payer} 95 | def to_binary(%__MODULE__{blockhash: nil}, _opts), do: {:error, :no_blockhash} 96 | def to_binary(%__MODULE__{instructions: []}, _opts), do: {:error, :no_instructions} 97 | 98 | def to_binary(tx = %__MODULE__{instructions: ixs, signers: signers}, opts) do 99 | with {:ok, ixs} <- check_instructions(List.flatten(ixs)), 100 | accounts = compile_accounts(ixs, tx.payer), 101 | true <- 102 | signers_match?(accounts, signers, Keyword.get(opts, :require_all_signatures?, true)) do 103 | message = encode_message(accounts, tx.blockhash, ixs) 104 | 105 | signatures = 106 | accounts 107 | |> Enum.filter(& &1.signer?) 108 | |> Enum.map(& &1.key) 109 | |> build_signatures(signers, message) 110 | |> CompactArray.to_iolist() 111 | 112 | {:ok, :erlang.list_to_binary([signatures, message])} 113 | else 114 | {:error, :no_program, idx} -> 115 | Logger.error("Missing program id on instruction at index #{idx}") 116 | {:error, :no_program} 117 | 118 | {:error, message, idx} -> 119 | Logger.error("error compiling instruction at index #{idx}: #{inspect(message)}") 120 | {:error, message} 121 | 122 | false -> 123 | {:error, :mismatched_signers} 124 | end 125 | end 126 | 127 | defp check_instructions(ixs) do 128 | ixs 129 | |> Enum.with_index() 130 | |> Enum.reduce_while({:ok, ixs}, fn 131 | {{:error, message}, idx}, _ -> {:halt, {:error, message, idx}} 132 | {%{program: nil}, idx}, _ -> {:halt, {:error, :no_program, idx}} 133 | _, acc -> {:cont, acc} 134 | end) 135 | end 136 | 137 | # https://docs.solana.com/developing/programming-model/transactions#account-addresses-format 138 | defp compile_accounts(ixs, payer) do 139 | ixs 140 | |> Enum.map(fn ix -> [%Account{key: ix.program} | ix.accounts] end) 141 | |> List.flatten() 142 | |> Enum.reject(&(&1.key == payer)) 143 | |> Enum.sort_by(&{&1.signer?, &1.writable?}, &>=/2) 144 | |> Enum.uniq_by(& &1.key) 145 | |> cons(%Account{writable?: true, signer?: true, key: payer}) 146 | end 147 | 148 | defp cons(list, item), do: [item | list] 149 | 150 | defp signers_match?(accounts, signers, true = _require_all_signatures) do 151 | expected = MapSet.new(Enum.map(signers, &elem(&1, 1))) 152 | 153 | accounts 154 | |> Enum.filter(& &1.signer?) 155 | |> Enum.map(& &1.key) 156 | |> MapSet.new() 157 | |> MapSet.equal?(expected) 158 | end 159 | 160 | defp signers_match?(_accounts, _signers, _require_all_signatures?) do 161 | true 162 | end 163 | 164 | # https://docs.solana.com/developing/programming-model/transactions#message-format 165 | defp encode_message(accounts, blockhash, ixs) do 166 | [ 167 | create_header(accounts), 168 | CompactArray.to_iolist(Enum.map(accounts, & &1.key)), 169 | blockhash, 170 | CompactArray.to_iolist(encode_instructions(ixs, accounts)) 171 | ] 172 | |> :erlang.list_to_binary() 173 | end 174 | 175 | # https://docs.solana.com/developing/programming-model/transactions#message-header-format 176 | defp create_header(accounts) do 177 | accounts 178 | |> Enum.reduce( 179 | {0, 0, 0}, 180 | &{ 181 | unary(&1.signer?) + elem(&2, 0), 182 | unary(&1.signer? && !&1.writable?) + elem(&2, 1), 183 | unary(!&1.signer? && !&1.writable?) + elem(&2, 2) 184 | } 185 | ) 186 | |> Tuple.to_list() 187 | end 188 | 189 | defp unary(result?), do: if(result?, do: 1, else: 0) 190 | 191 | # https://docs.solana.com/developing/programming-model/transactions#instruction-format 192 | defp encode_instructions(ixs, accounts) do 193 | idxs = index_accounts(accounts) 194 | 195 | Enum.map(ixs, fn ix = %Instruction{} -> 196 | [ 197 | Map.get(idxs, ix.program), 198 | CompactArray.to_iolist(Enum.map(ix.accounts, &Map.get(idxs, &1.key))), 199 | CompactArray.to_iolist(ix.data) 200 | ] 201 | end) 202 | end 203 | 204 | defp index_accounts(accounts) do 205 | Enum.into(Enum.with_index(accounts, &{&1.key, &2}), %{}) 206 | end 207 | 208 | defp sign({secret, pk}, message), do: Ed25519.signature(message, secret, pk) 209 | 210 | defp build_signatures(required_signer_pks, provided_signers, message) do 211 | signer_map = Map.new(provided_signers, fn {sk, pk} -> {pk, {sk, pk}} end) 212 | 213 | Enum.map(required_signer_pks, fn pk -> 214 | case Map.get(signer_map, pk) do 215 | nil -> 216 | # If not all signers are required, emit a zeroed signature for those not provided 217 | <<0::size(512)>> 218 | 219 | kp -> 220 | sign(kp, message) 221 | end 222 | end) 223 | end 224 | 225 | @doc """ 226 | Parses a `t:Solana.Transaction.t/0` from data encoded in Solana's [binary 227 | format](https://docs.solana.com/developing/programming-model/transactions#anatomy-of-a-transaction) 228 | 229 | Returns `{transaction, extras}` if the transaction was successfully 230 | parsed, or `:error` if the provided binary could not be parsed. `extras` 231 | is a keyword list containing information about the encoded transaction, 232 | namely: 233 | 234 | - `:header` - the [transaction message 235 | header](https://docs.solana.com/developing/programming-model/transactions#message-header-format) 236 | - `:accounts` - an [ordered array of 237 | accounts](https://docs.solana.com/developing/programming-model/transactions#account-addresses-format) 238 | - `:signatures` - a [list of signed copies of the transaction 239 | message](https://docs.solana.com/developing/programming-model/transactions#signatures) 240 | """ 241 | @spec parse(encoded :: binary) :: {t(), keyword} | :error 242 | def parse(encoded) do 243 | with {signatures, message, _} <- CompactArray.decode_and_split(encoded, 64), 244 | <> <- message, 245 | {account_keys, hash_and_ixs, key_count} <- CompactArray.decode_and_split(contents, 32), 246 | <> <- hash_and_ixs, 247 | {:ok, instructions} <- extract_instructions(ix_data) do 248 | tx_accounts = derive_accounts(account_keys, key_count, header) 249 | indices = Enum.into(Enum.with_index(tx_accounts, &{&2, &1}), %{}) 250 | 251 | { 252 | %__MODULE__{ 253 | payer: tx_accounts |> List.first() |> Map.get(:key), 254 | blockhash: blockhash, 255 | instructions: 256 | Enum.map(instructions, fn {program, accounts, data} -> 257 | %Instruction{ 258 | data: if(data == "", do: nil, else: :binary.list_to_bin(data)), 259 | program: Map.get(indices, program) |> Map.get(:key), 260 | accounts: Enum.map(accounts, &Map.get(indices, &1)) 261 | } 262 | end) 263 | }, 264 | [ 265 | accounts: tx_accounts, 266 | header: header, 267 | signatures: signatures 268 | ] 269 | } 270 | else 271 | _ -> :error 272 | end 273 | end 274 | 275 | defp extract_instructions(data) do 276 | with {ix_data, ix_count} <- CompactArray.decode_and_split(data), 277 | {reversed_ixs, ""} <- extract_instructions(ix_data, ix_count) do 278 | {:ok, Enum.reverse(reversed_ixs)} 279 | else 280 | error -> error 281 | end 282 | end 283 | 284 | defp extract_instructions(data, count) do 285 | Enum.reduce_while(1..count, {[], data}, fn _, {acc, raw} -> 286 | case extract_instruction(raw) do 287 | {ix, rest} -> {:cont, {[ix | acc], rest}} 288 | _ -> {:halt, :error} 289 | end 290 | end) 291 | end 292 | 293 | defp extract_instruction(raw) do 294 | with <> <- raw, 295 | {accounts, rest, _} <- CompactArray.decode_and_split(rest, 1), 296 | {data, rest, _} <- extract_instruction_data(rest) do 297 | {{program, Enum.map(accounts, &:binary.decode_unsigned/1), data}, rest} 298 | else 299 | _ -> :error 300 | end 301 | end 302 | 303 | defp extract_instruction_data(""), do: {"", "", 0} 304 | defp extract_instruction_data(raw), do: CompactArray.decode_and_split(raw, 1) 305 | 306 | defp derive_accounts(keys, total, header) do 307 | <> = header 308 | {signers, nonsigners} = Enum.split(keys, signers_count) 309 | {signers_write, signers_read} = Enum.split(signers, signers_count - signers_readonly_count) 310 | 311 | {nonsigners_write, nonsigners_read} = 312 | Enum.split(nonsigners, total - signers_count - nonsigners_readonly_count) 313 | 314 | List.flatten([ 315 | Enum.map(signers_write, &%Account{key: &1, writable?: true, signer?: true}), 316 | Enum.map(signers_read, &%Account{key: &1, signer?: true}), 317 | Enum.map(nonsigners_write, &%Account{key: &1, writable?: true}), 318 | Enum.map(nonsigners_read, &%Account{key: &1}) 319 | ]) 320 | end 321 | end 322 | -------------------------------------------------------------------------------- /test/solana/tx_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solana.TransactionTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ExUnit.CaptureLog 5 | import Solana, only: [pubkey!: 1] 6 | 7 | alias Solana.{Transaction, Instruction, Account} 8 | 9 | describe "to_binary/2" do 10 | test "fails if there's no blockhash" do 11 | payer = Solana.keypair() 12 | program = Solana.keypair() |> pubkey!() 13 | 14 | ix = %Instruction{ 15 | program: program, 16 | accounts: [ 17 | %Account{signer?: true, writable?: true, key: pubkey!(payer)} 18 | ] 19 | } 20 | 21 | tx = %Transaction{payer: pubkey!(payer), instructions: [ix], signers: [payer]} 22 | assert Transaction.to_binary(tx) == {:error, :no_blockhash} 23 | end 24 | 25 | test "fails if there's no payer" do 26 | blockhash = Solana.keypair() |> pubkey!() 27 | program = Solana.keypair() |> pubkey!() 28 | 29 | ix = %Instruction{ 30 | program: program, 31 | accounts: [ 32 | %Account{key: blockhash} 33 | ] 34 | } 35 | 36 | tx = %Transaction{instructions: [ix], blockhash: blockhash} 37 | assert Transaction.to_binary(tx) == {:error, :no_payer} 38 | end 39 | 40 | test "fails if there's no instructions" do 41 | payer = Solana.keypair() 42 | blockhash = Solana.keypair() |> pubkey!() 43 | tx = %Transaction{payer: pubkey!(payer), blockhash: blockhash} 44 | assert Transaction.to_binary(tx) == {:error, :no_instructions} 45 | end 46 | 47 | test "fails if an instruction doesn't have a program" do 48 | blockhash = Solana.keypair() |> pubkey!() 49 | payer = Solana.keypair() 50 | 51 | ix = %Instruction{ 52 | accounts: [ 53 | %Account{key: pubkey!(payer), writable?: true, signer?: true} 54 | ] 55 | } 56 | 57 | tx = %Transaction{ 58 | payer: pubkey!(payer), 59 | instructions: [ix], 60 | blockhash: blockhash, 61 | signers: [payer] 62 | } 63 | 64 | assert capture_log(fn -> Transaction.to_binary(tx) end) =~ "index 0" 65 | end 66 | 67 | test "fails if a signer is missing or if there's unnecessary signers" do 68 | blockhash = Solana.keypair() |> pubkey!() 69 | program = Solana.keypair() |> pubkey!() 70 | payer = Solana.keypair() 71 | signer = Solana.keypair() 72 | 73 | ix = %Instruction{ 74 | program: program, 75 | accounts: [ 76 | %Account{key: pubkey!(payer), writable?: true, signer?: true} 77 | ] 78 | } 79 | 80 | tx = %Transaction{payer: pubkey!(payer), instructions: [ix], blockhash: blockhash} 81 | assert Transaction.to_binary(tx) == {:error, :mismatched_signers} 82 | 83 | assert Transaction.to_binary(%{tx | signers: [payer, signer]}) == 84 | {:error, :mismatched_signers} 85 | end 86 | 87 | test "adds zeroed signatures in place of those not provided if set require_all_signatures? to false" do 88 | blockhash = Solana.keypair() |> pubkey!() 89 | program = Solana.keypair() |> pubkey!() 90 | payer = Solana.keypair() 91 | 92 | ix = %Instruction{ 93 | program: program, 94 | accounts: [ 95 | %Account{key: pubkey!(payer), writable?: true, signer?: true} 96 | ] 97 | } 98 | 99 | tx = %Transaction{payer: pubkey!(payer), instructions: [ix], blockhash: blockhash} 100 | {:ok, tx_bin} = Transaction.to_binary(tx, require_all_signatures?: false) 101 | {_, extras} = Transaction.parse(tx_bin) 102 | 103 | assert [<<0::512>>] == Keyword.fetch!(extras, :signatures) 104 | end 105 | 106 | test "places accounts in order (payer first)" do 107 | payer = Solana.keypair() 108 | signer = Solana.keypair() 109 | read_only = Solana.keypair() 110 | program = Solana.keypair() |> pubkey!() 111 | blockhash = Solana.keypair() |> pubkey!() 112 | 113 | ix = %Instruction{ 114 | program: program, 115 | accounts: [ 116 | %Account{signer?: true, key: pubkey!(read_only)}, 117 | %Account{signer?: true, writable?: true, key: pubkey!(signer)}, 118 | %Account{signer?: true, writable?: true, key: pubkey!(payer)} 119 | ] 120 | } 121 | 122 | tx = %Transaction{ 123 | payer: pubkey!(payer), 124 | instructions: [ix], 125 | blockhash: blockhash, 126 | signers: [payer, signer, read_only] 127 | } 128 | 129 | {:ok, tx_bin} = Transaction.to_binary(tx) 130 | {_, extras} = Transaction.parse(tx_bin) 131 | 132 | assert [pubkey!(payer), pubkey!(signer), pubkey!(read_only)] == 133 | extras 134 | |> Keyword.get(:accounts) 135 | |> Enum.map(& &1.key) 136 | |> Enum.take(3) 137 | end 138 | 139 | test "payer is writable and a signer" do 140 | payer = Solana.keypair() 141 | read_only = Solana.keypair() 142 | program = Solana.keypair() |> pubkey!() 143 | blockhash = Solana.keypair() |> pubkey!() 144 | 145 | ix = %Instruction{ 146 | program: program, 147 | accounts: [%Account{key: pubkey!(payer)}, %Account{key: pubkey!(read_only)}] 148 | } 149 | 150 | tx = %Transaction{ 151 | payer: pubkey!(payer), 152 | instructions: [ix], 153 | blockhash: blockhash, 154 | signers: [payer] 155 | } 156 | 157 | {:ok, tx_bin} = Transaction.to_binary(tx) 158 | {_, extras} = Transaction.parse(tx_bin) 159 | 160 | [actual_payer | _] = Keyword.get(extras, :accounts) 161 | 162 | assert actual_payer.key == pubkey!(payer) 163 | assert actual_payer.writable? 164 | assert actual_payer.signer? 165 | end 166 | 167 | test "sets up the header correctly" do 168 | payer = Solana.keypair() 169 | writable = Solana.keypair() 170 | signer = Solana.keypair() 171 | read_only = Solana.keypair() 172 | program = Solana.keypair() |> pubkey!() 173 | blockhash = Solana.keypair() |> pubkey!() 174 | 175 | ix = %Instruction{ 176 | program: program, 177 | accounts: [ 178 | %Account{key: pubkey!(read_only)}, 179 | %Account{writable?: true, key: pubkey!(writable)}, 180 | %Account{signer?: true, key: pubkey!(signer)}, 181 | %Account{signer?: true, writable?: true, key: pubkey!(payer)} 182 | ] 183 | } 184 | 185 | tx = %Transaction{ 186 | payer: pubkey!(payer), 187 | instructions: [ix], 188 | blockhash: blockhash, 189 | signers: [payer, signer] 190 | } 191 | 192 | {:ok, tx_bin} = Transaction.to_binary(tx) 193 | {_, extras} = Transaction.parse(tx_bin) 194 | 195 | # 2 signers, one read-only signer, 2 read-only non-signers (read_only and 196 | # program) 197 | assert Keyword.get(extras, :header) == <<2, 1, 2>> 198 | end 199 | 200 | test "dedups signatures and accounts" do 201 | from = Solana.keypair() 202 | to = Solana.keypair() 203 | program = Solana.keypair() |> pubkey!() 204 | blockhash = Solana.keypair() |> pubkey!() 205 | 206 | ix = %Instruction{ 207 | program: program, 208 | accounts: [ 209 | %Account{key: pubkey!(to)}, 210 | %Account{signer?: true, writable?: true, key: pubkey!(from)} 211 | ] 212 | } 213 | 214 | tx = %Transaction{ 215 | payer: pubkey!(from), 216 | instructions: [ix, ix], 217 | blockhash: blockhash, 218 | signers: [from] 219 | } 220 | 221 | {:ok, tx_bin} = Transaction.to_binary(tx) 222 | {_, extras} = Transaction.parse(tx_bin) 223 | 224 | assert [_] = Keyword.get(extras, :signatures) 225 | assert length(Keyword.get(extras, :accounts)) == 3 226 | end 227 | end 228 | 229 | describe "parse/1" do 230 | test "cannot parse an empty string" do 231 | assert :error = Transaction.parse("") 232 | end 233 | 234 | test "cannot parse an improperly encoded transaction" do 235 | payer = Solana.keypair() 236 | signer = Solana.keypair() 237 | read_only = Solana.keypair() 238 | program = Solana.keypair() |> pubkey!() 239 | blockhash = Solana.keypair() |> pubkey!() 240 | 241 | ix = %Instruction{ 242 | program: program, 243 | accounts: [ 244 | %Account{signer?: true, key: pubkey!(read_only)}, 245 | %Account{signer?: true, writable?: true, key: pubkey!(signer)}, 246 | %Account{signer?: true, writable?: true, key: pubkey!(payer)} 247 | ] 248 | } 249 | 250 | tx = %Transaction{ 251 | payer: pubkey!(payer), 252 | instructions: [ix], 253 | blockhash: blockhash, 254 | signers: [payer, signer, read_only] 255 | } 256 | 257 | {:ok, <<_::8, clipped_tx::binary>>} = Transaction.to_binary(tx) 258 | assert :error = Transaction.parse(clipped_tx) 259 | end 260 | 261 | test "can parse a properly encoded tranaction" do 262 | from = Solana.keypair() 263 | to = Solana.keypair() 264 | program = Solana.keypair() |> pubkey!() 265 | blockhash = Solana.keypair() |> pubkey!() 266 | 267 | ix = %Instruction{ 268 | program: program, 269 | accounts: [ 270 | %Account{key: pubkey!(to)}, 271 | %Account{signer?: true, writable?: true, key: pubkey!(from)} 272 | ], 273 | data: <<1, 2, 3>> 274 | } 275 | 276 | tx = %Transaction{ 277 | payer: pubkey!(from), 278 | instructions: [ix, ix], 279 | blockhash: blockhash, 280 | signers: [from] 281 | } 282 | 283 | {:ok, tx_bin} = Transaction.to_binary(tx) 284 | {actual, extras} = Transaction.parse(tx_bin) 285 | 286 | assert [_signature] = Keyword.get(extras, :signatures) 287 | 288 | assert actual.payer == pubkey!(from) 289 | assert actual.instructions == [ix, ix] 290 | assert actual.blockhash == blockhash 291 | end 292 | end 293 | 294 | describe "decode/1" do 295 | test "fails for signatures which are too short" do 296 | encoded = B58.encode58(Enum.into(1..63, <<>>, &<<&1::8>>)) 297 | assert {:error, _} = Transaction.decode(encoded) 298 | assert {:error, _} = Transaction.decode("12345") 299 | end 300 | 301 | test "fails for signatures which are too long" do 302 | encoded = B58.encode58(<<3, 0::64*8>>) 303 | assert {:error, _} = Transaction.decode(encoded) 304 | end 305 | 306 | test "fails for signatures which aren't base58-encoded" do 307 | assert {:error, _} = 308 | Transaction.decode( 309 | "0x300000000000000000000000000000000000000000000000000000000000000000000" 310 | ) 311 | 312 | assert {:error, _} = 313 | Transaction.decode( 314 | "0x300000000000000000000000000000000000000000000000000000000000000" 315 | ) 316 | 317 | assert {:error, _} = 318 | Transaction.decode( 319 | "135693854574979916511997248057056142015550763280047535983739356259273198796800000" 320 | ) 321 | end 322 | 323 | test "works for regular signatures" do 324 | assert {:ok, <<3, 0::63*8>>} = 325 | Transaction.decode( 326 | "4Umk1E47BhUNBHJQGJto6i5xpATqVs8UxW11QjpoVnBmiv7aZJyG78yVYj99SrozRa9x7av8p3GJmBuzvhpUHDZ" 327 | ) 328 | end 329 | end 330 | 331 | describe "decode!/1" do 332 | test "throws for signatures which aren't base58-encoded" do 333 | assert_raise ArgumentError, fn -> 334 | Transaction.decode!( 335 | "0x300000000000000000000000000000000000000000000000000000000000000000000" 336 | ) 337 | end 338 | 339 | assert_raise ArgumentError, fn -> 340 | Transaction.decode!("0x300000000000000000000000000000000000000000000000000000000000000") 341 | end 342 | 343 | assert_raise ArgumentError, fn -> 344 | Transaction.decode!( 345 | "135693854574979916511997248057056142015550763280047535983739356259273198796800000" 346 | ) 347 | end 348 | end 349 | 350 | test "works for regular signatures" do 351 | assert <<3, 0::63*8>> == 352 | Transaction.decode!( 353 | "4Umk1E47BhUNBHJQGJto6i5xpATqVs8UxW11QjpoVnBmiv7aZJyG78yVYj99SrozRa9x7av8p3GJmBuzvhpUHDZ" 354 | ) 355 | end 356 | end 357 | end 358 | -------------------------------------------------------------------------------- /test/solana/system_program_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solana.SystemProgramTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Solana.TestHelpers, only: [create_payer: 3] 5 | import Solana, only: [pubkey!: 1] 6 | 7 | alias Solana.{SystemProgram, RPC, Transaction} 8 | 9 | setup_all do 10 | {:ok, tracker} = RPC.Tracker.start_link(network: "localhost", t: 100) 11 | client = RPC.client(network: "localhost") 12 | {:ok, payer} = create_payer(tracker, client, commitment: "confirmed") 13 | 14 | [tracker: tracker, client: client, payer: payer] 15 | end 16 | 17 | describe "create_account/1" do 18 | test "can create account", %{tracker: tracker, client: client, payer: payer} do 19 | new = Solana.keypair() 20 | 21 | tx_reqs = [ 22 | RPC.Request.get_minimum_balance_for_rent_exemption(0, commitment: "confirmed"), 23 | RPC.Request.get_latest_blockhash(commitment: "confirmed") 24 | ] 25 | 26 | [{:ok, lamports}, {:ok, %{"blockhash" => blockhash}}] = RPC.send(client, tx_reqs) 27 | 28 | tx = %Transaction{ 29 | instructions: [ 30 | SystemProgram.create_account( 31 | lamports: lamports, 32 | space: 0, 33 | program_id: SystemProgram.id(), 34 | from: pubkey!(payer), 35 | new: pubkey!(new) 36 | ) 37 | ], 38 | signers: [payer, new], 39 | blockhash: blockhash, 40 | payer: pubkey!(payer) 41 | } 42 | 43 | {:ok, _signature} = 44 | RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) 45 | 46 | assert {:ok, %{"lamports" => ^lamports}} = 47 | RPC.send( 48 | client, 49 | RPC.Request.get_account_info(pubkey!(new), commitment: "confirmed") 50 | ) 51 | end 52 | 53 | test "can create an account with a seed", %{tracker: tracker, client: client, payer: payer} do 54 | {:ok, new} = Solana.Key.with_seed(pubkey!(payer), "create", SystemProgram.id()) 55 | 56 | tx_reqs = [ 57 | RPC.Request.get_minimum_balance_for_rent_exemption(0, commitment: "confirmed"), 58 | RPC.Request.get_latest_blockhash(commitment: "confirmed") 59 | ] 60 | 61 | [{:ok, lamports}, {:ok, %{"blockhash" => blockhash}}] = RPC.send(client, tx_reqs) 62 | 63 | tx = %Transaction{ 64 | instructions: [ 65 | SystemProgram.create_account( 66 | lamports: lamports, 67 | space: 0, 68 | program_id: SystemProgram.id(), 69 | from: pubkey!(payer), 70 | new: new, 71 | base: pubkey!(payer), 72 | seed: "create" 73 | ) 74 | ], 75 | signers: [payer], 76 | blockhash: blockhash, 77 | payer: pubkey!(payer) 78 | } 79 | 80 | {:ok, _signature} = 81 | RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) 82 | 83 | assert {:ok, %{"lamports" => ^lamports}} = 84 | RPC.send(client, RPC.Request.get_account_info(new, commitment: "confirmed")) 85 | end 86 | end 87 | 88 | describe "transfer/1" do 89 | test "can transfer lamports to an account", %{tracker: tracker, client: client, payer: payer} do 90 | new = Solana.keypair() 91 | space = 0 92 | 93 | tx_reqs = [ 94 | RPC.Request.get_minimum_balance_for_rent_exemption(space, commitment: "confirmed"), 95 | RPC.Request.get_latest_blockhash(commitment: "confirmed") 96 | ] 97 | 98 | [{:ok, lamports}, {:ok, %{"blockhash" => blockhash}}] = RPC.send(client, tx_reqs) 99 | 100 | tx = %Transaction{ 101 | instructions: [ 102 | SystemProgram.create_account( 103 | lamports: lamports, 104 | space: space, 105 | program_id: SystemProgram.id(), 106 | from: pubkey!(payer), 107 | new: pubkey!(new) 108 | ), 109 | SystemProgram.transfer( 110 | lamports: 1_000, 111 | from: pubkey!(payer), 112 | to: pubkey!(new) 113 | ) 114 | ], 115 | signers: [payer, new], 116 | blockhash: blockhash, 117 | payer: pubkey!(payer) 118 | } 119 | 120 | {:ok, _signature} = 121 | RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) 122 | 123 | expected = 1000 + lamports 124 | 125 | assert {:ok, %{"lamports" => ^expected}} = 126 | RPC.send( 127 | client, 128 | RPC.Request.get_account_info(pubkey!(new), 129 | commitment: "confirmed", 130 | encoding: "jsonParsed" 131 | ) 132 | ) 133 | end 134 | 135 | test "can transfer lamports to an account with a seed", %{ 136 | tracker: tracker, 137 | client: client, 138 | payer: payer 139 | } do 140 | {:ok, new} = Solana.Key.with_seed(pubkey!(payer), "transfer", SystemProgram.id()) 141 | space = 0 142 | 143 | tx_reqs = [ 144 | RPC.Request.get_minimum_balance_for_rent_exemption(space, commitment: "confirmed"), 145 | RPC.Request.get_latest_blockhash(commitment: "confirmed") 146 | ] 147 | 148 | [{:ok, lamports}, {:ok, %{"blockhash" => blockhash}}] = RPC.send(client, tx_reqs) 149 | 150 | tx = %Transaction{ 151 | instructions: [ 152 | SystemProgram.create_account( 153 | lamports: 1_000 + lamports, 154 | space: space, 155 | program_id: SystemProgram.id(), 156 | from: pubkey!(payer), 157 | new: new, 158 | base: pubkey!(payer), 159 | seed: "transfer" 160 | ), 161 | SystemProgram.transfer( 162 | lamports: 1_000, 163 | from: new, 164 | to: pubkey!(payer), 165 | base: pubkey!(payer), 166 | seed: "transfer", 167 | program_id: SystemProgram.id() 168 | ) 169 | ], 170 | signers: [payer], 171 | blockhash: blockhash, 172 | payer: pubkey!(payer) 173 | } 174 | 175 | {:ok, _signature} = 176 | RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) 177 | 178 | assert {:ok, %{"lamports" => ^lamports}} = 179 | RPC.send( 180 | client, 181 | RPC.Request.get_account_info(new, 182 | commitment: "confirmed", 183 | encoding: "jsonParsed" 184 | ) 185 | ) 186 | end 187 | end 188 | 189 | describe "assign/1" do 190 | test "can assign a new program ID to an account", %{ 191 | tracker: tracker, 192 | client: client, 193 | payer: payer 194 | } do 195 | new = Solana.keypair() 196 | space = 0 197 | new_program_id = pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") 198 | 199 | tx_reqs = [ 200 | RPC.Request.get_minimum_balance_for_rent_exemption(space, commitment: "confirmed"), 201 | RPC.Request.get_latest_blockhash(commitment: "confirmed") 202 | ] 203 | 204 | [{:ok, lamports}, {:ok, %{"blockhash" => blockhash}}] = RPC.send(client, tx_reqs) 205 | 206 | tx = %Transaction{ 207 | instructions: [ 208 | SystemProgram.create_account( 209 | lamports: lamports, 210 | space: space, 211 | program_id: SystemProgram.id(), 212 | from: pubkey!(payer), 213 | new: pubkey!(new) 214 | ), 215 | SystemProgram.assign( 216 | account: pubkey!(new), 217 | program_id: new_program_id 218 | ) 219 | ], 220 | signers: [payer, new], 221 | blockhash: blockhash, 222 | payer: pubkey!(payer) 223 | } 224 | 225 | {:ok, _signature} = 226 | RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) 227 | 228 | {:ok, account_info} = 229 | RPC.send( 230 | client, 231 | RPC.Request.get_account_info(pubkey!(new), 232 | commitment: "confirmed", 233 | encoding: "jsonParsed" 234 | ) 235 | ) 236 | 237 | assert account_info["owner"] == new_program_id 238 | end 239 | 240 | test "can assign a new program ID to an account with a seed", %{ 241 | tracker: tracker, 242 | client: client, 243 | payer: payer 244 | } do 245 | new_program_id = pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") 246 | {:ok, new} = Solana.Key.with_seed(pubkey!(payer), "assign", new_program_id) 247 | space = 0 248 | 249 | tx_reqs = [ 250 | RPC.Request.get_minimum_balance_for_rent_exemption(space, commitment: "confirmed"), 251 | RPC.Request.get_latest_blockhash(commitment: "confirmed") 252 | ] 253 | 254 | [{:ok, lamports}, {:ok, %{"blockhash" => blockhash}}] = RPC.send(client, tx_reqs) 255 | 256 | tx = %Transaction{ 257 | instructions: [ 258 | SystemProgram.create_account( 259 | lamports: lamports, 260 | space: space, 261 | program_id: new_program_id, 262 | from: pubkey!(payer), 263 | new: new, 264 | base: pubkey!(payer), 265 | seed: "assign" 266 | ), 267 | SystemProgram.assign( 268 | account: new, 269 | program_id: new_program_id, 270 | base: pubkey!(payer), 271 | seed: "assign" 272 | ) 273 | ], 274 | signers: [payer], 275 | blockhash: blockhash, 276 | payer: pubkey!(payer) 277 | } 278 | 279 | {:ok, _signature} = 280 | RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) 281 | 282 | {:ok, account_info} = 283 | RPC.send( 284 | client, 285 | RPC.Request.get_account_info(new, 286 | commitment: "confirmed", 287 | encoding: "jsonParsed" 288 | ) 289 | ) 290 | 291 | assert account_info["owner"] == new_program_id 292 | end 293 | end 294 | 295 | describe "allocate/1" do 296 | test "can allocate space to an account", %{tracker: tracker, client: client, payer: payer} do 297 | new = Solana.keypair() 298 | space = 0 299 | new_space = 10 300 | 301 | tx_reqs = [ 302 | RPC.Request.get_minimum_balance_for_rent_exemption(new_space, commitment: "confirmed"), 303 | RPC.Request.get_latest_blockhash(commitment: "confirmed") 304 | ] 305 | 306 | [{:ok, lamports}, {:ok, %{"blockhash" => blockhash}}] = RPC.send(client, tx_reqs) 307 | 308 | tx = %Transaction{ 309 | instructions: [ 310 | SystemProgram.create_account( 311 | lamports: lamports, 312 | space: space, 313 | program_id: SystemProgram.id(), 314 | from: pubkey!(payer), 315 | new: pubkey!(new) 316 | ), 317 | SystemProgram.allocate( 318 | account: pubkey!(new), 319 | space: new_space 320 | ) 321 | ], 322 | signers: [payer, new], 323 | blockhash: blockhash, 324 | payer: pubkey!(payer) 325 | } 326 | 327 | {:ok, _signature} = 328 | RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) 329 | 330 | {:ok, %{"data" => [data, "base64"]}} = 331 | RPC.send( 332 | client, 333 | RPC.Request.get_account_info(pubkey!(new), 334 | commitment: "confirmed", 335 | encoding: "jsonParsed" 336 | ) 337 | ) 338 | 339 | assert byte_size(Base.decode64!(data)) == new_space 340 | end 341 | 342 | test "can allocate space to an account with a seed", %{ 343 | tracker: tracker, 344 | client: client, 345 | payer: payer 346 | } do 347 | {:ok, new} = Solana.Key.with_seed(pubkey!(payer), "allocate", SystemProgram.id()) 348 | space = 0 349 | new_space = 10 350 | 351 | tx_reqs = [ 352 | RPC.Request.get_minimum_balance_for_rent_exemption(new_space, commitment: "confirmed"), 353 | RPC.Request.get_latest_blockhash(commitment: "confirmed") 354 | ] 355 | 356 | [{:ok, lamports}, {:ok, %{"blockhash" => blockhash}}] = RPC.send(client, tx_reqs) 357 | 358 | tx = %Transaction{ 359 | instructions: [ 360 | SystemProgram.create_account( 361 | lamports: lamports, 362 | space: space, 363 | program_id: SystemProgram.id(), 364 | from: pubkey!(payer), 365 | new: new, 366 | base: pubkey!(payer), 367 | seed: "allocate" 368 | ), 369 | SystemProgram.allocate( 370 | account: new, 371 | space: new_space, 372 | program_id: SystemProgram.id(), 373 | base: pubkey!(payer), 374 | seed: "allocate" 375 | ) 376 | ], 377 | signers: [payer], 378 | blockhash: blockhash, 379 | payer: pubkey!(payer) 380 | } 381 | 382 | {:ok, _signature} = 383 | RPC.send_and_confirm(client, tracker, tx, commitment: "confirmed", timeout: 1_000) 384 | 385 | {:ok, %{"data" => [data, "base64"]}} = 386 | RPC.send( 387 | client, 388 | RPC.Request.get_account_info(new, 389 | commitment: "confirmed", 390 | encoding: "jsonParsed" 391 | ) 392 | ) 393 | 394 | assert byte_size(Base.decode64!(data)) == new_space 395 | end 396 | end 397 | end 398 | --------------------------------------------------------------------------------