lib/icon/rpc/request.ex

defmodule Icon.RPC.Request do
  @moduledoc """
  This module defines a basic JSON RPC request payloads.

  ## Building a Request

  The requests built using `build/3` are prepared to be encoded as a JSON
  accepted by the ICON 2.0 JSON RPC v3. For building a request we need three
  things:

  - The name of the method we're calling.
  - The parameters we're sending along the method.
  - Some options to both validate the parameters, sign any transaction and
    build the actual request.

  e.g. let's say we want to query a block by its height, we would do the
  following:

  ```elixir
  iex> method = "icx_getBlockByHeight"
  iex> params = %{height: 42}
  iex> schema = %{height: :pos_integer}
  iex> Icon.RPC.Request.build(method, params, schema: schema)
  %Icon.RPC.Request{
    id: 1_639_382_704_065_742_380,
    method: "icx_getBlockByHeight",
    options: %{
      schema: %{height: :pos_integer},
      identity: #Identity<
        node: "https://ctz.solidwallet.io",
        network_id: "0x01 (Mainnet)",
        debug: false
      >,
      url: "https://ctz.solidwallet.io/api/v3"
    },
    params: %{height: 42}
  }
  ```

  > Note: The previous example is for documentation purpose. This functionality
  > is already present in `Icon.RPC.Request.Goloop`, so no need to build the
  > request ourselves.

  And, when using the function `Jason.encode!`, we'll get the following JSON:

  ```json
  {
    "id": 1639382704065742380,
    "jsonrpc": "2.0",
    "method": "icx_getBlockByHeight",
    "params": {
      "height": "0x2a"
    }
  }
  ```

  ## Signing a Transaction

  Transactions need to be signed before sending them to the node. With the
  signature is possible to verify it comes from the wallet requesting it e.g.
  let's say we want to sent 1 ICX from one wallet to another:

  ```elixir
  iex> request = Icon.RPC.Request.build("icx_sendTransaction", ...)
  %Icon.RPC.Request{
    id: 1_639_382_704_065_742_380,
    method: "icx_sendTransaction",
    options: ...,
    params: %{
      from: "hx2e243ad926ac48d15156756fce28314357d49d83",
      to: "hxdd3ead969f0dfb0b72265ca584092a3fb25d27e0",
      value: 1_000_000_000_000_000_000,
      ...
    }
  }
  ```

  then we can sign it as follows:

  ```elixir
  iex> {:ok, request} = Icon.RPC.Request.sign(request)
  {
    :ok,
    %Icon.RPC.Request{
      id: 1_639_382_704_065_742_380,
      method: "icx_sendTransaction",
      options: ...,
      params: %{
        from: "hx2e243ad926ac48d15156756fce28314357d49d83",
        to: "hxdd3ead969f0dfb0b72265ca584092a3fb25d27e0",
        value: 1_000_000_000_000_000_000,
        signature: "Kut8d4uXzy0UPIU13l3OW5Ba3WNuq6B6w7+0v4XR4qQNv1Cy3qOmn7ih4TZrXZGT3qhkaRM/WCL+qmWyh86/tgA="
        ...
      }
    }
  }
  ```

  and also verify it to check everything is correct after signing it:

  ```elixir
  iex> Icon.RPC.Request.verify(request)
  true
  ```

  > Note: In order to sign a transaction, the option `identity` is mandatory and
  > it needs to be built using a `private_key` e.g.
  > ```elixir
  > iex> Icon.RPC.Identity.new(private_key: "8ad9...")
  > #Identity<[
  >   node: "https://ctz.solidwallet.io",
  >   network_id: "0x1 (Mainnet)",
  >   debug: false,
  >   address: "hxfd7e4560ba363f5aabd32caac7317feeee70ea57",
  >   private_key: "8ad9..."
  > ]>
  > ```

  ### Sending the Request

  Once we're satisfied with our request, we can send it to the ICON 2.0
  blockchain using the `send/1` function:

  ```elixir
  iex> Icon.RPC.Request.send(request)
  {:ok, "0xd579ce6162019928d874da9bd1dbf7cced2359a5614e8aa0bf7cf75f3770504b"}
  ```

  where the `response()` will depend on the actual method being called.
  """
  import Icon.RPC.Identity, only: [can_sign: 1]

  alias Icon.RPC.Identity
  alias Icon.RPC.Request.Goloop
  alias Icon.Schema
  alias Icon.Schema.Error

  @enforce_keys [:id, :method, :params, :options]

  @doc false
  defstruct [:id, :method, :params, :options]

  @typedoc """
  RPC ID.
  """
  @type id :: pos_integer()

  @typedoc """
  RPC Method.
  """
  @type method :: binary()

  @typedoc """
  RPC call parameters.
  """
  @type params :: map()

  @typedoc """
  RPC option.
  """
  @type option ::
          {:schema, module() | Schema.t()}
          | {:timeout, non_neg_integer()}
          | {:identity, Identity.t()}
          | {:url, binary()}

  @typedoc """
  RPC options.
  """
  @type options :: [option()]

  @typedoc """
  A JSON RPC request.
  """
  @type t :: %__MODULE__{
          id: id :: pos_integer(),
          method: method :: method(),
          params: params :: params(),
          options:
            options :: %{
              required(:identity) => Identity.t(),
              required(:url) => binary(),
              optional(:schema) => module() | Schema.t(),
              optional(:timeout) => non_neg_integer()
            }
        }

  @typedoc """
  A JSON RPC response.
  """
  @type response :: binary() | list() | map()

  @transactions ["icx_sendTransaction", "icx_sendTransactionAndWait"]

  @doc """
  Builds an RPC request given a `method`, some `parameters` and general
  `options`.

  The `method` and `parameters` depend on the JSON RPC method we're calling,
  while the `options` have extra instructions for the actual request.

  Options:
  - `timeout` - Whether we should have a timeout on the call or not. This
    timeout only applies to methods we can wait on the result e.g.
    `icx_waitTransactionResult` and `icx_sendTransactionAndWait`.
  - `schema` - `Icon.Schema` for verifying the call `parameters`.
  - `identity` - `Icon.RPC.Identity` of the wallet performing the action. A full
    identity is not required for most readonly calls. However, it is necessary
    to have a full wallet configure for sending transactions to the ICON 2.0
    blockchain.
  - `url` - This endpoint is set automatically by the request builder.

  ## Encoding

  Though having the `schema` option is not mandatory, it is necessary whenever
  we're calling a `method` with `parameters` in order to convert them to the
  ICON 2.0 type representation as well as serializing transactions. For more
  information, check `Icon.Schema` module.

  ### Example

  The following example shows how to build a request for querying a block by
  `height`:

  ```elixir
  iex> method = "icx_getBlockByHeight"
  iex> params = %{height: 42}
  iex> schema = %{height: :pos_integer}
  iex> Icon.RPC.Request.build(method, params, schema: schema)
  %Icon.RPC.Request{
    id: 1_639_382_704_065_742_380,
    method: "icx_getBlockByHeight",
    options: %{
      schema: %{height: :pos_integer},
      identity: #Identity<
        node: "https://ctz.solidwallet.io",
        network_id: "0x01 (Mainnet)",
        debug: false
      >,
      url: "https://ctz.solidwallet.io/api/v3"
    },
    params: %{height: 42}
  }
  ```
  """
  @spec build(method(), params(), options()) :: t()
  def build(method, params, options)
      when is_binary(method) and is_map(params) and is_list(options) do
    options =
      options
      |> Keyword.put_new(:identity, Identity.new())
      |> put_url()
      |> Map.new()

    %__MODULE__{
      id: :erlang.system_time(),
      method: method,
      params: params,
      options: options
    }
  end

  @doc """
  Adds step limit to a transaction. If no value is provided, then it will
  request for a node estimation.
  """
  @spec add_step_limit(t()) :: {:ok, t()} | {:error, Error.t()}
  @spec add_step_limit(t(), nil | Schema.Types.Loop.t()) ::
          {:ok, t()}
          | {:error, Error.t()}
  def add_step_limit(request, step_limit \\ nil)

  def add_step_limit(
        %__MODULE__{method: method, params: %{stepLimit: step_limit}} = request,
        nil
      )
      when method in @transactions and is_integer(step_limit) and step_limit > 0 do
    {:ok, request}
  end

  def add_step_limit(%__MODULE__{method: method} = request, nil)
      when method in @transactions do
    case estimate_step(request) do
      {:ok, step_limit} ->
        add_step_limit(request, step_limit)

      {:error, %Error{}} = error ->
        error
    end
  end

  def add_step_limit(%__MODULE__{method: method} = request, step_limit)
      when is_integer(step_limit) and step_limit > 0 and method in @transactions do
    params = Map.put(request.params, :stepLimit, step_limit)
    request = %{request | params: params}

    {:ok, request}
  end

  def add_step_limit(%__MODULE__{} = _request, _step_limit) do
    reason =
      Error.new(
        reason: :invalid_request,
        message: "only transactions have step limit"
      )

    {:error, reason}
  end

  @doc """
  Serializes a transaction `request`.

  When building a transaction signature, one of the steps of the process is
  serializing the transaction. In general, the serialization process goes as
  follows:
  1. Convert the JSON RPC method parameters to the ICON representation.
  2. Serialize them.

  E.g. a request like the following:

  ```elixir
  %Icon.RPC.Request{
    id: 1_641_400_211_292_452_380,
    method: "icx_sendTransaction",
    options: ...,
    params: %{
      from: "hx2e243ad926ac48d15156756fce28314357d49d83",
      to: "hxdd3ead969f0dfb0b72265ca584092a3fb25d27e0",
      nid: 1,
      version: 3,
      timestamp: ~U[2022-01-05 16:30:11.292452Z],
      stepLimit: 100_000,
      value: 1_000_000_000_000_000_000
    }
  }
  ```

  would be serialized as follows:

  ```text
  icx_sendTransaction.from.hx2e243ad926ac48d15156756fce28314357d49d83.nid.0x1.stepLimit.0x186a0.timestamp.0x5d4d844874124.to.hxdd3ead969f0dfb0b72265ca584092a3fb25d27e0.value.0xde0b6b3a7640000.version.0x3
  ```

  The serialization rules are simple:

  - Values should be encoded to the ICONs encoding e.g. the integer `1` would
    be converted to `"0x1"`.
  - `<key>`/`<value>` pairs in maps should be converted to `"<key>.<value>"`
    string e.g. `{:a, 1}` would be converted to `"a.0x1"`
  - All keys in a map should be in alphabetical order.
  - All maps except the top level one should be surrounded by braces e.g.
    `%{a: 1}` would be converted to `"{a.0x1}"`.
  - Lists should be surrounded by brackets and its elements should be separated
    by `.` e.g. `[1,2,3]` would be converted to `"[0x1.0x2.0x3]"`.
  - The top level map should be preceded by `"icx_sendTransaction."` prefix e.g.
    `%{from: "hx...", ...}` would be converted to
    `"icx_sendTransaction.from.hx..."`
  - Any of the characters `\\`, `{`, `}`, `[`, `]` and `.` should be escaped by
    adding a `\\` before them e.g. `%{message: "..."}` would be encoded as
    `{message.\\.\\.\\.}`.
  """
  @spec serialize(t()) :: {:ok, binary()} | {:error, Error.t()}
  def serialize(%__MODULE__{
        method: method,
        params: params,
        options: %{schema: schema}
      })
      when method in @transactions do
    state =
      schema
      |> Schema.generate()
      |> Schema.new(params)
      |> Schema.dump()

    with {:ok, params} <- Schema.apply(state) do
      serialized = "icx_sendTransaction.#{do_serialize(params)}"

      {:ok, serialized}
    end
  end

  def serialize(%__MODULE__{method: method}) do
    reason =
      Error.new(
        reason: :invalid_params,
        message: "cannot serialize method #{method}"
      )

    {:error, reason}
  end

  @doc """
  Signs `request`.

  Signing a request does the following:

  1. Serializes the parameters see `serialize/1`,
  2. Hash the serialized parameters with `SHA3_256` digest algorithm **twice**.
  3. Generate a SECP256K1 signature with the hash.
  4. Encode the signature in Base 64.
  5. Add the encoded signature to the transaction parameters.

  > Note: `Curvy` is the library used by this API for the signature. It
  > generates compact signatures in the form of `VRS` while ICON expects `RSV`
  > signatures. This modules handles the conversion between these formats
  > transparently.
  """
  @spec sign(t()) :: {:ok, t()} | {:error, Error.t()}
  def sign(request)

  def sign(
        %__MODULE__{
          method: method,
          params: params,
          options: %{identity: %Identity{} = identity}
        } = request
      )
      when is_map(params) and params != %{} and can_sign(identity) and
             method in @transactions do
    with {:ok, serialized} <- serialize(request) do
      {:ok, do_sign(request, serialized)}
    end
  end

  def sign(%__MODULE__{}) do
    reason =
      Error.new(
        reason: :invalid_request,
        message: "cannot sign request"
      )

    {:error, reason}
  end

  @doc """
  Whether a `request` is signed correctly or not.
  """
  @spec verify(t()) :: boolean()
  def verify(request)

  def verify(
        %__MODULE__{
          method: method,
          params: %{signature: signature} = params,
          options: %{identity: %Identity{key: key} = identity}
        } = request
      )
      when is_map(params) and params != %{} and can_sign(identity) and
             method in @transactions do
    with {:ok, decoded_signature} <- Base.decode64(signature),
         curvy_signature = to_curvy(decoded_signature),
         {:ok, serialized} <- serialize(request),
         hashed = hash(serialized),
         verified when is_boolean(verified) <-
           Curvy.verify(curvy_signature, hashed, key, hash: :sha3_256) do
      verified
    else
      _ ->
        false
    end
  end

  def verify(%__MODULE__{} = _request) do
    false
  end

  @doc """
  Sends a remote procedure call to an ICON 2.0 node.
  """
  @spec send(t()) :: {:ok, response()} | {:error, Error.t()}
  def send(request)

  def send(%__MODULE__{options: %{url: url}} = request) do
    payload = Jason.encode!(request)

    :post
    |> Finch.build(url, headers(request), payload)
    |> do_send()
  end

  ###############
  # Build helpers

  # Puts either the debug or the normal endpoint.
  @spec put_url(options()) :: options()
  defp put_url(options) do
    case options[:identity] do
      %Identity{debug: false, node: node} ->
        Keyword.put(options, :url, "#{node}/api/v3")

      %Identity{debug: true, node: node} ->
        Keyword.put(options, :url, "#{node}/api/v3d")
    end
  end

  ####################
  # Estimation helpers

  @spec estimate_step(t()) ::
          {:ok, pos_integer()}
          | {:error, Error.t()}
  defp estimate_step(request)

  defp estimate_step(%__MODULE__{
         params: params,
         options: %{schema: schema, identity: identity}
       }) do
    with {:ok, request} <-
           Goloop.estimate_step(identity, params, schema),
         {:ok, "0x" <> _ = value} <- send(request),
         {:ok, step_limit} <- Icon.Schema.Types.Integer.load(value) do
      {:ok, step_limit}
    else
      {:error, %Error{}} = error ->
        error

      _ ->
        reason =
          Error.new(
            reason: :system_error,
            message: "cannot estimate stepLimit"
          )

        {:error, reason}
    end
  end

  #######################
  # Serialization helpers

  @spec do_serialize(any()) :: binary()
  defp do_serialize(params)

  defp do_serialize(data) when is_map(data) do
    data
    |> Enum.sort_by(fn {key, _} -> key end, :asc)
    |> Stream.map(fn
      {:signature, _} ->
        ""

      {key, value} when is_map(value) ->
        "#{key}.{#{do_serialize(value)}}"

      {key, value} when is_list(value) ->
        "#{key}.[#{do_serialize(value)}]"

      {key, value} ->
        "#{key}.#{do_serialize(value)}"
    end)
    |> Stream.reject(&(&1 == ""))
    |> Enum.join(".")
  end

  defp do_serialize(data) when is_list(data) do
    data
    |> Stream.map(fn value -> do_serialize(value) end)
    |> Enum.join(".")
  end

  defp do_serialize(nil) do
    "\\0"
  end

  defp do_serialize(data) when is_binary(data) do
    data
    |> to_charlist()
    |> Enum.map(fn
      ?\\ -> [?\\, ?\\]
      ?{ -> [?\\, ?{]
      ?} -> [?\\, ?}]
      ?[ -> [?\\, ?[]
      ?] -> [?\\, ?]]
      ?. -> [?\\, ?.]
      char -> char
    end)
    |> IO.iodata_to_binary()
  end

  ###################
  # Signature helpers

  @spec do_sign(t(), binary()) :: t()
  defp do_sign(request, serialized_request)

  defp do_sign(
         %__MODULE__{
           params: params,
           options: %{identity: %Identity{key: key}}
         } = request,
         serialized_request
       ) do
    signature =
      serialized_request
      |> hash()
      |> Curvy.sign(key, compact: true, hash: :sha3_256)
      |> from_curvy()
      |> Base.encode64()

    %{request | params: Map.put(params, :signature, signature)}
  end

  @spec hash(binary()) :: binary()
  defp hash(message) do
    :crypto.hash(:sha3_256, message)
  end

  @spec from_curvy(binary()) :: binary()
  defp from_curvy(compacted_signature)

  defp from_curvy(<<
         v::8-unsigned-integer,
         r::bytes-size(32),
         s::bytes-size(32)
       >>) do
    recovery_id = v - (27 + 4)

    <<r::bytes-size(32), s::bytes-size(32), recovery_id::8-unsigned-integer>>
  end

  @spec to_curvy(binary()) :: binary()
  defp to_curvy(compacted_signature)

  defp to_curvy(<<
         r::bytes-size(32),
         s::bytes-size(32),
         recovery_id::8-unsigned-integer
       >>) do
    v = recovery_id + (27 + 4)

    <<v::8-unsigned-integer, r::bytes-size(32), s::bytes-size(32)>>
  end

  ##############
  # HTTP helpers

  @spec headers(t()) :: [{binary(), binary()}]
  defp headers(%__MODULE__{options: options}) do
    case options[:timeout] || 0 do
      timeout when timeout > 0 ->
        [
          {"Content-type", "application/json"},
          {"Icon-Options", "#{timeout}"}
        ]

      _ ->
        [{"Content-type", "application/json"}]
    end
  end

  @spec do_send(Finch.Request.t()) ::
          {:ok, response()}
          | {:error, Error.t()}
  defp do_send(%Finch.Request{} = request) do
    case Finch.request(request, Icon.Finch) do
      {:ok, %Finch.Response{body: data}} ->
        decode_response(data)

      {:error, reason} ->
        message = "#{inspect(reason)}"
        {:error, Error.new(reason: :system_error, message: message)}
    end
  end

  @spec decode_response(binary()) :: {:ok, response()} | {:error, Error.t()}
  defp decode_response(data) do
    case Jason.decode(data) do
      {:ok, %{"result" => result}} ->
        {:ok, result}

      {:ok, %{"error" => error}} ->
        reason =
          Error.new(
            code: error["code"],
            message: error["message"],
            data: error["data"]
          )

        {:error, reason}

      {:error, _} ->
        {:error, Error.new(reason: :system_error)}
    end
  end
