lib/strkey/strkey.ex

defmodule StellarBase.StrKey do
  @moduledoc """
  Allows encoding and decoding signatures used in the Stellar network.
  """
  import Bitwise

  alias StellarBase.StrKeyError

  @type data :: String.t() | nil
  @type binary_data :: binary() | nil
  @type version_bytes :: integer()
  @type checksum :: integer()
  @type error :: {:error, atom()}
  @type validation :: :ok | error()
  @type decoded_components :: {:ok, {version_bytes(), binary(), checksum()}} | error()
  @type version ::
          :ed25519_public_key
          | :ed25519_secret_seed
          | :pre_auth_tx
          | :sha256_hash
          | :muxed_account
          | :signed_payload
          | :contract

  @version_bytes [
    # Base32-encodes to 'G...'
    ed25519_public_key: 6 <<< 3,
    # Base32-encodes to 'S...'
    ed25519_secret_seed: 18 <<< 3,
    # Base32-encodes to 'T...'
    pre_auth_tx: 19 <<< 3,
    # Base32-encodes to 'X...'
    sha256_hash: 23 <<< 3,
    # Base32-encodes to 'M...'
    muxed_account: 12 <<< 3,
    # Base32-encodes to 'P...'
    signed_payload: 15 <<< 3,
    # Base32-encodes to 'C...'
    contract: 2 <<< 3
  ]

  @spec encode(data :: binary_data(), version :: version()) ::
          {:ok, String.t()} | {:error, atom()}
  def encode(nil, _version), do: {:error, :invalid_binary}

  def encode(data, version) do
    version_bytes = Keyword.get(@version_bytes, version, :ed25519_public_key)
    payload = <<version_bytes>> <> data

    payload
    |> (&CRC.crc(:crc_16_xmodem, &1)).()
    |> (&(payload <> <<&1::little-16>>)).()
    |> Base.encode32(padding: false)
    |> (&{:ok, &1}).()
  end

  @spec encode!(data :: binary_data(), version :: version()) :: String.t() | no_return()
  def encode!(data, version) do
    case encode(data, version) do
      {:ok, key} -> key
      {:error, reason} -> raise(StrKeyError, reason)
    end
  end

  @spec decode(data :: data(), version :: version()) :: {:ok, binary()} | error()
  def decode(nil, _version), do: {:error, :cant_decode_nil_data}

  def decode(data, version) do
    version_bytes = Keyword.get(@version_bytes, version, :ed25519_public_key)

    with {:ok, {decoded_version_bytes, decoded_data, decoded_checksum}} <-
           decode_components(data),
         :ok <- validate_version_bytes(version_bytes, decoded_version_bytes),
         :ok <- validate_checksum(decoded_data, version_bytes, decoded_checksum) do
      {:ok, decoded_data}
    end
  end

  @spec decode!(data :: data(), version :: atom()) :: binary() | no_return()
  def decode!(data, version) do
    case decode(data, version) do
      {:ok, key} -> key
      {:error, reason} -> raise(StrKeyError, reason)
    end
  end

  @spec decode_components(data :: binary()) :: decoded_components()
  defp decode_components(data) do
    data
    |> Base.decode32(padding: false)
    |> validate_decoded_binary()
  end

  @spec validate_decoded_binary(data :: {:ok, binary()} | :error) :: decoded_components()
  defp validate_decoded_binary(
         {:ok,
          <<version_bytes::size(8), data::binary-size(40), checksum::little-integer-size(16)>>}
       ),
       do: {:ok, {version_bytes, data, checksum}}

  defp validate_decoded_binary(
         {:ok,
          <<version_bytes::size(8), data::binary-size(32), checksum::little-integer-size(16)>>}
       ),
       do: {:ok, {version_bytes, data, checksum}}

  defp validate_decoded_binary({:ok, binary})
       when byte_size(binary) >= 43 and byte_size(binary) <= 103 do
    data_size = byte_size(binary) - 3

    <<version_bytes::size(8), data::binary-size(data_size), checksum::little-integer-size(16)>> =
      binary

    {:ok, {version_bytes, data, checksum}}
  end

  defp validate_decoded_binary(_decoded_data), do: {:error, :invalid_data_to_decode}

  @spec validate_version_bytes(
          version_bytes :: version_bytes(),
          decoded_version :: version_bytes()
        ) :: validation()
  defp validate_version_bytes(version_bytes, version_bytes), do: :ok

  defp validate_version_bytes(_version_bytes, _decoded_version_bytes),
    do: {:error, :unmatched_version_bytes}

  @spec validate_checksum(
          decoded_data :: binary(),
          version_bytes :: version_bytes(),
          decoded_checksum :: checksum()
        ) :: validation()
  defp validate_checksum(decoded_data, version_bytes, decoded_checksum) do
    checksum = CRC.crc(:crc_16_xmodem, <<version_bytes>> <> decoded_data)
    if checksum == decoded_checksum, do: :ok, else: {:error, :invalid_checksum}
  end
end