lib/etherex.ex

defmodule Etherex do
  @moduledoc """
  Elixir high level library for the Ethereum blockchain. This library
  is based on the methods of the Json RPC API
  (https://eth.wiki/json-rpc/api).

  All functions returns `{:error, :econnrefused}` if no client is
  listening in the configured URL.

  You can directly use Elixir integers for quantities or atoms for
  tags. The library encode/decode Elixir primitive data to/from the
  blockchain.

  Compiled code and contract instances are abstracted. The library can
  compile code and generate a value of type `t:compiled_code/0` and
  that representation can be used to deploy the contact and get a
  value of `t:contract/0` that can be used in `call/5` and
  `call_transaction/5`.

  Some operations allow options. Options are `t:Keyword.t/0` that
  accepts optional data accepted by the API (see
  https://eth.wiki/json-rpc/api for details when no details are given
  in this documentation).

  The result of some operations are maps with a direct representation
  of the returns of the operations in the API (see
  https://eth.wiki/json-rpc/api for details when no details are given
  in this documentation).
  """

  alias Ethereumex.HttpClient, as: JsonRPC

  alias Etherex.Contract

  import Etherex.Helpers,
    only: [
      add_opts: 2,
      decode_json_rpc_error: 1,
      decode_quantity: 1,
      encode_block_parameter: 1
    ]

  alias Etherex.Type

  @type address() :: Type.address()
  @type hash() :: Type.hash()
  @type hex() :: Type.hex()
  @type tag() :: Type.tag()
  @type quantity() :: Type.quantity()
  @type unit() :: Type.unit()
  @type error() :: Type.error()
  @type event() :: Type.event()
  @type opts() :: Type.opts()
  @type block_parameter() :: Type.block_parameter()

  @opaque bytecode() :: Contract.bytecode()
  @opaque contract() :: Contract.contract()

  @doc """
  Returns the current client version.
  """
  @spec client_version() :: {:ok, String.t()} | {:error, error()}
  def client_version() do
    case JsonRPC.web3_client_version() do
      {:ok, version} -> {:ok, version}
      error -> decode_json_rpc_error(error)
    end
  end

  @doc """
  Works like `client_version/0` but a exception is raised on error.
  """
  @spec client_version!() :: String.t()
  def client_version!() do
    {:ok, v} = client_version()
    v
  end

  @doc """
  Returns syncing status.
  """
  @spec syncing() :: {:ok, map() | nil} | {:error, error()}
  def syncing() do
    case JsonRPC.eth_syncing() do
      {:ok, false} ->
        {:ok, nil}

      {:ok, status} ->
        {:ok, status |> decode_quantities()}

      error ->
        decode_json_rpc_error(error)
    end
  end

  @doc """
  Works like `syncing/0` but a exception is raised on error.
  """
  @spec syncing!() :: map()
  def syncing!() do
    {:ok, status} = syncing()
    status
  end

  @doc """
  Returns the current network id.
  """
  @spec net_version() :: {:ok, String.t()} | {:error, error()}
  def net_version() do
    case JsonRPC.net_version() do
      {:ok, version} -> {:ok, version}
      error -> decode_json_rpc_error(error)
    end
  end

  @doc """
  Works like `net_version/0` but a exception is raised on error.
  """
  @spec net_version!() :: String.t()
  def net_version!() do
    {:ok, v} = net_version()
    v
  end

  @doc """
  Returns true if client is actively listening for network connections, false otherwise.
  """
  @spec net_listening() :: {:ok, boolean()} | {:error, error()}
  def(net_listening()) do
    case JsonRPC.net_listening() do
      {:ok, listening} -> {:ok, listening}
      error -> decode_json_rpc_error(error)
    end
  end

  @doc """
  Works like `net_listening/0` but a exception is raised on error.
  """
  @spec net_listening!() :: boolean()
  def net_listening!() do
    case net_listening() do
      {:ok, listening} -> listening
    end
  end

  @doc """
  Returns the current ethereum protocol version.
  """
  @spec protocol_version() :: {:ok, String.t()} | {:error, error()}
  def protocol_version() do
    case JsonRPC.eth_protocol_version() do
      {:ok, version} -> {:ok, version}
      error -> decode_json_rpc_error(error)
    end
  end

  @doc """
  Works like `protocol_version/0` but a exception is raised on error.
  """
  @spec protocol_version!() :: String.t()
  def protocol_version!() do
    {:ok, version} = protocol_version()
    version
  end

  @doc """
  Returns a list of addresses owned by client.
  """
  @spec accounts() :: {:ok, [address()]} | {:error, error()}
  def accounts() do
    case JsonRPC.eth_accounts() do
      {:ok, accounts} -> {:ok, accounts}
      error -> decode_json_rpc_error(error)
    end
  end

  @doc """
  Works like `accounts/0` but a exception is raised on error.
  """
  @spec accounts!() :: [address()]
  def accounts!() do
    {:ok, accounts} = accounts()
    accounts
  end

  @doc """
  Returns the balance of the account of given address.
  """
  @spec get_balance(address(), block_parameter()) :: {:ok, quantity()} | {:error, error()}
  def get_balance(address, block \\ :latest) do
    case JsonRPC.eth_get_balance(address, encode_block_parameter(block)) do
      {:ok, balance} -> {:ok, balance |> decode_quantity}
      error -> decode_json_rpc_error(error)
    end
  end

  @doc """
  Works like `get_balance/2` but a exception is raised on error.
  """
  @spec get_balance!(address(), block_parameter()) :: quantity()
  def get_balance!(address, block \\ :latest) do
    {:ok, balance} = get_balance(address, block)
    balance
  end

  @doc """
  Transfer funds.
  """
  @spec transfer(
          from :: address(),
          to :: address(),
          value :: quantity(),
          opts :: opts()
        ) ::
          {:ok, hash()} | {:error, error()}
  def transfer(from, to, value, opts \\ []) do
    %{from: from, to: to, value: value}
    |> add_opts(opts)
    |> JsonRPC.eth_send_transaction()
    |> case do
      {:ok, hash} ->
        {:ok, hash}

      error ->
        decode_json_rpc_error(error)
    end
  end

  @doc """
  Deploys a contract to the blockchain.

  ## Examples

      iex> owner = List.last(Etherex.accounts!())
      iex> {:ok, _contract} = Etherex.compile_solidity!("priv/solidity/Counter.sol")
      ...> |> Etherex.deploy(owner, [], gas: 1_000_000)

  """
  @spec deploy(
          contract :: bytecode(),
          creator :: address(),
          arguments :: list(),
          opts :: opts()
        ) :: {:ok, contract()} | {:error, error()}
  def deploy(contract, creator, arguments, opts \\ []) do
    Contract.deploy(contract, creator, arguments, opts)
  end

  @doc """
  Works like `deploy/4` but a exception is raised on error.
  """
  @spec deploy!(
          code :: bytecode(),
          creator :: address(),
          arguments :: list(),
          opts :: opts()
        ) :: contract()
  def deploy!(code, creator, arguments, opts \\ []) do
    {:ok, contract} = deploy(code, creator, arguments, opts)
    contract
  end

  @doc """
  Performs a call to a function of a deployed contract. This call does not
  generate a transaction in the blockchain.

  ## Examples

      iex> owner = List.last(Etherex.accounts!())
      iex> {:ok, [value]} = Etherex.compile_solidity!("priv/solidity/Counter.sol")
      ...> |> Etherex.deploy!(owner, [], gas: 1_000_000)
      ...> |> Etherex.call(owner, "get", [])
      iex> value
      0

  """
  @spec call(
          contract :: contract(),
          caller :: address(),
          function :: String.t(),
          arguments :: list(),
          block :: block_parameter(),
          opts :: Keyword.t()
        ) :: {:ok, any()} | {:error, error()}
  def call(contract, caller, function, arguments, block \\ :latest, opts \\ []) do
    Contract.call(contract, caller, function, arguments, block, opts)
  end

  @doc """
  Works like `call/6` but a exception is raised on error.
  """
  @spec call!(
          contract :: contract(),
          caller :: address(),
          function :: String.t(),
          arguments :: list(),
          block :: block_parameter(),
          opts :: Keyword.t()
        ) :: any()
  def call!(contract, caller, function, arguments, block \\ :latest, opts \\ []) do
    {:ok, result} = call(contract, caller, function, arguments, block, opts)
    result
  end

  @doc """
  Generates and returns an estimate of how much gas is necessary to
  allow the transaction to complete. The transaction will not be added
  to the blockchain. Note that the estimate may be significantly more
  than the amount of gas actually used by the transaction, for a
  variety of reasons including EVM mechanics and node performance.

  ## Examples

      iex> owner = List.last(Etherex.accounts!())
      iex> {:ok, estimated_gas} = Etherex.compile_solidity!("priv/solidity/Counter.sol")
      ...> |> Etherex.deploy!(owner, [], gas: 1_000_000)
      ...> |> Etherex.estimate_gas(owner, "get", [])
      iex> is_integer(estimated_gas)
      true

  """
  @spec estimate_gas(
          contract :: contract(),
          caller :: address(),
          function :: String.t(),
          arguments :: list(),
          opts :: opts()
        ) :: {:ok, any()} | {:error, error()}
  def estimate_gas(contract, caller, function, arguments, opts \\ []) do
    Contract.estimate_gas(contract, caller, function, arguments, opts)
  end

  @doc """
  Works like `estimate_gas/5` but a exception is raised on error.
  """
  @spec estimate_gas!(
          contract :: contract(),
          caller :: address(),
          function :: String.t(),
          arguments :: list(),
          opts :: Keyword.t()
        ) :: any()
  def estimate_gas!(contract, caller, function, arguments, opts \\ []) do
    {:ok, result} = estimate_gas(contract, caller, function, arguments, opts)
    result
  end

  @doc """
  Generates and returns an estimate the gas cost of a transaction. It
  uses `estimate_gas/5` and `gas_price/0`.

  ## Examples

      iex> owner = List.last(Etherex.accounts!())
      iex> {:ok, gas_cost} = Etherex.compile_solidity!("priv/solidity/Counter.sol")
      ...> |> Etherex.deploy!(owner, [], gas: 1_000_000)
      ...> |> Etherex.estimate_gas_cost(owner, "get", [])
      iex> is_integer(gas_cost)
      true

  """
  @spec estimate_gas_cost(
          contract :: contract(),
          caller :: address(),
          function :: String.t(),
          arguments :: list(),
          opts :: opts()
        ) :: {:ok, any()} | {:error, error()}
  def estimate_gas_cost(contract, caller, function, arguments, opts \\ []) do
    with {:ok, estimated_gas} <- estimate_gas(contract, caller, function, arguments, opts),
         {:ok, gas_price} <- gas_price() do
      {:ok, estimated_gas * gas_price}
    else
      error -> error
    end
  end

  @doc """
  Works like `estimate_gas_cost/5` but a exception is raised on error.
  """
  @spec estimate_gas_cost!(
          contract :: contract(),
          caller :: address(),
          function :: String.t(),
          arguments :: list(),
          opts :: Keyword.t()
        ) :: any()
  def estimate_gas_cost!(contract, caller, function, arguments, opts \\ []) do
    {:ok, result} = estimate_gas_cost(contract, caller, function, arguments, opts)
    result
  end

  @doc """
  Performs a call to a function of a deployed contract, This call generates a
  transaction in the blockchain.

  ## Examples

      iex> owner = List.last(Etherex.accounts!())
      iex> {:ok, "0x" <> _} = Etherex.compile_solidity!("priv/solidity/Counter.sol")
      ...> |> Etherex.deploy!(owner, [], gas: 1_000_000)
      ...> |> Etherex.call_transaction(owner, "get", [])

  """
  @spec call_transaction(
          contract :: contract(),
          caller :: address(),
          function :: String.t(),
          arguments :: list(),
          opts :: opts()
        ) :: {:ok, hash()} | {:error, error()}
  def call_transaction(contract, caller, function, arguments, opts \\ []) do
    Contract.call_transaction(contract, caller, function, arguments, opts)
  end

  @doc """
  Works like `call_transaction/5` but a exception is raised on error.
  """
  @spec call_transaction!(
          contract :: contract(),
          caller :: address(),
          function :: String.t(),
          arguments :: list(),
          opts :: opts()
        ) :: hash()
  def call_transaction!(contract, caller, function, arguments, opts \\ []) do
    {:ok, hash} = call_transaction(contract, caller, function, arguments, opts)
    hash
  end

  @doc """
  Returns the information about a transaction requested by transaction
  hash.
  """
  @spec get_transaction(hash) :: {:ok, map()} | {:error, error}
  def get_transaction(hash) do
    case JsonRPC.eth_get_transaction_by_hash(hash) do
      {:ok, transaction} ->
        {:ok, decode_transaction(transaction)}

      error ->
        decode_json_rpc_error(error)
    end
  end

  @doc """
  Works like `get_transaction/1` but a exception is raised on error.
  """
  @spec get_transaction!(hash) :: map()
  def get_transaction!(hash) do
    {:ok, transaction} = get_transaction(hash)
    transaction
  end

  @doc """
  Returns the receipt of a transaction by transaction hash. The
  receipt is not available for pending transactions and `{:ok, nil}` is
  returned.
  """
  @spec get_transaction_receipt(Type.hash()) :: {:ok, map() | nil} | {:error, Type.error()}
  def get_transaction_receipt(hash) do
    case JsonRPC.eth_get_transaction_receipt(hash) do
      {:ok, receipt} ->
        {:ok,
         receipt
         |> decode_quantities([
           "blockNumber",
           "cumulativeGasUsed",
           "gasUsed",
           "status",
           "transactionIndex"
         ])}

      error ->
        decode_json_rpc_error(error)
    end
  end

  @doc """
  Works like `get_transaction_receipt/1` but a exception is raised on error.
  """
  @spec get_transaction_receipt!(hash()) :: map() | nil
  def get_transaction_receipt!(hash) do
    {:ok, receipt} = get_transaction_receipt(hash)
    receipt
  end

  @doc """
  Returns the gas cost of a transaction. If the transaction hasn't
  been processed yet (is pending), `{:ok, nil}` is returned.
  """
  @spec gas_cost(hash()) :: {:ok, quantity() | nil} | {:error, error()}
  def gas_cost(hash) do
    with {:ok, receipt} <- get_transaction_receipt(hash),
         {:ok, transaction} <- get_transaction(hash) do
      gas_used = receipt["gasUsed"]

      if is_nil(gas_used) do
        {:ok, nil}
      else
        {:ok, gas_used * transaction["gasPrice"]}
      end
    else
      error ->
        error
    end
  end

  @doc """
  Works like `gas_cost/1` but a exception is raised on error.
  """
  @spec gas_cost!(hash()) :: quantity() | nil
  def gas_cost!(hash) do
    {:ok, gas_cost} = gas_cost(hash)
    gas_cost
  end

  @doc """
  Given a contract and a transaction of the contract, retrieves the
  list of events logged in it. Returns `[]` if no events. Returns
  `nil` if transaction is pending.
  """
  @spec get_events(contract() | bytecode(), hash()) :: {:ok, [event()] | nil} | {:error, error()}
  def get_events(contract_or_bytecode, hash) do
    Contract.get_events(contract_or_bytecode, hash)
  end

  @doc """
  Works like `get_events/2` but a exception is raised on error.
  """
  @spec get_events!(contract(), hash()) :: [event()] | nil
  def get_events!(contract, hash) do
    {:ok, events} = get_events(contract, hash)
    events
  end

  @doc """
  Returns the current price per gas in wei.
  """
  @spec gas_price() :: {:ok, quantity()} | {:error, error()}
  def gas_price do
    case JsonRPC.eth_gas_price() do
      {:ok, price} ->
        {:ok, price |> decode_quantity}

      error ->
        decode_json_rpc_error(error)
    end
  end

  @doc """
  Works like `gas_price/0` but a exception is raised on error.
  """
  @spec gas_price!() :: quantity()
  def gas_price!() do
    {:ok, price} = gas_price()
    price
  end

  @doc """
  Returns the number of most recent block.
  """
  @spec block_number() :: {:ok, quantity()} | {:error, error()}
  def block_number() do
    case JsonRPC.eth_block_number() do
      {:ok, block_number} ->
        {:ok, block_number |> decode_quantity}

      error ->
        decode_json_rpc_error(error)
    end
  end

  @doc """
  Works like `block_number/0` but a exception is raised on error.
  """
  @spec block_number!() :: quantity()
  def block_number! do
    {:ok, block_number} = block_number()
    block_number
  end

  @doc """
  Returns information about a block by block number or by block hash.
  """
  @spec get_block(hash() | block_parameter()) :: {:ok, map()} | {:error, error()}
  def get_block(number_or_hash \\ :latest) do
    cond do
      is_binary(number_or_hash) ->
        JsonRPC.eth_get_block_by_hash(number_or_hash, true)

      true ->
        JsonRPC.eth_get_block_by_number(encode_block_parameter(number_or_hash), true)
    end
    |> case do
      {:ok, block} ->
        {:ok,
         block
         |> decode_quantities([
           "difficulty",
           "gasLimit",
           "gasUsed",
           "number",
           "size",
           "timestamp",
           "totalDifficulty"
         ])
         |> Map.update!("transactions", fn transactions ->
           for transaction <- transactions, do: decode_transaction(transaction)
         end)}

      error ->
        decode_json_rpc_error(error)
    end
  end

  @doc """
  Works like `get_block/1` but a exception is raised on error.
  """
  @spec get_block!(quantity() | hash() | tag()) :: map()
  def get_block!(number_or_hash) do
    {:ok, block} = get_block(number_or_hash)
    block
  end

  @doc """
  Returns the byte code after compiling Solidity source
  code. Parameter `source_or_filename` can be the source code or a
  filename with the source code.

  If source code is passed, the function tries to send the code to the
  client to get the compiled contract.

  If a filename is passed, a local compiler is used to compile the
  code. If no local compiler can be used or compilation fails then the
  source code in file is sent to the client.
  """
  @spec compile_solidity(String.t()) :: {:ok, bytecode()} | {:error, error()}
  def compile_solidity(source_or_filename) do
    Contract.compile_solidity(source_or_filename)
  end

  @doc """
  Works like `compile_solidity/1` but a exception is raised on error.
  """
  @spec compile_solidity!(String.t()) :: bytecode()
  def compile_solidity!(source_or_filename) do
    {:ok, bytecode} = compile_solidity(source_or_filename)
    bytecode
  end

  @doc """
  Returns the contract creator address.
  """
  @spec get_creator(contract()) :: address()
  def get_creator(contract), do: Contract.get_creator(contract)

  @doc """
  Returns the deployed bytecode of a contract.
  """
  @spec get_bytecode(contract()) :: bytecode()
  def get_bytecode(contract), do: Contract.get_bytecode(contract)

  @doc """
  Returns the hash of the creation transaction of a contract.
  """
  @spec get_contract_creation_hash(contract()) :: hash()
  def get_contract_creation_hash(contract), do: Contract.get_creation_hash(contract)

  @doc """
  Returns the address of a contract. If the creation transaction has not been mined yet, `nil` is returned.
  """
  @spec get_contract_address(contract()) :: {:ok, address() | nil} | {:error, error()}
  def get_contract_address(contract), do: Contract.get_address(contract)

  @doc """
  Works like `get_contract_creation_address/1` but a exception is raised on error.
  """
  @spec get_contract_address!(contract()) :: address() | nil
  def get_contract_address!(contract) do
    {:ok, address} = get_contract_address(contract)
    address
  end

  ######################################################################
  ## Public helpers
  @doc """
  String representation of a value given an Ether unit.

  ## Examples

      iex> Etherex.wei_to_string(0)
      "0 WEI"

      iex> Etherex.wei_to_string(0, :WEI)
      "0 WEI"

      iex> Etherex.wei_to_string(0, :GWEI)
      "0.000000000 GWEI"

      iex> Etherex.wei_to_string(0, :PWEI)
      "0.000000000000000 PWEI"

      iex> Etherex.wei_to_string(0, :ETH)
      "0.000000000000000000 ETH"

      iex> Etherex.wei_to_string(20000000000)
      "20,000,000,000 WEI"

      iex> Etherex.wei_to_string(20000000000, :WEI)
      "20,000,000,000 WEI"

      iex> Etherex.wei_to_string(20000000000, :GWEI)
      "20.000000000 GWEI"

      iex> Etherex.wei_to_string(20000000000, :PWEI)
      "0.000020000000000 PWEI"

      iex> Etherex.wei_to_string(20000000000, :ETH)
      "0.000000020000000000 ETH"

  """
  @spec wei_to_string(quantity(), unit()) :: String.t()
  def wei_to_string(wei, currency \\ :WEI) when is_integer(wei) do
    Money.new(wei, currency)
    |> Money.to_string(symbol_space: true, symbol_on_right: true)
  end

  @doc """
  Decimal representation of a value given an Ether unit.

  ## Examples

      iex> Etherex.wei_to_decimal(0)
      Decimal.new("0")

      iex> Etherex.wei_to_decimal(0, :WEI)
      Decimal.new("0")

      iex> Etherex.wei_to_decimal(0, :ETH)
      Decimal.new("0E-18")

      iex> Etherex.wei_to_decimal(20000000000)
      Decimal.new("20000000000")

      iex> Etherex.wei_to_decimal(20000000000, :WEI)
      Decimal.new("20000000000")

      iex> Etherex.wei_to_decimal(20000000000, :GWEI)
      Decimal.new("20.000000000")

      iex> Etherex.wei_to_decimal(20000000000, :PWEI)
      Decimal.new("0.000020000000000")

      iex> Etherex.wei_to_decimal(20000000000, :ETH)
      Decimal.new("2.0000000000E-8")

  """
  @spec wei_to_decimal(quantity(), unit()) :: Decimal.t()
  def wei_to_decimal(wei, currency \\ :WEI) when is_integer(wei) do
    Money.new(wei, currency)
    |> Money.to_decimal()
  end

  ######################################################################
  ## Helpers
  @spec decode_quantities(nil, [String.t()]) :: nil
  @spec decode_quantities(map(), [String.t()]) :: map()
  defp decode_quantities(value, keys \\ [])
  defp decode_quantities(nil, _keys), do: nil

  defp decode_quantities(map, keys) when is_map(map) do
    Enum.reduce(
      keys,
      map,
      fn k, m ->
        Map.update!(
          m,
          k,
          fn
            nil ->
              nil

            v ->
              decode_quantity(v)
          end
        )
      end
    )
  end

  @spec decode_transaction(map()) :: map()
  defp decode_transaction(transaction) do
    transaction
    |> decode_quantities([
      "blockNumber",
      "gas",
      "gasPrice",
      "transactionIndex",
      "v",
      "value"
    ])
  end
end