end

defimpl Jason.Encoder, for: Icon.RPC.Request do
  @moduledoc """
  JSON encoder for an RPC request payload. This encoder, uses `dump/1` callback
  from an `Icon.Schema.Type` to convert the RPC call parameters.
  The recomended types for converting values to ICON 2.0 representation are the
  following:

  - `Icon.Schema.Types.Address` for both EOA and SCORE addresses.
  - `Icon.Schema.Types.BinaryData` for binary data.
  - `Icon.Schema.Types.Boolean` for Elixir's `boolean` type.
  - `Icon.Schema.Types.EOA` for Externally Owned Account (EOA) addresses.
  - `Icon.Schema.Types.Hash` for hashes e.g. block hash.
  - `Icon.Schema.Types.Integer` for Elixir's `non_neg_integer` type.
  - `Icon.Schema.Types.Loop` for loop where 10¹⁸ loop = 1 ICX. It delegates to
    `Icon.Schema.Types.Integer`.
  - `Icon.Schema.Types.SCORE` for SCORE addresses.
  - `Icon.Schema.Types.Signature` for signatures.
  - `Icon.Schema.Types.String` for Elixir's `binary` type.
  - `Icon.Schema.Types.Timestamp` for Elixir's `DateTime.t()` type.
  """
  alias Icon.Schema
  alias Icon.Schema.Error

  @doc """
  Converts a `request` to JSON.
  """
  @spec encode(Icon.RPC.Request.t(), Jason.Encode.opts()) :: binary()
  def encode(%Icon.RPC.Request{} = request, options) do
    schema = request.options[:schema]

    request
    |> Map.take([:id, :method, :params])
    |> Map.put(:jsonrpc, "2.0")
    |> Stream.reject(fn {_key, value} -> is_nil(value) end)
    |> Stream.reject(fn {_key, value} -> value == %{} end)
    |> Map.new()
    |> dump_params(schema)
    |> Jason.Encode.map(options)
  end

  #########
  # Helpers

  @spec dump_params(map(), nil | map()) :: map()
  defp dump_params(payload, types)

  defp dump_params(payload, nil), do: payload

  defp dump_params(%{params: params} = payload, schema) when is_map(params) do
    schema
    |> Schema.generate()
    |> Schema.new(params)
    |> Schema.dump()
    |> Schema.apply()
    |> case do
      {:ok, params} when is_map(params) and params == %{} ->
        Map.delete(payload, :params)

      {:ok, params} when is_map(params) ->
        %{payload | params: params}

      {:error, %Error{message: message}} ->
        raise ArgumentError, message: message
    end
  end
end