Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Solana support #38

Merged
merged 3 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## v1.0.2

* Adds support for Solana addresses

## v1.0.1

* Brings in Ethereum EIP-55 checksum addresses, credit goes to @wchenNL
Expand Down
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![Build Status](https://travis-ci.com/AgileAlpha/block_keys.svg?branch=master)](https://travis-ci.com/AgileAlpha/block_keys)

BlockKeys is an Elixir implementation of BIP44 Multi-Account Hierarchy for Deterministic Wallets.
Currently it supports Bitcoin and Ethereum but will be extended to support a [large number of coin types](https://github.com/satoshilabs/slips/blob/master/slip-0044.md) of coins in the future.
Currently it supports Bitcoin, Ethereum and Solana, but will be extended to support a [large number of coin types](https://github.com/satoshilabs/slips/blob/master/slip-0044.md) of coins in the future.

For low level details check the [Wiki](https://github.com/AgileAlpha/block_keys/wiki).

Expand All @@ -14,7 +14,7 @@ Add the dependency to your `mix.exs`:
```
def deps do
[
{:block_keys, "~> 1.0.1"}
{:block_keys, "~> 1.0.2"}
]
end
```
Expand Down Expand Up @@ -100,6 +100,13 @@ path = "M/60'/0'/0'"
xpub = BlockKeys.CKD.derive(root_key, path)
```

### Solana

```
path = "M/501'/0'/0'"
xpub = BlockKeys.CKD.derive(root_key, path, curve: :ed25519)
```

## Generating addresses from Master Public Key

Generally you would export the master public key and keep it on your live server so that you can generate addresses for payments or deposits.
Expand All @@ -120,6 +127,13 @@ path = "M/0/0"
address = BlockKeys.Ethereum.address(xpub, path)
```

### Solana

```
path = "M/0/0"
address = BlockKeys.Solana.address(xpub, path)
```

## Path and derivations

You will notice that we used different paths for generating the master private key vs addresses. This is because our initial derivation path includes some hardened paths in order to prevent any downstream generation of addresses for specific paths. For example, the coin code path is hardened in order to prevent anyone from generating a tree of addresses for a different coin given our master public key.
Expand Down
69 changes: 46 additions & 23 deletions lib/block_keys/ckd.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,29 @@ defmodule BlockKeys.CKD do
iex> BlockKeys.derive("xprv9s21ZrQH143K3BwM39ubv3fkaHxCN6M4roETEg68Jviq9AnbRjmqVAF4qJHkoLqgSv2bNqYTnRNY9yBQhjNYceZ1NxiDe8WcNJAeWetCvfR", "m/44'/0'/0'")
"xprv9yAYtNSBnu2ojv5BR1b8T39t8oPnbzG8H8CbEHnhBhoXWf441nRA3zDW7PFBL4wkz7CNqtbhr4YVnLuSquiR1QPJgk72jVN8uZ4S2UkuLVk"
"""
def derive(<<"xpub", _rest::binary>>, <<"m/", _path::binary>>),
def derive(key, path, opts \\ [])

def derive(<<"xpub", _rest::binary>>, <<"m/", _path::binary>>, _opts),
do: {:error, "Cannot derive private key from public key"}

def derive(<<"tpub", _rest::binary>>, <<"m/", _path::binary>>),
def derive(<<"tpub", _rest::binary>>, <<"m/", _path::binary>>, _opts),
do: {:error, "Cannot derive private key from public key"}

def derive(<<"xprv", _rest::binary>> = extended_key, <<"M/", path::binary>>) do
def derive(<<"xprv", _rest::binary>> = extended_key, <<"M/", path::binary>>, opts) do
path
|> String.split("/")
|> _derive(extended_key)
|> master_public_key()
|> master_public_key(opts)
end

def derive(<<"tprv", _rest::binary>> = extended_key, <<"M/", path::binary>>) do
def derive(<<"tprv", _rest::binary>> = extended_key, <<"M/", path::binary>>, opts) do
path
|> String.split("/")
|> _derive(extended_key)
|> master_public_key()
|> master_public_key(opts)
end

def derive(extended_key, path) do
def derive(extended_key, path, _opts) do
path
|> String.replace(~r/m\/|M\//, "")
|> String.split("/")
Expand Down Expand Up @@ -116,35 +118,42 @@ defmodule BlockKeys.CKD do
)
end

def master_public_key(<<"xpub", _rest::binary>>),
def master_public_key(key, opts \\ [])

def master_public_key(<<"xpub", _rest::binary>>, _opts),
do: {:error, "Cannot derive master public key from another extended public key"}

def master_public_key(<<"tpub", _rest::binary>>),
def master_public_key(<<"tpub", _rest::binary>>, _opts),
do: {:error, "Cannot derive master public key from another extended public key"}

def master_public_key(key) do
def master_public_key(key, opts) do
decoded_key = Encoding.decode_extended_key(key)

data =
decoded_key
|> slice_prefix()
|> put_uncompressed_parent_pub(%{index: decoded_key.index})
|> put_compressed_parent_pub()
|> put_parent_pub(%{index: decoded_key.index}, opts)

network =
{network, prefix} =
case key do
"xprv" <> _ -> :mainnet
"tprv" <> _ -> :testnet
"xprv" <> _ -> {:mainnet, "xpub"}
"tprv" <> _ -> {:testnet, "tpub"}
end

Encoding.encode_extended_key(
Encoding.public_version_number(network),
decoded_key.depth,
decoded_key.fingerprint,
decoded_key.index,
decoded_key.chain_code,
data.parent_pub_key
)
encoded_public_key =
Encoding.encode_extended_key(
Encoding.public_version_number(network),
decoded_key.depth,
decoded_key.fingerprint,
decoded_key.index,
decoded_key.chain_code,
data.parent_pub_key
)

case opts[:curve] do
:ed25519 -> prefix <> encoded_public_key
_ -> encoded_public_key
end
end

defp parse_index(index) do
Expand Down Expand Up @@ -197,6 +206,20 @@ defmodule BlockKeys.CKD do
|> Map.merge(%{decoded_key: decoded_key, version_number: version_number})
end

defp put_parent_pub(%{parent_priv_key: parent_priv_key} = data, index, opts) do
case opts[:curve] do
:ed25519 ->
data
|> Map.merge(%{parent_pub_key: Ed25519.derive_public_key(parent_priv_key)})
|> Map.merge(index)

_ ->
data
|> put_uncompressed_parent_pub(index)
|> put_compressed_parent_pub()
end
end

defp put_uncompressed_parent_pub(%{parent_priv_key: parent_priv_key} = data, index) do
data
|> Map.merge(%{parent_pub_key_uncompressed: Crypto.public_key(parent_priv_key)})
Expand Down
54 changes: 54 additions & 0 deletions lib/block_keys/solana/address.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
defmodule BlockKeys.Solana.Address do
@moduledoc """
Converts a public extended key into a Solana Address
"""

alias BlockKeys.Base58

def from_xpub(xpub) do
xpub
|> maybe_decode()
|> Base58.encode()
end

# https://solanacookbook.com/references/keypairs-and-wallets.html#how-to-check-if-a-public-key-has-an-associated-private-key
def valid_address?(address) when byte_size(address) in 32..44 do
public_key = address |> BlockKeys.Base58.decode() |> :binary.encode_unsigned()
Ed25519.on_curve?(public_key)
end

def valid_address?(_address), do: false

defp maybe_decode(<<"xpub", encoded_key::binary>> = _xpub) do
encoded_key
|> decode_extended_key()
|> Map.fetch!(:key)
end

defp maybe_decode(key), do: key

defp decode_extended_key(key) do
decoded_key =
Base58.decode(key)
|> :binary.encode_unsigned()

<<
version_number::binary-4,
depth::binary-1,
fingerprint::binary-4,
index::binary-4,
chain_code::binary-32,
key::binary-32,
_checksum::binary-4
>> = decoded_key

%{
version_number: version_number,
depth: depth,
fingerprint: fingerprint,
index: index,
chain_code: chain_code,
key: key
}
end
end
13 changes: 13 additions & 0 deletions lib/block_keys/solana/solana.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
defmodule BlockKeys.Solana do
@moduledoc """
Helper module to derive and convert to a Solana Address
"""

alias BlockKeys.Solana.Address
alias BlockKeys.CKD

def address(key, path) do
CKD.derive(key, path, curve: :ed25519)
|> Address.from_xpub()
end
end
6 changes: 4 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule BlockKeys.MixProject do
def project do
[
app: :block_keys,
version: "1.0.1",
version: "1.0.2",
elixir: "~> 1.7",
description: description(),
start_permanent: Mix.env() == :prod,
Expand Down Expand Up @@ -43,7 +43,9 @@ defmodule BlockKeys.MixProject do
{:ex_doc, "~> 0.19", only: :dev, runtime: false},
{:ex_keccak, "~> 0.7.3"},
{:ex_secp256k1, "~> 0.7.2"},
{:excoveralls, "~> 0.10", only: :test}
{:excoveralls, "~> 0.10", only: :test},
# Solana keys algorithm
{:ed25519, "~> 1.3"}
]
end
end
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
%{
"castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"},
"earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"},
"ed25519": {:hex, :ed25519, "1.4.1", "479fb83c3e31987c9cad780e6aeb8f2015fb5a482618cdf2a825c9aff809afc4", [:mix], [], "hexpm", "0dacb84f3faa3d8148e81019ca35f9d8dcee13232c32c9db5c2fb8ff48c80ec7"},
"ex_doc": {:hex, :ex_doc, "0.31.0", "06eb1dfd787445d9cab9a45088405593dd3bb7fe99e097eaa71f37ba80c7a676", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "5350cafa6b7f77bdd107aa2199fe277acf29d739aba5aee7e865fc680c62a110"},
"ex_keccak": {:hex, :ex_keccak, "0.7.3", "33298f97159f6b0acd28f6e96ce5ea975a0f4a19f85fe615b4f4579b88b24d06", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6.1", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4c5e6d9d5f77b64ab48769a0166a9814180d40ced68ed74ce60a5174ab55b3fc"},
"ex_secp256k1": {:hex, :ex_secp256k1, "0.7.2", "33398c172813b90fab9ab75c12b98d16cfab472c6dcbde832b13c45ce1c01947", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "f3b1bf56e6992e28b9d86e3bf741a4aca3e641052eb47d13ae4f5f4d4944bdaf"},
Expand Down
28 changes: 28 additions & 0 deletions test/block_keys/ckd_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,16 @@ defmodule CKDTest do
assert CKD.derive(xpub, "M/0/0") == CKD.derive(xprv, "M/44'/0'/0'/0/0")
assert CKD.derive(xpub, "M/0/1") == CKD.derive(xprv, "M/44'/0'/0'/0/1")
end

test "derives xpub from master with network keyword" do
path = "M/44'/0'/0'"

xprv =
"xprv9s21ZrQH143K4RdNK1f51Rdeu4XRG8q2cgzeh7ejtzgYpdZcHpNb1MJ2DdBa4iX6NVoZZajsC4gr26mLFaHGBrrtvGkxwhGh6ng8HVZRSeV"

assert CKD.derive(xprv, path, curve: :ed25519) ==
"xpubDeb7pPtgAGEq2eo7ZHPNUvR7xsF4nhK5dBRqA1KD9jZSkSouoZQj6XJ2BVMAMkjHyPeUtUv46Ku4WCWns9uZnUc9BbV5WvFaWNeXbn15bKXNzr"
end
end

describe "master_keys/1" do
Expand Down Expand Up @@ -170,6 +180,24 @@ defmodule CKDTest do
assert testnet_public_key ==
"tpubD6NzVbkrYhZ4XEp55bZ1JFNwu7uUPpqcTaFJSb5nDa2yQq5NKwWNHnrrTrGkK1HxcfswjNaMY1fYx23rohEt6PwKqX8HAeFHTb8oYhXsaYi"
end

test "returns proper public key with network keyword" do
mainnet_private_key =
"xprv9tyUQV64JT5qs3RSTJkXCWKMyUgoQp7F3hA1xzG6ZGu6u6Q9VMNjGr67Lctvy5P8oyaYAL9CAWrUE9i6GoNMKUga5biW6Hx4tws2six3b9c"

mainnet_public_key = CKD.master_public_key(mainnet_private_key, curve: :ed25519)

assert mainnet_public_key ==
"xpubDeb7pNy9ZrJfKYBgSVfGXtVfeJWFPmkZoxhMFwAf1YC34mH74pyFja1T1pu1xHiWSBoCMyKa41TTvd821mnV5D1rom5JzN6KPPiqnaoK6L6Jwn"

testnet_private_key =
"tprv8ZgxMBicQKsPdmnHBwtQtqiqL6PYEVehtGeXA53UoJEaaLpbhYgn7JEzHhuXusKgYiNyZnC71oS5D7s1CDVmsMpoRxfM5e3TZfG9LAbmyuc"

testnet_public_key = CKD.master_public_key(testnet_private_key, curve: :ed25519)

assert testnet_public_key ==
"tpubCk2VbTbSG6A2iAeAru4sKG5Xt2u6eBdMhy9kNt2t5SPqJoTGxF4i5kANbAV4WoQ8Fi7m5oQVMd3TD3novoKLtquy8XKmbe45pMm8WbSRpKgddt"
end
end

describe "child_key_public/2" do
Expand Down
27 changes: 27 additions & 0 deletions test/block_keys/solana/address_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
defmodule SolanaAddressTest do
use ExUnit.Case, async: true

alias BlockKeys.Solana.Address
alias BlockKeys.CKD

test "address from mnemonic" do
root_key =
BlockKeys.from_mnemonic(
"nurse grid sister metal flock choice system control about mountain sister rapid hundred render shed chicken print cover tape sister zero bronze tattoo stairs"
)

assert root_key ==
"xprv9s21ZrQH143K35qGjQ6GG1wGHFZP7uCZA1WBdUJA8vBZqESQXQGA4A9d4eve5JqWB5m8YTMcNe8cc7c3FVzDGNcmiabi9WQycbFeEvvJF2D"

assert CKD.derive(root_key, "M/44'/501'/0'/0/0", curve: :ed25519)
|> Address.from_xpub() ==
"4U76rEGDx595M46rWgoA7LwtA821BWCU9CkwG8zbJ6xa"
end

test "check if address is valid" do
valid_address = "4U76rEGDx595M46rWgoA7LwtA821BWCU9CkwG8zbJ6xa"
assert Address.valid_address?(valid_address) == true
invalid_address = "ABCDEFG1234567"
assert Address.valid_address?(invalid_address) == false
end
end
Loading