lib/base58.ex

defmodule Bitcoinex.Base58 do
  @moduledoc """
    Includes Base58 serialization and validation.

    Some code is inspired by:
    https://github.com/comboy/bitcoin-elixir/blob/develop/lib/bitcoin/base58_check.ex
  """
  alias Bitcoinex.Utils

  @typedoc """
    Base58 encoding is only supported for p2sh and p2pkh address types.
  """
  @type address_type :: :p2sh | :p2pkh
  @base58_encode_list ~c(123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz)
  @base58_decode_map @base58_encode_list |> Enum.with_index() |> Enum.into(%{})

  @base58_0 <<?1>>
  @type byte_list :: list(byte())

  @doc """
    Decodes a Base58 encoded string into a byte array and validates checksum.
  """
  @spec decode(binary) :: {:ok, binary} | {:error, atom}
  def decode(binary)

  def decode(""), do: {:ok, ""}

  def decode(<<body_and_checksum::binary>>) do
    if valid_charset?(body_and_checksum) do
      body_and_checksum
      |> decode_base!()
      |> validate_checksum()
    else
      {:error, :invalid_characters}
    end
  end

  @doc """
    Decodes a Base58 encoded string into a byte array.
  """
  @spec decode_base!(binary) :: binary
  def decode_base!(binary)

  def decode_base!(@base58_0), do: <<0>>

  def decode_base!(@base58_0 <> body) when byte_size(body) > 0 do
    decode_base!(@base58_0) <> decode_base!(body)
  end

  def decode_base!(""), do: ""

  def decode_base!(bin) do
    bin
    |> :binary.bin_to_list()
    |> Enum.map(&Map.fetch!(@base58_decode_map, &1))
    |> Integer.undigits(58)
    |> :binary.encode_unsigned()
  end

  @doc """
    Validates a Base58 checksum.
  """
  @spec validate_checksum(binary) :: {:ok, binary} | {:error, atom}
  def validate_checksum(data) do
    [decoded_body, checksum] =
      data
      |> :binary.bin_to_list()
      |> Enum.split(-4)
      |> Tuple.to_list()
      |> Enum.map(&:binary.list_to_bin(&1))

    if checksum == checksum(decoded_body) do
      {:ok, decoded_body}
    else
      {:error, :invalid_checksum}
    end
  end

  defp valid_charset?(""), do: true

  defp valid_charset?(<<char>> <> string),
    do: char in @base58_encode_list && valid_charset?(string)

  @doc """
    Encodes binary into a Base58 encoded string.
  """
  @spec encode(binary) :: String.t()
  def encode(bin) do
    bin
    |> append_checksum()
    |> encode_base()
  end

  @spec encode_base(binary) :: String.t()
  def encode_base(binary)

  def encode_base(""), do: ""

  def encode_base(<<0>> <> tail) do
    @base58_0 <> encode_base(tail)
  end

  @doc """
    Encodes a binary into a Base58 encoded string.
  """
  def encode_base(bin) do
    bin
    |> :binary.decode_unsigned()
    |> Integer.digits(58)
    |> Enum.map(&Enum.fetch!(@base58_encode_list, &1))
    |> List.to_string()
  end

  @spec append_checksum(binary) :: binary
  def append_checksum(body) do
    body <> checksum(body)
  end

  @spec checksum(binary) :: binary
  defp checksum(body) do
    <<checksum::binary-4, _rest::binary>> = Utils.double_sha256(body)

    checksum
  end
end