├── .formatter.exs ├── .gitignore ├── LICENSE ├── README.md ├── lib ├── solana.ex └── solana │ ├── account.ex │ ├── compact_array.ex │ ├── helpers.ex │ ├── ix.ex │ ├── key.ex │ ├── rpc.ex │ ├── rpc │ ├── middleware.ex │ ├── request.ex │ └── tracker.ex │ ├── system_program.ex │ ├── system_program │ └── nonce.ex │ ├── test │ ├── bin │ │ └── wrapper-unix │ └── validator.ex │ └── tx.ex ├── mix.exs ├── mix.lock └── test ├── solana ├── compact_array_test.exs ├── key_test.exs ├── system_program │ └── nonce_test.exs ├── system_program_test.exs └── tx_test.exs ├── solana_test.exs ├── support ├── invalid1.json ├── invalid2.json ├── test_helpers.ex └── wallet.json └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/solana/compact_array.ex: -------------------------------------------------------------------------------- 1 | defmodule Solana.CompactArray do 2 | @moduledoc false 3 | use Bitwise, skip_operators: true 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/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 | -------------------------------------------------------------------------------- /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/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, {: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 | -------------------------------------------------------------------------------- /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({"getRecentBlockhash", blockhash_result}) do 56 | {:ok, update_in(blockhash_result, ["blockhash"], &B58.decode58!/1)} 57 | end 58 | 59 | defp decode_result({"sendTransaction", signature}) do 60 | {:ok, B58.decode58!(signature)} 61 | end 62 | 63 | defp decode_result({"getTransaction", %{"transaction" => tx} = result}) when is_map(tx) do 64 | tx = 65 | tx 66 | |> update_in(["message", "accountKeys"], &decode_b58_list/1) 67 | |> update_in(["message", "recentBlockhash"], &B58.decode58!/1) 68 | |> Map.update!("signatures", &decode_b58_list/1) 69 | 70 | {:ok, Map.put(result, "transaction", tx)} 71 | end 72 | 73 | defp decode_result({"getAccountInfo", %{} = result}) do 74 | {:ok, Map.update!(result, "owner", &B58.decode58!/1)} 75 | end 76 | 77 | # just run the decoding for getAccountInfo for each item in the list 78 | defp decode_result({"getMultipleAccounts", result}) when is_list(result) do 79 | {:ok, Enum.map(result, &elem(decode_result({"getAccountInfo", &1}), 1))} 80 | end 81 | 82 | defp decode_result({_method, result}), do: {:ok, result} 83 | 84 | defp decode_b58_list(list), do: Enum.map(list, &B58.decode58!/1) 85 | end 86 | -------------------------------------------------------------------------------- /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 a recent block hash from the ledger, and a fee schedule that can be 84 | used to compute the cost of submitting a transaction using it. 85 | 86 | For more information, see [the Solana 87 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#getrecentblockhash). 88 | """ 89 | @spec get_recent_blockhash(opts :: keyword) :: t 90 | def get_recent_blockhash(opts \\ []) do 91 | {"getRecentBlockhash", [encode_opts(opts)]} 92 | end 93 | 94 | @doc """ 95 | Returns minimum balance required to make an account rent exempt. 96 | 97 | For more information, see [the Solana 98 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#getminimumbalanceforrentexemption). 99 | """ 100 | @spec get_minimum_balance_for_rent_exemption(length :: non_neg_integer, opts :: keyword) :: t 101 | def get_minimum_balance_for_rent_exemption(length, opts \\ []) do 102 | {"getMinimumBalanceForRentExemption", [length, encode_opts(opts)]} 103 | end 104 | 105 | @doc """ 106 | Submits a signed transaction to the cluster for processing. 107 | 108 | For more information, see [the Solana 109 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#sendtransaction). 110 | """ 111 | @spec send_transaction(transaction :: Solana.Transaction.t(), opts :: keyword) :: t 112 | def send_transaction(tx = %Solana.Transaction{}, opts \\ []) do 113 | {:ok, tx_bin} = Solana.Transaction.to_binary(tx) 114 | opts = opts |> fix_tx_opts() |> encode_opts(%{"encoding" => "base64"}) 115 | {"sendTransaction", [Base.encode64(tx_bin), opts]} 116 | end 117 | 118 | defp fix_tx_opts(opts) do 119 | opts 120 | |> Enum.map(fn 121 | {:commitment, commitment} -> {:preflight_commitment, commitment} 122 | other -> other 123 | end) 124 | |> Enum.into([]) 125 | end 126 | 127 | @doc """ 128 | Requests an airdrop of lamports to an account. 129 | 130 | For more information, see [the Solana 131 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#requestairdrop). 132 | """ 133 | @spec request_airdrop(account :: Solana.key(), sol :: pos_integer, opts :: keyword) :: t 134 | def request_airdrop(account, sol, opts \\ []) do 135 | {"requestAirdrop", 136 | [B58.encode58(account), sol * Solana.lamports_per_sol(), encode_opts(opts)]} 137 | end 138 | 139 | @doc """ 140 | Returns confirmed signatures for transactions involving an address backwards 141 | in time from the provided signature or most recent confirmed block. 142 | 143 | For more information, see [the Solana 144 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#getsignaturesforaddress). 145 | """ 146 | @spec get_signatures_for_address(account :: Solana.key(), opts :: keyword) :: t 147 | def get_signatures_for_address(account, opts \\ []) do 148 | {"getSignaturesForAddress", [B58.encode58(account), encode_opts(opts)]} 149 | end 150 | 151 | @doc """ 152 | Returns the statuses of a list of signatures. 153 | 154 | Unless the `searchTransactionHistory` configuration parameter is included, 155 | this method only searches the recent status cache of signatures, which retains 156 | statuses for all active slots plus `MAX_RECENT_BLOCKHASHES` rooted slots. 157 | 158 | For more information, see [the Solana 159 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#getsignaturestatuses). 160 | """ 161 | @spec get_signature_statuses(signatures :: [Solana.key()], opts :: keyword) :: t 162 | def get_signature_statuses(signatures, opts \\ []) when is_list(signatures) do 163 | {"getSignatureStatuses", [Enum.map(signatures, &B58.encode58/1), encode_opts(opts)]} 164 | end 165 | 166 | @doc """ 167 | Returns transaction details for a confirmed transaction. 168 | 169 | For more information, see [the Solana 170 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#gettransaction). 171 | """ 172 | @spec get_transaction(signature :: Solana.key(), opts :: keyword) :: t 173 | def get_transaction(signature, opts \\ []) do 174 | {"getTransaction", [B58.encode58(signature), encode_opts(opts)]} 175 | end 176 | 177 | @doc """ 178 | Returns the total supply of an SPL Token. 179 | 180 | For more information, see [the Solana 181 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#gettokensupply). 182 | """ 183 | @spec get_token_supply(mint :: Solana.key(), opts :: keyword) :: t 184 | def get_token_supply(mint, opts \\ []) do 185 | {"getTokenSupply", [B58.encode58(mint), encode_opts(opts)]} 186 | end 187 | 188 | @doc """ 189 | Returns the 20 largest accounts of a particular SPL Token type. 190 | 191 | For more information, see [the Solana 192 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#gettokenlargestaccounts). 193 | """ 194 | @spec get_token_largest_accounts(mint :: Solana.key(), opts :: keyword) :: t 195 | def get_token_largest_accounts(mint, opts \\ []) do 196 | {"getTokenLargestAccounts", [B58.encode58(mint), encode_opts(opts)]} 197 | end 198 | 199 | @doc """ 200 | Returns the account information for a list of pubkeys. 201 | 202 | For more information, see [the Solana 203 | docs](https://docs.solana.com/developing/clients/jsonrpc-api#getmultipleaccounts). 204 | """ 205 | @spec get_multiple_accounts(accounts :: [Solana.key()], opts :: keyword) :: t 206 | def get_multiple_accounts(accounts, opts \\ []) when is_list(accounts) do 207 | {"getMultipleAccounts", 208 | [Enum.map(accounts, &B58.encode58/1), encode_opts(opts, %{"encoding" => "base64"})]} 209 | end 210 | 211 | defp encode_opts(opts, defaults \\ %{}) do 212 | Enum.into(opts, defaults, fn {k, v} -> {camelize(k), encode_value(v)} end) 213 | end 214 | 215 | defp camelize(word) do 216 | case Regex.split(~r/(?:^|[-_])|(?=[A-Z])/, to_string(word)) do 217 | words -> 218 | words 219 | |> Enum.filter(&(&1 != "")) 220 | |> camelize_list(:lower) 221 | |> Enum.join() 222 | end 223 | end 224 | 225 | defp camelize_list([], _), do: [] 226 | 227 | defp camelize_list([h | tail], :lower) do 228 | [String.downcase(h)] ++ camelize_list(tail, :upper) 229 | end 230 | 231 | defp camelize_list([h | tail], :upper) do 232 | [String.capitalize(h)] ++ camelize_list(tail, :upper) 233 | end 234 | 235 | defp encode_value(v) do 236 | cond do 237 | :ok == elem(Solana.Key.check(v), 0) -> B58.encode58(v) 238 | :ok == elem(Solana.Transaction.check(v), 0) -> B58.encode58(v) 239 | true -> v 240 | end 241 | end 242 | end 243 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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: "1024-65535"], 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 | -------------------------------------------------------------------------------- /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 | Returns `{:ok, encoded_transaction}` if the transaction was successfully 82 | encoded, or an error tuple if the encoding failed -- plus more error details 83 | via `Logger.error/1`. 84 | """ 85 | @spec to_binary(tx :: t) :: {:ok, binary()} | {:error, encoding_err()} 86 | def to_binary(%__MODULE__{payer: nil}), do: {:error, :no_payer} 87 | def to_binary(%__MODULE__{blockhash: nil}), do: {:error, :no_blockhash} 88 | def to_binary(%__MODULE__{instructions: []}), do: {:error, :no_instructions} 89 | 90 | def to_binary(tx = %__MODULE__{instructions: ixs, signers: signers}) do 91 | with {:ok, ixs} <- check_instructions(List.flatten(ixs)), 92 | accounts = compile_accounts(ixs, tx.payer), 93 | true <- signers_match?(accounts, signers) do 94 | message = encode_message(accounts, tx.blockhash, ixs) 95 | 96 | signatures = 97 | signers 98 | |> reorder_signers(accounts) 99 | |> Enum.map(&sign(&1, message)) 100 | |> CompactArray.to_iolist() 101 | 102 | {:ok, :erlang.list_to_binary([signatures, message])} 103 | else 104 | {:error, :no_program, idx} -> 105 | Logger.error("Missing program id on instruction at index #{idx}") 106 | {:error, :no_program} 107 | 108 | {:error, message, idx} -> 109 | Logger.error("error compiling instruction at index #{idx}: #{inspect(message)}") 110 | {:error, message} 111 | 112 | false -> 113 | {:error, :mismatched_signers} 114 | end 115 | end 116 | 117 | defp check_instructions(ixs) do 118 | ixs 119 | |> Enum.with_index() 120 | |> Enum.reduce_while({:ok, ixs}, fn 121 | {{:error, message}, idx}, _ -> {:halt, {:error, message, idx}} 122 | {%{program: nil}, idx}, _ -> {:halt, {:error, :no_program, idx}} 123 | _, acc -> {:cont, acc} 124 | end) 125 | end 126 | 127 | # https://docs.solana.com/developing/programming-model/transactions#account-addresses-format 128 | defp compile_accounts(ixs, payer) do 129 | ixs 130 | |> Enum.map(fn ix -> [%Account{key: ix.program} | ix.accounts] end) 131 | |> List.flatten() 132 | |> Enum.reject(&(&1.key == payer)) 133 | |> Enum.sort_by(&{&1.signer?, &1.writable?}, &>=/2) 134 | |> Enum.uniq_by(& &1.key) 135 | |> cons(%Account{writable?: true, signer?: true, key: payer}) 136 | end 137 | 138 | defp cons(list, item), do: [item | list] 139 | 140 | defp signers_match?(accounts, signers) do 141 | expected = MapSet.new(Enum.map(signers, &elem(&1, 1))) 142 | 143 | accounts 144 | |> Enum.filter(& &1.signer?) 145 | |> Enum.map(& &1.key) 146 | |> MapSet.new() 147 | |> MapSet.equal?(expected) 148 | end 149 | 150 | # https://docs.solana.com/developing/programming-model/transactions#message-format 151 | defp encode_message(accounts, blockhash, ixs) do 152 | [ 153 | create_header(accounts), 154 | CompactArray.to_iolist(Enum.map(accounts, & &1.key)), 155 | blockhash, 156 | CompactArray.to_iolist(encode_instructions(ixs, accounts)) 157 | ] 158 | |> :erlang.list_to_binary() 159 | end 160 | 161 | # https://docs.solana.com/developing/programming-model/transactions#message-header-format 162 | defp create_header(accounts) do 163 | accounts 164 | |> Enum.reduce( 165 | {0, 0, 0}, 166 | &{ 167 | unary(&1.signer?) + elem(&2, 0), 168 | unary(&1.signer? && !&1.writable?) + elem(&2, 1), 169 | unary(!&1.signer? && !&1.writable?) + elem(&2, 2) 170 | } 171 | ) 172 | |> Tuple.to_list() 173 | end 174 | 175 | defp unary(result?), do: if(result?, do: 1, else: 0) 176 | 177 | # https://docs.solana.com/developing/programming-model/transactions#instruction-format 178 | defp encode_instructions(ixs, accounts) do 179 | idxs = index_accounts(accounts) 180 | 181 | Enum.map(ixs, fn ix = %Instruction{} -> 182 | [ 183 | Map.get(idxs, ix.program), 184 | CompactArray.to_iolist(Enum.map(ix.accounts, &Map.get(idxs, &1.key))), 185 | CompactArray.to_iolist(ix.data) 186 | ] 187 | end) 188 | end 189 | 190 | defp reorder_signers(signers, accounts) do 191 | account_idxs = index_accounts(accounts) 192 | Enum.sort_by(signers, &Map.get(account_idxs, elem(&1, 1))) 193 | end 194 | 195 | defp index_accounts(accounts) do 196 | Enum.into(Enum.with_index(accounts, &{&1.key, &2}), %{}) 197 | end 198 | 199 | defp sign({secret, pk}, message), do: Ed25519.signature(message, secret, pk) 200 | 201 | @doc """ 202 | Parses a `t:Solana.Transaction.t/0` from data encoded in Solana's [binary 203 | format](https://docs.solana.com/developing/programming-model/transactions#anatomy-of-a-transaction) 204 | 205 | Returns `{transaction, extras}` if the transaction was successfully 206 | parsed, or `:error` if the provided binary could not be parsed. `extras` 207 | is a keyword list containing information about the encoded transaction, 208 | namely: 209 | 210 | - `:header` - the [transaction message 211 | header](https://docs.solana.com/developing/programming-model/transactions#message-header-format) 212 | - `:accounts` - an [ordered array of 213 | accounts](https://docs.solana.com/developing/programming-model/transactions#account-addresses-format) 214 | - `:signatures` - a [list of signed copies of the transaction 215 | message](https://docs.solana.com/developing/programming-model/transactions#signatures) 216 | """ 217 | @spec parse(encoded :: binary) :: {t(), keyword} | :error 218 | def parse(encoded) do 219 | with {signatures, message, _} <- CompactArray.decode_and_split(encoded, 64), 220 | <> <- message, 221 | {account_keys, hash_and_ixs, key_count} <- CompactArray.decode_and_split(contents, 32), 222 | <> <- hash_and_ixs, 223 | {:ok, instructions} <- extract_instructions(ix_data) do 224 | tx_accounts = derive_accounts(account_keys, key_count, header) 225 | indices = Enum.into(Enum.with_index(tx_accounts, &{&2, &1}), %{}) 226 | 227 | { 228 | %__MODULE__{ 229 | payer: tx_accounts |> List.first() |> Map.get(:key), 230 | blockhash: blockhash, 231 | instructions: 232 | Enum.map(instructions, fn {program, accounts, data} -> 233 | %Instruction{ 234 | data: if(data == "", do: nil, else: :binary.list_to_bin(data)), 235 | program: Map.get(indices, program) |> Map.get(:key), 236 | accounts: Enum.map(accounts, &Map.get(indices, &1)) 237 | } 238 | end) 239 | }, 240 | [ 241 | accounts: tx_accounts, 242 | header: header, 243 | signatures: signatures 244 | ] 245 | } 246 | else 247 | _ -> :error 248 | end 249 | end 250 | 251 | defp extract_instructions(data) do 252 | with {ix_data, ix_count} <- CompactArray.decode_and_split(data), 253 | {reversed_ixs, ""} <- extract_instructions(ix_data, ix_count) do 254 | {:ok, Enum.reverse(reversed_ixs)} 255 | else 256 | error -> error 257 | end 258 | end 259 | 260 | defp extract_instructions(data, count) do 261 | Enum.reduce_while(1..count, {[], data}, fn _, {acc, raw} -> 262 | case extract_instruction(raw) do 263 | {ix, rest} -> {:cont, {[ix | acc], rest}} 264 | _ -> {:halt, :error} 265 | end 266 | end) 267 | end 268 | 269 | defp extract_instruction(raw) do 270 | with <> <- raw, 271 | {accounts, rest, _} <- CompactArray.decode_and_split(rest, 1), 272 | {data, rest, _} <- extract_instruction_data(rest) do 273 | {{program, Enum.map(accounts, &:binary.decode_unsigned/1), data}, rest} 274 | else 275 | _ -> :error 276 | end 277 | end 278 | 279 | defp extract_instruction_data(""), do: {"", "", 0} 280 | defp extract_instruction_data(raw), do: CompactArray.decode_and_split(raw, 1) 281 | 282 | defp derive_accounts(keys, total, header) do 283 | <> = header 284 | {signers, nonsigners} = Enum.split(keys, signers_count) 285 | {signers_write, signers_read} = Enum.split(signers, signers_count - signers_readonly_count) 286 | 287 | {nonsigners_write, nonsigners_read} = 288 | Enum.split(nonsigners, total - signers_count - nonsigners_readonly_count) 289 | 290 | List.flatten([ 291 | Enum.map(signers_write, &%Account{key: &1, writable?: true, signer?: true}), 292 | Enum.map(signers_read, &%Account{key: &1, signer?: true}), 293 | Enum.map(nonsigners_write, &%Account{key: &1, writable?: true}), 294 | Enum.map(nonsigners_read, &%Account{key: &1}) 295 | ]) 296 | end 297 | end 298 | -------------------------------------------------------------------------------- /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.4.0"}, 53 | # json library 54 | {:jason, ">= 1.0.0"}, 55 | # keys and signatures 56 | {:ed25519, "~> 1.3"}, 57 | # base58 encoding 58 | {:basefiftyeight, "~> 0.1.0"}, 59 | # validating parameters 60 | {:nimble_options, "~> 0.4.0"}, 61 | # docs and testing 62 | {:ex_doc, "~> 0.25.5", only: :dev, runtime: false}, 63 | {:dialyxir, "~> 1.0", 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 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "basefiftyeight": {:hex, :basefiftyeight, "0.1.0", "3d48544743bf9aab7ab02aed803ac42af77acf268c7d8c71d4f39e7fa85ee8d3", [:mix], [], "hexpm", "af12f551429528c711e98628c029ad48d1e5ba5a284f40b2d91029a65381837a"}, 3 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.16", "607709303e1d4e3e02f1444df0c821529af1c03b8578dfc81bb9cf64553d02b9", [:mix], [], "hexpm", "69fcf696168f5a274dd012e3e305027010658b2d1630cef68421d6baaeaccead"}, 5 | "ed25519": {:hex, :ed25519, "1.3.3", "177688baf1ae6e3b1a2eb4d3bc873cd1b070885ecf804342a1b82c2af97de21e", [:mix], [], "hexpm", "cdbcd139f1281d3576346dcaca3777485084a9ffaba2af93fe07dab8045012e4"}, 6 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 7 | "ex_doc": {:hex, :ex_doc, "0.25.5", "ac3c5425a80b4b7c4dfecdf51fa9c23a44877124dd8ca34ee45ff608b1c6deb9", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "688cfa538cdc146bc4291607764a7f1fcfa4cce8009ecd62de03b27197528350"}, 8 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 9 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 10 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.2", "dc72dfe17eb240552857465cc00cce390960d9a0c055c4ccd38b70629227e97c", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fd23ae48d09b32eff49d4ced2b43c9f086d402ee4fd4fcb2d7fad97fa8823e75"}, 11 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 12 | "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, 13 | "nimble_options": {:hex, :nimble_options, "0.4.0", "c89babbab52221a24b8d1ff9e7d838be70f0d871be823165c94dd3418eea728f", [:mix], [], "hexpm", "e6701c1af326a11eea9634a3b1c62b475339ace9456c1a23ec3bc9a847bca02d"}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 15 | "tesla": {:hex, :tesla, "1.4.3", "f5a494e08fb1abe4fd9c28abb17f3d9b62b8f6fc492860baa91efb1aab61c8a0", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, 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", "e0755bb664bf4d664af72931f320c97adbf89da4586670f4864bf259b5750386"}, 16 | } 17 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_recent_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_recent_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_recent_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_recent_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 | -------------------------------------------------------------------------------- /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_recent_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_recent_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_recent_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_recent_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_recent_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_recent_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_recent_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_recent_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 | -------------------------------------------------------------------------------- /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/1" 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 "places accounts in order (payer first)" do 88 | payer = Solana.keypair() 89 | signer = Solana.keypair() 90 | read_only = Solana.keypair() 91 | program = Solana.keypair() |> pubkey!() 92 | blockhash = Solana.keypair() |> pubkey!() 93 | 94 | ix = %Instruction{ 95 | program: program, 96 | accounts: [ 97 | %Account{signer?: true, key: pubkey!(read_only)}, 98 | %Account{signer?: true, writable?: true, key: pubkey!(signer)}, 99 | %Account{signer?: true, writable?: true, key: pubkey!(payer)} 100 | ] 101 | } 102 | 103 | tx = %Transaction{ 104 | payer: pubkey!(payer), 105 | instructions: [ix], 106 | blockhash: blockhash, 107 | signers: [payer, signer, read_only] 108 | } 109 | 110 | {:ok, tx_bin} = Transaction.to_binary(tx) 111 | {_, extras} = Transaction.parse(tx_bin) 112 | 113 | assert [pubkey!(payer), pubkey!(signer), pubkey!(read_only)] == 114 | extras 115 | |> Keyword.get(:accounts) 116 | |> Enum.map(& &1.key) 117 | |> Enum.take(3) 118 | end 119 | 120 | test "payer is writable and a signer" do 121 | payer = Solana.keypair() 122 | read_only = Solana.keypair() 123 | program = Solana.keypair() |> pubkey!() 124 | blockhash = Solana.keypair() |> pubkey!() 125 | 126 | ix = %Instruction{ 127 | program: program, 128 | accounts: [%Account{key: pubkey!(payer)}, %Account{key: pubkey!(read_only)}] 129 | } 130 | 131 | tx = %Transaction{ 132 | payer: pubkey!(payer), 133 | instructions: [ix], 134 | blockhash: blockhash, 135 | signers: [payer] 136 | } 137 | 138 | {:ok, tx_bin} = Transaction.to_binary(tx) 139 | {_, extras} = Transaction.parse(tx_bin) 140 | 141 | [actual_payer | _] = Keyword.get(extras, :accounts) 142 | 143 | assert actual_payer.key == pubkey!(payer) 144 | assert actual_payer.writable? 145 | assert actual_payer.signer? 146 | end 147 | 148 | test "sets up the header correctly" do 149 | payer = Solana.keypair() 150 | writable = Solana.keypair() 151 | signer = Solana.keypair() 152 | read_only = Solana.keypair() 153 | program = Solana.keypair() |> pubkey!() 154 | blockhash = Solana.keypair() |> pubkey!() 155 | 156 | ix = %Instruction{ 157 | program: program, 158 | accounts: [ 159 | %Account{key: pubkey!(read_only)}, 160 | %Account{writable?: true, key: pubkey!(writable)}, 161 | %Account{signer?: true, key: pubkey!(signer)}, 162 | %Account{signer?: true, writable?: true, key: pubkey!(payer)} 163 | ] 164 | } 165 | 166 | tx = %Transaction{ 167 | payer: pubkey!(payer), 168 | instructions: [ix], 169 | blockhash: blockhash, 170 | signers: [payer, signer] 171 | } 172 | 173 | {:ok, tx_bin} = Transaction.to_binary(tx) 174 | {_, extras} = Transaction.parse(tx_bin) 175 | 176 | # 2 signers, one read-only signer, 2 read-only non-signers (read_only and 177 | # program) 178 | assert Keyword.get(extras, :header) == <<2, 1, 2>> 179 | end 180 | 181 | test "dedups signatures and accounts" do 182 | from = Solana.keypair() 183 | to = Solana.keypair() 184 | program = Solana.keypair() |> pubkey!() 185 | blockhash = Solana.keypair() |> pubkey!() 186 | 187 | ix = %Instruction{ 188 | program: program, 189 | accounts: [ 190 | %Account{key: pubkey!(to)}, 191 | %Account{signer?: true, writable?: true, key: pubkey!(from)} 192 | ] 193 | } 194 | 195 | tx = %Transaction{ 196 | payer: pubkey!(from), 197 | instructions: [ix, ix], 198 | blockhash: blockhash, 199 | signers: [from] 200 | } 201 | 202 | {:ok, tx_bin} = Transaction.to_binary(tx) 203 | {_, extras} = Transaction.parse(tx_bin) 204 | 205 | assert [_] = Keyword.get(extras, :signatures) 206 | assert length(Keyword.get(extras, :accounts)) == 3 207 | end 208 | end 209 | 210 | describe "parse/1" do 211 | test "cannot parse an empty string" do 212 | assert :error = Transaction.parse("") 213 | end 214 | 215 | test "cannot parse an improperly encoded transaction" do 216 | payer = Solana.keypair() 217 | signer = Solana.keypair() 218 | read_only = Solana.keypair() 219 | program = Solana.keypair() |> pubkey!() 220 | blockhash = Solana.keypair() |> pubkey!() 221 | 222 | ix = %Instruction{ 223 | program: program, 224 | accounts: [ 225 | %Account{signer?: true, key: pubkey!(read_only)}, 226 | %Account{signer?: true, writable?: true, key: pubkey!(signer)}, 227 | %Account{signer?: true, writable?: true, key: pubkey!(payer)} 228 | ] 229 | } 230 | 231 | tx = %Transaction{ 232 | payer: pubkey!(payer), 233 | instructions: [ix], 234 | blockhash: blockhash, 235 | signers: [payer, signer, read_only] 236 | } 237 | 238 | {:ok, <<_::8, clipped_tx::binary>>} = Transaction.to_binary(tx) 239 | assert :error = Transaction.parse(clipped_tx) 240 | end 241 | 242 | test "can parse a properly encoded tranaction" do 243 | from = Solana.keypair() 244 | to = Solana.keypair() 245 | program = Solana.keypair() |> pubkey!() 246 | blockhash = Solana.keypair() |> pubkey!() 247 | 248 | ix = %Instruction{ 249 | program: program, 250 | accounts: [ 251 | %Account{key: pubkey!(to)}, 252 | %Account{signer?: true, writable?: true, key: pubkey!(from)} 253 | ], 254 | data: <<1, 2, 3>> 255 | } 256 | 257 | tx = %Transaction{ 258 | payer: pubkey!(from), 259 | instructions: [ix, ix], 260 | blockhash: blockhash, 261 | signers: [from] 262 | } 263 | 264 | {:ok, tx_bin} = Transaction.to_binary(tx) 265 | {actual, extras} = Transaction.parse(tx_bin) 266 | 267 | assert [_signature] = Keyword.get(extras, :signatures) 268 | 269 | assert actual.payer == pubkey!(from) 270 | assert actual.instructions == [ix, ix] 271 | assert actual.blockhash == blockhash 272 | end 273 | end 274 | 275 | describe "decode/1" do 276 | test "fails for signatures which are too short" do 277 | encoded = B58.encode58(Enum.into(1..63, <<>>, &<<&1::8>>)) 278 | assert {:error, _} = Transaction.decode(encoded) 279 | assert {:error, _} = Transaction.decode("12345") 280 | end 281 | 282 | test "fails for signatures which are too long" do 283 | encoded = B58.encode58(<<3, 0::64*8>>) 284 | assert {:error, _} = Transaction.decode(encoded) 285 | end 286 | 287 | test "fails for signatures which aren't base58-encoded" do 288 | assert {:error, _} = 289 | Transaction.decode( 290 | "0x300000000000000000000000000000000000000000000000000000000000000000000" 291 | ) 292 | 293 | assert {:error, _} = 294 | Transaction.decode( 295 | "0x300000000000000000000000000000000000000000000000000000000000000" 296 | ) 297 | 298 | assert {:error, _} = 299 | Transaction.decode( 300 | "135693854574979916511997248057056142015550763280047535983739356259273198796800000" 301 | ) 302 | end 303 | 304 | test "works for regular signatures" do 305 | assert {:ok, <<3, 0::63*8>>} = 306 | Transaction.decode( 307 | "4Umk1E47BhUNBHJQGJto6i5xpATqVs8UxW11QjpoVnBmiv7aZJyG78yVYj99SrozRa9x7av8p3GJmBuzvhpUHDZ" 308 | ) 309 | end 310 | end 311 | 312 | describe "decode!/1" do 313 | test "throws for signatures which aren't base58-encoded" do 314 | assert_raise ArgumentError, fn -> 315 | Transaction.decode!( 316 | "0x300000000000000000000000000000000000000000000000000000000000000000000" 317 | ) 318 | end 319 | 320 | assert_raise ArgumentError, fn -> 321 | Transaction.decode!("0x300000000000000000000000000000000000000000000000000000000000000") 322 | end 323 | 324 | assert_raise ArgumentError, fn -> 325 | Transaction.decode!( 326 | "135693854574979916511997248057056142015550763280047535983739356259273198796800000" 327 | ) 328 | end 329 | end 330 | 331 | test "works for regular signatures" do 332 | assert <<3, 0::63*8>> == 333 | Transaction.decode!( 334 | "4Umk1E47BhUNBHJQGJto6i5xpATqVs8UxW11QjpoVnBmiv7aZJyG78yVYj99SrozRa9x7av8p3GJmBuzvhpUHDZ" 335 | ) 336 | end 337 | end 338 | end 339 | -------------------------------------------------------------------------------- /test/solana_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SolanaTest do 2 | use ExUnit.Case 3 | end 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/support/invalid2.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | --------------------------------------------------------------------------------