defmodule Tezex.Rpc do @moduledoc """ Send transactions to the Tezos network. `send_operation/4` is the main function, see tests for usage. """ alias Tezex.Crypto alias Tezex.ForgeOperation alias Tezex.Rpc alias Tezex.Fee @type t() :: %__MODULE__{ endpoint: binary(), chain_id: binary(), headers: Finch.Request.headers(), opts: Finch.request_opts() } @type encoded_private_key() :: <<_::32, _::_*8>> @type transaction() :: map() @type operation() :: map() @type preapplied_operations() :: map() @type transport_error() :: {:transport, Exception.t()} | {:http_status, Finch.Response.t()} | {:decode, Jason.DecodeError.t()} @type error_reason() :: transport_error() | {:missing_keys, [String.t()]} | {:preapply_failed, list()} | {:unexpected_response, term()} | {:invalid_counter, term()} | {:invalid_balance, term()} defstruct [:endpoint, chain_id: "main", headers: [], opts: []] @spec prepare_operation( list(transaction()), nonempty_binary(), integer(), nonempty_binary() ) :: operation() def prepare_operation(transactions, wallet_address, counter, branch) do transactions_length = length(transactions) hard_gas_limit_per_content = div(Fee.hard_gas_limit_per_operation(), transactions_length) hard_storage_limit_per_content = div(Fee.hard_storage_limit_per_operation(), transactions_length) contents = transactions |> Enum.with_index(counter) |> Enum.map(fn {content, counter} -> gas_limit = min(hard_gas_limit_per_content, Fee.default_gas_limit(content)) storage_limit = min(hard_storage_limit_per_content, Fee.default_storage_limit(content)) Map.merge(content, %{ "counter" => Integer.to_string(counter), "source" => wallet_address, "gas_limit" => Integer.to_string(gas_limit), "storage_limit" => Integer.to_string(storage_limit), "fee" => "0" }) end) %{ "branch" => branch, "contents" => contents } end @spec fill_operation_fee(operation(), list(preapplied_operations()), gas_limit: non_neg_integer(), storage_limit: non_neg_integer(), gas_reserve: non_neg_integer(), burn_reserve: non_neg_integer() ) :: {:ok, operation()} | {:error, ForgeOperation.error_reason()} def fill_operation_fee(operation, preapplied_operations, opts \\ []) do gas_limit = Keyword.get(opts, :gas_limit) storage_limit = Keyword.get(opts, :storage_limit) gas_reserve = Keyword.get(opts, :gas_reserve, Fee.default_gas_reserve()) burn_reserve = Keyword.get(opts, :burn_reserve, Fee.default_gas_reserve()) number_contents = length(preapplied_operations) contents = Enum.map(preapplied_operations, fn content -> if validation_passes(content["kind"]) == 3 do gas_limit_new = if is_nil(gas_limit) do get_preapplied_operation_values(content, fn x -> v = Map.get(x, "consumed_milligas", "0") |> String.to_integer() ceil(v / 1000) end) |> Enum.reduce(&(&1 + &2)) else div(gas_limit, number_contents) end storage_limit_new = if is_nil(storage_limit) do paid_storage_size_diff = get_preapplied_operation_values( content, fn x -> Map.get(x, "paid_storage_size_diff", "0") |> String.to_integer() end ) |> Enum.reduce(&(&1 + &2)) burned = get_preapplied_operation_values(content, fn x -> allocated_destination_contract? = not is_nil(Map.get(x, "allocated_destination_contract")) originated_contracts? = not is_nil(Map.get(x, "originated_contracts")) if allocated_destination_contract? or originated_contracts? do 257 else 0 end end) |> Enum.reduce(&(&1 + &2)) paid_storage_size_diff + burned else div(storage_limit, number_contents) end content = Map.drop(content, ~w(metadata)) {gas_limit_new, storage_limit_new} = if content["kind"] in ~w(origination transaction) do {gas_limit_new + gas_reserve, storage_limit_new + burn_reserve} else {gas_limit_new, storage_limit_new} end extra_size = 1 + div(Fee.extra_size(), number_contents) case Fee.calculate_fee(content, gas_limit_new, extra_size: extra_size) do {:ok, fee} -> {:ok, %{ content | "gas_limit" => Integer.to_string(gas_limit_new), "storage_limit" => Integer.to_string(storage_limit_new), "fee" => Integer.to_string(fee) }} err -> err end else {:ok, content} end end) first_error = Enum.find(contents, &(elem(&1, 0) == :error)) if is_nil(first_error) do {:ok, %{operation | "contents" => Enum.map(contents, &elem(&1, 1))}} else first_error end end defp get_preapplied_operation_values(op, value_fun) do metadata = Map.get(op, "metadata", %{}) internal_operation_results = Map.get(metadata, "internal_operation_results", []) operation_result = Map.get(metadata, "operation_result") internal_operation_values = Enum.map(internal_operation_results, fn r -> value_fun.(r["result"]) end) [value_fun.(operation_result) | internal_operation_values] end @doc """ Send an operation to a Tezos RPC node. """ @spec send_operation( t(), list(transaction()) | transaction(), nonempty_binary(), encoded_private_key(), offset: non_neg_integer(), storage_limit: non_neg_integer() ) :: {:ok, any()} | {:error, error_reason()} def send_operation(%Rpc{} = rpc, transactions, wallet_address, encoded_private_key, opts \\ []) do transactions = if is_map(transactions), do: [transactions], else: transactions offset = Keyword.get(opts, :offset, 0) with {:ok, block_head} <- get_block_at_offset(rpc, offset), branch = binary_part(block_head["hash"], 0, 51), protocol = block_head["protocol"], {:ok, counter} <- get_next_counter_for_account(rpc, wallet_address), operation = prepare_operation(transactions, wallet_address, counter, branch), {:ok, preapplied_operations} <- preapply_operation(rpc, operation, encoded_private_key, protocol), {:ok, operation} <- fill_operation_fee(operation, preapplied_operations, opts), {:ok, payload} <- forge_and_sign_operation(operation, encoded_private_key) do inject_operation(rpc, payload) end end @doc """ Sign the forged operation and returns the forged operation+signature payload to be injected. """ @spec forge_and_sign_operation(operation(), encoded_private_key()) :: {:ok, nonempty_binary()} | {:error, ForgeOperation.error_reason()} def forge_and_sign_operation(operation, encoded_private_key) do with {:ok, forged_operation} <- ForgeOperation.operation_group(operation) do signature = Crypto.sign_operation(encoded_private_key, forged_operation) payload_signature = signature |> Crypto.decode_signature!() |> Base.encode16(case: :lower) signed_payload = forged_operation <> payload_signature {:ok, signed_payload} end end @doc """ Simulate the application of the operations with the context of the given block and return the result of each operation application. """ @spec preapply_operation(t(), map(), encoded_private_key(), any()) :: {:ok, list()} | {:error, error_reason()} def preapply_operation(%Rpc{} = rpc, operation, encoded_private_key, protocol) do with {:ok, forged_operation} <- ForgeOperation.operation_group(operation), signature = Crypto.sign_operation(encoded_private_key, forged_operation), payload = [Map.merge(operation, %{"signature" => signature, "protocol" => protocol})], {:ok, preapplied_operations} <- do_preapply_operation(rpc, payload) do {:ok, preapplied_operations} end end defp do_preapply_operation(%Rpc{} = rpc, payload) do case post(rpc, "/blocks/head/helpers/preapply/operations", payload) do {:ok, [%{"contents" => preapplied_operations}]} when is_list(preapplied_operations) -> applied? = Enum.all?( preapplied_operations, &(&1["metadata"]["operation_result"]["status"] == "applied") ) if applied? do {:ok, preapplied_operations} else errors = Enum.flat_map( preapplied_operations, fn %{ "metadata" => %{ "internal_operation_results" => internal_operation_results } } -> Enum.flat_map(internal_operation_results, & &1["result"]["errors"]) _ -> [] end ) errors = if Enum.empty?(errors) do Enum.map(preapplied_operations, & &1["metadata"]["operation_result"]["errors"]) else errors end {:error, {:preapply_failed, errors}} end {:ok, result} -> {:error, {:unexpected_response, result}} err -> err end end @spec get_counter_for_account(t(), nonempty_binary()) :: {:ok, integer()} | {:error, error_reason()} def get_counter_for_account(%Rpc{} = rpc, address) do with {:ok, n} <- get(rpc, "/blocks/head/context/contracts/#{address}/counter") do case Integer.parse(n) do {parsed, ""} -> {:ok, parsed} _ -> {:error, {:invalid_counter, n}} end end end @spec get_next_counter_for_account(t(), nonempty_binary()) :: {:ok, integer()} | {:error, error_reason()} def get_next_counter_for_account(%Rpc{} = rpc, address) do with {:ok, n} <- get_counter_for_account(rpc, address) do {:ok, n + 1} end end @spec get_block(t()) :: {:ok, map()} | {:error, transport_error()} @spec get_block(t(), nonempty_binary()) :: {:ok, map()} | {:error, transport_error()} def get_block(%Rpc{} = rpc, hash \\ "head") do get(rpc, "/blocks/#{hash}") end @spec get_block_at_offset(t(), integer()) :: {:ok, map()} | {:error, transport_error()} def get_block_at_offset(%Rpc{} = rpc, offset) do if offset <= 0 do get_block(rpc) else with {:ok, head} <- get_block(rpc) do get(rpc, "/blocks/#{head["header"]["level"] - offset}") end end end @spec inject_operation(t(), any()) :: {:ok, any()} | {:error, transport_error()} def inject_operation(%Rpc{} = rpc, payload) do post(rpc, "/injection/operation", payload) end @spec get_balance(t(), nonempty_binary()) :: {:ok, pos_integer()} | {:error, error_reason()} def get_balance(%Rpc{} = rpc, address) do with {:ok, balance} <- get(rpc, "/blocks/head/context/contracts/#{address}/balance") do case Integer.parse(balance) do {parsed, ""} -> {:ok, parsed} _ -> {:error, {:invalid_balance, balance}} end end end @spec get(Tezex.Rpc.t(), nonempty_binary()) :: {:ok, any()} | {:error, transport_error()} defp get(%Rpc{} = rpc, path) do url = URI.parse(rpc.endpoint) |> URI.append_path("/chains/#{rpc.chain_id}") |> URI.append_path(path) |> URI.to_string() Finch.build(:get, url, rpc.headers) |> Finch.request(Tezex.Finch, rpc.opts) |> case do {:ok, %Finch.Response{status: 200, body: body}} -> case Jason.decode(body) do {:ok, _} = ok -> ok {:error, e} -> {:error, {:decode, e}} end {:ok, resp} -> {:error, {:http_status, resp}} {:error, e} -> {:error, {:transport, e}} end end @spec post(Tezex.Rpc.t(), nonempty_binary(), any()) :: {:ok, any()} | {:error, transport_error()} defp post(%Rpc{} = rpc, path, body) do url = URI.parse(rpc.endpoint) url = if String.starts_with?(path, "/blocks") do URI.append_path(url, "/chains/#{rpc.chain_id}") else URI.append_query(url, URI.encode_query(%{"chain" => rpc.chain_id})) end url = url |> URI.append_path(path) |> URI.to_string() body = Jason.encode!(body) Finch.build(:post, url, rpc.headers, body) |> Finch.request(Tezex.Finch, rpc.opts) |> case do {:ok, %Finch.Response{status: 200, body: body}} -> case Jason.decode(body) do {:ok, _} = ok -> ok {:error, e} -> {:error, {:decode, e}} end {:ok, resp} -> {:error, {:http_status, resp}} {:error, e} -> {:error, {:transport, e}} end end # NOTE: Explanation: https://pytezos.baking-bad.org/tutorials/02.html#operation-group @spec validation_passes(nonempty_binary()) :: -1 | 0 | 1 | 2 | 3 defp validation_passes(kind) do case kind do "failing_noop" -> -1 "endorsement" -> 0 "endorsement_with_slot" -> 0 "proposals" -> 1 "ballot" -> 1 "seed_nonce_revelation" -> 2 "double_endorsement_evidence" -> 2 "double_baking_evidence" -> 2 "activate_account" -> 2 "reveal" -> 3 "transaction" -> 3 "origination" -> 3 "delegation" -> 3 "register_global_constant" -> 3 "transfer_ticket" -> 3 "smart_rollup_add_messages" -> 3 "smart_rollup_execute_outbox_message" -> 3 kind -> raise "unknown operation kind: #{kind}" end end end