lib/borsh_encode.ex

defmodule Borsh.Encode do
  require Logger

  @moduledoc """
  This module contains functions for encoding Elixir data structures into BORSH binary format.
  ## Usage

  ```elixir
  defmodule ParentStruct do
   @type t() :: %__MODULE__{first_name: String.t(), last_name: String.t(), age: integer}

   defstruct [
     :first_name,
     :last_name,
     :age
   ]

   use Borsh,
     schema: [
       first_name: :string,
       last_name: :string,
       age: :u8
     ]
  end
  borsh_struct = %ParentStruct{first_name: "Boris", last_name: "Johnson", age: 58}
  Borsh.Encode.encode(borsh_struct)
  <<5, 0, 0, 0, 66, 111, 114, 105, 115, 7, 0, 0, 0, 74, 111, 104, 110, 115, 111, 110, 58>>
  ```
  """

  @doc """
  Encodes structs according to the schema into the bitstring
  """
  @spec borsh_encode(obj :: struct) :: bitstring()
  def borsh_encode(obj) do
    borsh_schema = obj.__struct__.borsh_schema()

    {_, res} =
      Enum.map_reduce(
        borsh_schema,
        [],
        fn schema_item, acc ->
          {schema_item, acc ++ [extract_encode_item(obj, schema_item)]}
        end
      )

    res
    |> List.flatten()
    |> :erlang.list_to_binary()
  end

  # Encode
  defp extract_encode_item(obj, {key, format}) do
    value = Map.get(obj, key)
    encode_item(value, {key, format})
  end

  # encoding list of structs
  defp encode_item(value, {_key, [:borsh]}), do: encode_list(value)
  defp encode_item(value, {_key, [{:borsh, _module}]}), do: encode_list(value)
  defp encode_item(value, {_key, _}) when is_list(value), do: encode_list(value)

  # keeping this for backward compatibility, we should use {:borsh, struct} instead of :borsh
  defp encode_item(value, {key, :borsh}) do
    Logger.warn(
      "Using :borsh format is deprecated, please use `#{key}: {:borsh, #{value.__struct__}}` instead of `#{key}: :borsh`"
    )

    value.__struct__.borsh_encode(value)
  end

  # when this is a :borsh type with a struct name
  # we dont need struct name for encoding, but we'll need that for decoding
  defp encode_item(value, {_key, {:borsh, _}}) do
    value.__struct__.borsh_encode(value)
  end

  # encoding strings with fixed sizes: 32 bytes and 64 bytes, types `[32]` and `[64]` respectively:
  defp encode_item(<<_::size(256)>> = string_value, {key, [32]}) do
    encode_item(string_value, {key, :string})
  end

  defp encode_item(_value, {key, [32]}),
    do: raise("Invalid string length for `#{key}`, must be 32 bytes")

  defp encode_item(<<_::size(512)>> = string_value, {key, [64]}) do
    encode_item(string_value, {key, :string})
  end

  defp encode_item(_value, {key, [64]}),
    do: raise("Invalid string length for `#{key}`, must be 64 bytes")

  defp encode_item(value, {key, size})
       when size in [:u8, :u16, :u32, :u64, :u128] and is_binary(value) do
    value
    |> String.to_integer()
    |> encode_item({key, size})
  end

  defp encode_item(value, {_key, size})
       when size in [:u8, :u16, :u32, :u64, :u128, :i8, :i16, :i32, :i64, :i128] do
    binarify(value, size)
  end

  defp encode_item(string_value, {_key, :string}) do
    # 4 bytes of the string length
    [
      # 32-bits string length,
      string_value |> byte_size() |> binarify(:u32),
      # string itself
      string_value
    ]
  end

  defp encode_list(value) do
    [
      # 4bytes binary length of the List
      value |> length() |> binarify(:u32),
      # encode each item in the list
      Enum.map(value, fn i -> i.__struct__.borsh_encode(i) end)
    ]
  end

  defp binarify(value, size) when size in [:i8, :i16, :i32, :i64, :i128] do
    size = convert_size(size)
    <<value::little-signed-integer-size(size)>>
  end

  defp binarify(value, size) when size in [:u8, :u16, :u32, :u64, :u128] do
    size = convert_size(size)
    <<value::little-unsigned-integer-size(size)>>
  end

  def convert_size(:u8), do: 8
  def convert_size(:u16), do: 16
  def convert_size(:u32), do: 32
  def convert_size(:u64), do: 64
  def convert_size(:u128), do: 128
  def convert_size(:i8), do: 8
  def convert_size(:i16), do: 16
  def convert_size(:i32), do: 32
  def convert_size(:i64), do: 64
  def convert_size(:i128), do: 128
end