defmodule BitcoinLib.Key.HD.DerivationPath do @moduledoc """ Can parse derivation paths string format into a native format m / purpose' / coin_type' / account' / change / address_index Inspired by https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki https://learnmeabitcoin.com/technical/derivation-paths """ @enforce_keys [:type] defstruct [:type, :purpose, :coin_type, :account, :change, :address_index] alias BitcoinLib.Key.HD.DerivationPath alias BitcoinLib.Key.HD.DerivationPath.Level # https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#purpose @bip44_purpose 44 @bip44_atom :bip44 # https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki#public-key-derivation @bip49_purpose 49 @bip49_atom :bip49 # https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki#public-key-derivation @bip84_purpose 84 @bip84_atom :bip84 # https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#registered-coin-types @bitcoin_coin_type_value 0 @bitcoin_testnet_coin_type_value 1 # https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#change @receiving_chain_value 0 @receiving_chain_atom :receiving_chain @change_chain_value 1 @change_chain_atom :change_chain @bitcoin_atom :bitcoin @bitcoin_testnet_atom :bitcoin_testnet @invalid_atom :invalid @doc """ Transforms a derivation path string into an elixir structure ## Examples iex> "m / 44' / 1' / 2' / 1 / 4" ...> |> BitcoinLib.Key.HD.DerivationPath.parse() { :ok, %BitcoinLib.Key.HD.DerivationPath{ type: :private, purpose: :bip44, coin_type: :bitcoin_testnet, account: %BitcoinLib.Key.HD.DerivationPath.Level{hardened?: true, value: 2}, change: :change_chain, address_index: %BitcoinLib.Key.HD.DerivationPath.Level{hardened?: false, value: 4} } } """ @spec parse(binary()) :: {:ok, %BitcoinLib.Key.HD.DerivationPath{}} def parse(derivation_path) do derivation_path |> validate |> maybe_parse_valid_derivation_path() end defp validate(derivation_path) do trimmed_path = derivation_path |> String.replace(" ", "") case Regex.match?(~r/^(m|M)((\/(\d+\'?)*){0,5})$/, trimmed_path) do true -> {:ok, trimmed_path} false -> {:error, "Invalid derivation path"} end end defp maybe_parse_valid_derivation_path({:error, _}) do {:error, "Invalid derivation path"} end defp maybe_parse_valid_derivation_path({:ok, derivation_path}) do derivation_path |> split_path |> extract_string_values |> parse_values |> assign_keys |> create_hash(derivation_path) |> parse_purpose |> parse_coin_type |> parse_change |> add_status_code end defp extract_type("m" <> _rest), do: :private defp extract_type("M" <> _rest), do: :public defp split_path(derivation_path) do Regex.scan(~r/\/\s*(\d+\'?)/, derivation_path) end defp extract_string_values(split_path) do split_path |> Enum.map(fn [_, value] -> Regex.named_captures(~r/(?\d+)(?\'?)/, value) end) end defp parse_values(string_values) do string_values |> Enum.map(fn %{"has_quote" => has_quote, "value_string" => value_string} -> {value, _} = Integer.parse(value_string) %Level{ value: value, hardened?: has_quote == "'" } end) end defp assign_keys(parsed_values) do parsed_values |> Enum.zip_with( ["purpose", "coin_type", "account", "change", "address_index"], fn value, title -> {String.to_atom(title), value} end ) end defp create_hash(keys_and_values, derivation_path) do type = extract_type(derivation_path) keys_and_values |> Enum.reduce(%DerivationPath{type: type}, fn {key, value}, acc -> acc |> Map.put(key, value) end) end defp parse_purpose(%{purpose: %{hardened?: true, value: value}} = hash) do hash |> Map.put( :purpose, case value do @bip44_purpose -> @bip44_atom @bip49_purpose -> @bip49_atom @bip84_purpose -> @bip84_atom _ -> @invalid_atom end ) end defp parse_purpose(hash), do: hash defp parse_coin_type(%{coin_type: %{hardened?: true, value: value}} = hash) do hash |> Map.put( :coin_type, case value do @bitcoin_coin_type_value -> @bitcoin_atom @bitcoin_testnet_coin_type_value -> @bitcoin_testnet_atom _ -> @invalid_atom end ) end defp parse_coin_type(hash), do: hash defp parse_change(%{change: %{hardened?: false, value: value}} = hash) do hash |> Map.put( :change, case value do @receiving_chain_value -> @receiving_chain_atom @change_chain_value -> @change_chain_atom _ -> @invalid_atom end ) end defp parse_change(hash) do hash end defp add_status_code(%{purpose: @invalid_atom}), do: {:error, "Invalid purpose"} defp add_status_code(%{coin_type: @invalid_atom}), do: {:error, "Invalid coin type"} defp add_status_code(%{change: @invalid_atom}), do: {:error, "Invalid change chain"} defp add_status_code(result), do: {:ok, result} end