diff --git a/apps/explorer/lib/explorer/chain/hash/address.ex b/apps/explorer/lib/explorer/chain/hash/address.ex index e0fd8b4e77..8c04f735e8 100644 --- a/apps/explorer/lib/explorer/chain/hash/address.ex +++ b/apps/explorer/lib/explorer/chain/hash/address.ex @@ -148,4 +148,123 @@ defmodule Explorer.Chain.Hash.Address do @impl Hash def byte_count, do: @byte_count + + @doc """ + Validates a hexadecimal encoded string to see if it conforms to an address. + + ## Error Descriptions + + * `:invalid_characters` - String used non-hexidecimal characters + * `:invalid_checksum` - Mixed-case string didn't pass [EIP-55 checksum](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-55.md) + * `:invalid_length` - Addresses are expected to be 40 hex characters long + + ## Example + + iex> Explorer.Chain.Hash.Address.validate("0xc1912fEE45d61C87Cc5EA59DaE31190FFFFf232d") + {:ok, "0xc1912fEE45d61C87Cc5EA59DaE31190FFFFf232d"} + + iex> Explorer.Chain.Hash.Address.validate("0xc1912fEE45d61C87Cc5EA59DaE31190FFFFf232H") + {:error, :invalid_characters} + """ + @spec validate(String.t()) :: {:ok, String.t()} | {:error, :invalid_length | :invalid_characters, :invalid_checksum} + def validate("0x" <> hash) do + with {:length, true} <- {:length, String.length(hash) == 40}, + {:hex, true} <- {:hex, is_hex?(hash)}, + {:mixed_case, true} <- {:mixed_case, is_mixed_case?(hash)}, + {:checksummed, true} <- {:checksummed, is_checksummed?(hash)} do + {:ok, "0x" <> hash} + else + {:length, false} -> + {:error, :invalid_length} + + {:hex, false} -> + {:error, :invalid_characters} + + {:mixed_case, false} -> + {:ok, "0x" <> hash} + + {:checksummed, false} -> + {:error, :invalid_checksum} + end + end + + @spec is_hex?(String.t()) :: boolean() + defp is_hex?(hash) do + case Regex.run(~r|[0-9a-f]{40}|i, hash) do + nil -> false + [_] -> true + end + end + + @spec is_mixed_case?(String.t()) :: boolean() + defp is_mixed_case?(hash) do + upper_check = ~r|[0-9A-F]{40}| + lower_check = ~r|[0-9a-f]{40}| + + with nil <- Regex.run(upper_check, hash), + nil <- Regex.run(lower_check, hash) do + true + else + _ -> false + end + end + + @spec is_checksummed?(String.t()) :: boolean() + defp is_checksummed?(original_hash) do + lowercase_hash = String.downcase(original_hash) + sha3_hash = :keccakf1600.hash(:sha3_256, lowercase_hash) + + do_checksum_check(sha3_hash, original_hash) + end + + @spec do_checksum_check(binary(), String.t()) :: boolean() + defp do_checksum_check(_, ""), do: true + + defp do_checksum_check(sha3_hash, address_hash) do + <> = sha3_hash + <> = address_hash + + if is_proper_case?(checksum_digit, current_char) do + do_checksum_check(remaining_sha3_hash, remaining_address_hash) + else + false + end + end + + @spec is_proper_case?(integer, String.t()) :: boolean() + defp is_proper_case?(checksum_digit, character) do + case_map = %{ + "0" => :both, + "1" => :both, + "2" => :both, + "3" => :both, + "4" => :both, + "5" => :both, + "6" => :both, + "7" => :both, + "8" => :both, + "9" => :both, + "a" => :lower, + "b" => :lower, + "c" => :lower, + "d" => :lower, + "e" => :lower, + "f" => :lower, + "A" => :upper, + "B" => :upper, + "C" => :upper, + "D" => :upper, + "E" => :upper, + "F" => :upper + } + + character_case = Map.get(case_map, character) + + # Digits with checksum digit greater than 7 should be uppercase + if checksum_digit > 7 do + character_case in ~w(both upper)a + else + character_case in ~w(both lower)a + end + end end diff --git a/apps/explorer/test/explorer/chain/hash/address_test.exs b/apps/explorer/test/explorer/chain/hash/address_test.exs index 4ca2206315..d603c14d38 100644 --- a/apps/explorer/test/explorer/chain/hash/address_test.exs +++ b/apps/explorer/test/explorer/chain/hash/address_test.exs @@ -2,4 +2,35 @@ defmodule Explorer.Chain.Hash.AddressTest do use ExUnit.Case, async: true doctest Explorer.Chain.Hash.Address + + alias Explorer.Chain.Hash.Address + + describe "validate/1" do + test "with valid uppercase hash" do + assert Address.validate("0xC1912FEE45D61C87CC5EA59DAE31190FFFFF232D") == + {:ok, "0xC1912FEE45D61C87CC5EA59DAE31190FFFFF232D"} + end + + test "with valid lowercase hash" do + assert Address.validate("0xc1912fee45d61c87cc5ea59dae31190fffff232d") == + {:ok, "0xc1912fee45d61c87cc5ea59dae31190fffff232d"} + end + + test "with valid checksummed hash" do + assert Address.validate("0xc1912fEE45d61C87Cc5EA59DaE31190FFFFf232d") == + {:ok, "0xc1912fEE45d61C87Cc5EA59DaE31190FFFFf232d"} + end + + test "with invalid checksum hash" do + assert Address.validate("0xC1912fEE45d61C87Cc5EA59DaE31190FFFFf232d") == {:error, :invalid_checksum} + end + + test "with non-hex string" do + assert Address.validate("0xc1912fEE45d61C87Cc5EA59DaE31190FFFFf232H") == {:error, :invalid_characters} + end + + test "with invalid length string" do + assert Address.validate("0xc1912fEE45d61C87Cc5EA59DaE31190FFFFf232") == {:error, :invalid_length} + end + end end