diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 04f3e25..975b9fe 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,20 +6,31 @@ on: push jobs: test: name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} + runs-on: "ubuntu-20.04" strategy: matrix: - otp: ['22.x', '24.x'] - elixir: ['1.11.4', '1.13.4'] - runs-on: ubuntu-20.04 + otp: ["22.x", "24.x", "25.x"] + elixir: ["1.11.4", "1.13.4", "1.14.5"] + exclude: + - otp: "25.x" + elixir: "1.11.4" + - otp: "22.x" + elixir: "1.14.5" + - otp: "24.x" + elixir: "1.14.5" steps: - - uses: actions/checkout@v2 + - name: "Checkout" + uses: actions/checkout@v4 + - name: "Setup Elixir / Erlang" uses: erlef/setup-beam@v1 with: otp-version: ${{matrix.otp}} elixir-version: ${{matrix.elixir}} + - name: "Get the deps" run: mix deps.get + - name: "Run the tests" - run: mix test \ No newline at end of file + run: mix test diff --git a/config/config.exs b/config/config.exs index a7c9a72..9ae2c52 100644 --- a/config/config.exs +++ b/config/config.exs @@ -10,4 +10,5 @@ config :tt_eth, secondary: "0xe2187dc017e880dded10c403f7c0d397afb11736ac027c1202e318b0dd345086", ternary: "0xfa015243f2e6d8694ab037a7987dc73b1630fc8cb1ce82860344684c15d24026" ], + wallet_adapter: TTEth.LocalWallet, transaction_module: TTEth.Transactions.LegacyTransaction diff --git a/lib/tt_eth.ex b/lib/tt_eth.ex index f3eece7..4118be6 100644 --- a/lib/tt_eth.ex +++ b/lib/tt_eth.ex @@ -69,16 +69,15 @@ defmodule TTEth do @doc "Builds the tx data using the configured `TTEth.ChainClient`." def build_tx_data(%Wallet{} = wallet, to, method, args, opts \\ []) do - with {_, %{private_key: private_key, human_address: human_address}} <- - {:wallet, wallet}, - {_, {:ok, "0x" <> raw_nonce}} <- - {:raw_nonce, human_address |> chain_client().eth_get_transaction_count("pending")}, + with {_, {:ok, "0x" <> raw_nonce}} <- + {:raw_nonce, + wallet.human_address |> chain_client().eth_get_transaction_count("pending")}, {_, {nonce, ""}} <- {:parse_nonce, raw_nonce |> Integer.parse(16)}, {_, abi_data} <- {:abi_encode, method |> ABI.encode(args)} do to - |> chain_client().build_tx_data(abi_data, private_key, nonce, opts) + |> chain_client().build_tx_data(abi_data, wallet, nonce, opts) end end diff --git a/lib/tt_eth/behaviours/chain_client.ex b/lib/tt_eth/behaviours/chain_client.ex index d850cb3..939f452 100644 --- a/lib/tt_eth/behaviours/chain_client.ex +++ b/lib/tt_eth/behaviours/chain_client.ex @@ -6,7 +6,7 @@ defmodule TTEth.Behaviours.ChainClient do """ @type address :: TTEth.Type.Address.t() - @type private_key :: TTEth.Type.PrivateKey.t() + @type wallet :: TTEth.Wallet.t() @type encoded_args :: binary @type opts :: keyword @type nonce :: non_neg_integer() @@ -23,8 +23,8 @@ defmodule TTEth.Behaviours.ChainClient do @callback eth_send_raw_transaction(tx_data) :: any @callback eth_send_raw_transaction(tx_data, opts) :: any - @callback build_tx_data(address, abi_data, private_key, nonce) :: tx_data - @callback build_tx_data(address, abi_data, private_key, nonce, keyword) :: tx_data + @callback build_tx_data(address, abi_data, wallet, nonce) :: tx_data + @callback build_tx_data(address, abi_data, wallet, nonce, keyword) :: tx_data @callback eth_get_transaction_count(account :: address, block_id) :: any @callback eth_get_transaction_count(account :: address, block_id, opts) :: any diff --git a/lib/tt_eth/behaviours/transaction.ex b/lib/tt_eth/behaviours/transaction.ex index f297def..810f1e6 100644 --- a/lib/tt_eth/behaviours/transaction.ex +++ b/lib/tt_eth/behaviours/transaction.ex @@ -9,9 +9,9 @@ defmodule TTEth.Behaviours.Transaction do @type opts :: Keyword.t() @type transaction :: struct() - @type private_key :: binary() + @type wallet :: TTEth.Wallet.t() @callback new(to_address, abi_data, nonce, opts) :: transaction - @callback build(transaction, private_key) :: binary() + @callback build(transaction, wallet) :: binary() end diff --git a/lib/tt_eth/behaviours/wallet_adapter.ex b/lib/tt_eth/behaviours/wallet_adapter.ex new file mode 100644 index 0000000..b386d03 --- /dev/null +++ b/lib/tt_eth/behaviours/wallet_adapter.ex @@ -0,0 +1,32 @@ +defmodule TTEth.Behaviours.WalletAdapter do + @moduledoc """ + Defines a shared behaviour for wallet adapters. + """ + + @typedoc """ + This represents the config for a wallet adapter. + + Check the documentation for the adapter for specific configuration options. + """ + @type config :: map() | binary() + + @typedoc """ + Represents a wallet adapter. + """ + @type wallet_adapter :: struct() + + @doc """ + Returns a new populated wallet adapter struct. + """ + @callback new(config) :: wallet_adapter + + @doc """ + Provides the attributes needed to build a `Wallet.t` using the passed `wallet_adapter`. + """ + @callback wallet_attrs(wallet_adapter) :: map() + + @doc """ + Signs `digest` using the given `wallet_adapter`. + """ + @callback sign(wallet_adapter, digest :: binary()) :: {:ok, binary()} | {:error, any()} +end diff --git a/lib/tt_eth/chain_client.ex b/lib/tt_eth/chain_client.ex index 2215863..78728cb 100644 --- a/lib/tt_eth/chain_client.ex +++ b/lib/tt_eth/chain_client.ex @@ -5,6 +5,7 @@ defmodule TTEth.ChainClient do This is agnostic to the transaction version/type. """ alias TTEth.Behaviours.ChainClient + alias TTEth.Wallet alias Ethereumex.HttpClient import TTEth, only: [transaction_module: 0, hex_prefix!: 1] @@ -24,12 +25,12 @@ defmodule TTEth.ChainClient do # Delegate to the transaction module to serialize and sign the transaction. @impl ChainClient - def build_tx_data("" <> to_address, abi_data, private_key, nonce, opts \\ []) + def build_tx_data("" <> to_address, abi_data, %Wallet{} = wallet, nonce, opts \\ []) when is_integer(nonce), do: to_address |> transaction_module().new(abi_data, nonce, opts) - |> transaction_module().build(private_key) + |> transaction_module().build(wallet) |> Base.encode16(case: :lower) |> hex_prefix!() diff --git a/lib/tt_eth/chain_client_mock_impl.ex b/lib/tt_eth/chain_client_mock_impl.ex index 9b01fd8..25d1fe7 100644 --- a/lib/tt_eth/chain_client_mock_impl.ex +++ b/lib/tt_eth/chain_client_mock_impl.ex @@ -3,6 +3,7 @@ defmodule TTEth.ChainClientMockImpl do Implementation default for tests. """ alias TTEth.Behaviours.ChainClient + alias TTEth.Wallet @behaviour ChainClient @@ -15,8 +16,8 @@ defmodule TTEth.ChainClientMockImpl do do: :error @impl ChainClient - def build_tx_data(to, abi_data, private_key, nonce, opts \\ []), - do: to |> TTEth.ChainClient.build_tx_data(abi_data, private_key, nonce, opts) + def build_tx_data(to, abi_data, %Wallet{} = wallet, nonce, opts \\ []), + do: to |> TTEth.ChainClient.build_tx_data(abi_data, wallet, nonce, opts) @impl ChainClient def eth_get_transaction_count(_address, _block \\ "latest", _opts \\ []), diff --git a/lib/tt_eth/local_wallet.ex b/lib/tt_eth/local_wallet.ex new file mode 100644 index 0000000..0723eb7 --- /dev/null +++ b/lib/tt_eth/local_wallet.ex @@ -0,0 +1,78 @@ +defmodule TTEth.LocalWallet do + @moduledoc """ + A local wallet, derived from a private key. + + ## Config + + Expects the config to look like so: + + config TTEth, + wallet_adapter: TTEth.LocalWallet, + wallets: [ + primary: "0x0aF6...0000", + ] + """ + alias TTEth.Type.{Address, PublicKey, PrivateKey} + alias TTEth.Secp256k1 + alias TTEth.Behaviours.WalletAdapter, as: WalletAdapterBehaviour + + @type t :: %__MODULE__{} + + @behaviour WalletAdapterBehaviour + + defstruct [ + :private_key, + :human_private_key + ] + + @impl WalletAdapterBehaviour + def new(%{private_key: "" <> private_key} = _config), + do: + __MODULE__ + |> struct!(%{ + private_key: private_key |> PrivateKey.from_human!(), + human_private_key: private_key + }) + + @impl WalletAdapterBehaviour + def new("" <> private_key = _config), + do: + %{private_key: private_key} + |> new() + + @impl WalletAdapterBehaviour + def wallet_attrs(%__MODULE__{} = wallet) do + pub = + wallet.private_key + |> PublicKey.from_private_key!() + |> PublicKey.from_human!() + + address = pub |> Address.from_public_key!() + + %{ + address: address, + public_key: pub, + human_address: address |> Address.to_human!(), + human_public_key: pub |> PublicKey.to_human!(), + _adapter: wallet + } + end + + @impl WalletAdapterBehaviour + def sign(%__MODULE__{} = wallet, "" <> digest), + do: + digest + |> Secp256k1.ecdsa_sign_compact(wallet.private_key) + + ## Helpers. + + @doc """ + Generates a new `TTEth.LocalWallet.t` with a random private key. + """ + def generate() do + {_pub, priv} = TTEth.new_keypair() + + %{private_key: priv} + |> new() + end +end diff --git a/lib/tt_eth/secp256k1.ex b/lib/tt_eth/secp256k1.ex index 28b184c..57d8d19 100644 --- a/lib/tt_eth/secp256k1.ex +++ b/lib/tt_eth/secp256k1.ex @@ -3,13 +3,18 @@ defmodule TTEth.Secp256k1 do Wrapper around `ExSecp256k1` functions. """ + @valid_recovery_ids [0, 1] + + @type recovery_id :: 0 | 1 + @type compact_signature_return :: {:ok, {binary, recovery_id}} | {:error, atom} + @doc """ Delegates to `ExSecp256k1.recover_compact/3` with guards around the `recovery_id` value. """ @spec ecdsa_recover_compact(binary(), binary(), non_neg_integer()) :: {:ok, binary()} | {:error, atom()} - def ecdsa_recover_compact(hash, sig, recovery_id) when recovery_id in [0, 1], - do: ExSecp256k1.recover_compact(hash, sig, recovery_id) + def ecdsa_recover_compact(hash, signature, recovery_id) when recovery_id in @valid_recovery_ids, + do: ExSecp256k1.recover_compact(hash, signature, recovery_id) def ecdsa_recover_compact(_hash, _sig, _recovery_id), do: {:error, :invalid_recovery_id} @@ -17,8 +22,7 @@ defmodule TTEth.Secp256k1 do @doc """ Delegates to `ExSecp256k1.sign_compact/2`. """ - @spec ecdsa_sign_compact(binary(), binary()) :: - {:ok, {binary(), binary()}} | {:error, atom()} + @spec ecdsa_sign_compact(binary(), binary()) :: compact_signature_return def ecdsa_sign_compact(hash, private_key), do: ExSecp256k1.sign_compact(hash, private_key) @@ -26,6 +30,6 @@ defmodule TTEth.Secp256k1 do Delegates to `ExSecp256k1.create_public_key/1`. """ @spec ec_pubkey_create(binary()) :: {:ok, binary()} | atom() - def ec_pubkey_create(priv), - do: ExSecp256k1.create_public_key(priv) + def ec_pubkey_create(private_key), + do: ExSecp256k1.create_public_key(private_key) end diff --git a/lib/tt_eth/transactions/eip1559_transaction.ex b/lib/tt_eth/transactions/eip1559_transaction.ex index 9cf2243..604fce6 100644 --- a/lib/tt_eth/transactions/eip1559_transaction.ex +++ b/lib/tt_eth/transactions/eip1559_transaction.ex @@ -4,7 +4,7 @@ defmodule TTEth.Transactions.EIP1559Transaction do SEE: https://eips.ethereum.org/EIPS/eip-1559 """ - alias TTEth.{BitHelper, Secp256k1} + alias TTEth.{BitHelper, Wallet} alias TTEth.Behaviours.Transaction import BitHelper, only: [encode_unsigned: 1] @@ -53,24 +53,22 @@ defmodule TTEth.Transactions.EIP1559Transaction do This will return a binary which can then be base16 encoded etc. """ @impl Transaction - def build(%__MODULE__{} = trx, private_key), + def build(%__MODULE__{} = trx, %Wallet{} = wallet), do: trx - |> sign_transaction(private_key) + |> sign_transaction(wallet) |> serialize(_include_signature = true) |> rlp_encode() |> put_transaction_envelope(trx) - @doc """ - Delegate to ExRLP to RLP encode values. - """ - def rlp_encode(data), + ## Private. + + # Delegate to ExRLP to RLP encode values. + defp rlp_encode(data), do: data |> ExRLP.encode() - @doc """ - Encodes a transaction such that it can be RLP-encoded. - """ - def serialize(%__MODULE__{} = trx, include_vrs \\ true), + # Encodes a transaction such that it can be RLP-encoded. + defp serialize(%__MODULE__{} = trx, include_vrs), do: [ trx.chain_id |> encode_unsigned(), @@ -85,19 +83,8 @@ defmodule TTEth.Transactions.EIP1559Transaction do ] |> maybe_add_yrs(trx, include_vrs) - @doc """ - Returns a ECDSA signature (v,r,s) for a given hashed value. - """ - def sign_hash(hash, private_key) do - {:ok, {<>, v}} = Secp256k1.ecdsa_sign_compact(hash, private_key) - - {v, r, s} - end - - @doc """ - Returns a hash of a given transaction. - """ - def transaction_hash(%__MODULE__{} = trx), + # Returns a hash of a given transaction. + defp transaction_hash(%__MODULE__{} = trx), do: trx |> serialize(_include_signature = false) @@ -105,28 +92,21 @@ defmodule TTEth.Transactions.EIP1559Transaction do |> put_transaction_envelope(trx) |> TTEth.keccak() - @doc """ - Takes a given transaction and returns a version signed with the given private key. - """ - def sign_transaction(%__MODULE__{} = trx, private_key) when is_binary(private_key) do - {y_parity, r, s} = - trx - |> transaction_hash() - |> sign_hash(private_key) + # Takes a given transaction and returns a version signed with the given private key. + defp sign_transaction(%__MODULE__{} = trx, %Wallet{} = wallet) do + {:ok, {<>, y_parity}} = + wallet + |> Wallet.sign(trx |> transaction_hash()) - %{trx | y_parity: y_parity, r: r, s: s} + %{trx | r: r, s: s, y_parity: y_parity} end - @doc """ - Wraps the RLP encoded transaction in a transaction envelope. - SEE: https://eips.ethereum.org/EIPS/eip-2718 - """ - def put_transaction_envelope(encoded, %__MODULE__{} = trx) when is_binary(encoded), + # Wraps the RLP encoded transaction in a transaction envelope. + # SEE: https://eips.ethereum.org/EIPS/eip-2718 + defp put_transaction_envelope(encoded, %__MODULE__{} = trx) when is_binary(encoded), do: <> <> encoded - ## Private. - - # Optionally add the YRS values. + # Maybe add the y_parity, r and s values. defp maybe_add_yrs(base, %__MODULE__{} = trx, _include_vrs = true), do: base ++ @@ -136,5 +116,6 @@ defmodule TTEth.Transactions.EIP1559Transaction do trx.s |> encode_unsigned() ] - defp maybe_add_yrs(base, %__MODULE__{}, _dont_include_vrs), do: base + defp maybe_add_yrs(base, %__MODULE__{}, _dont_include_vrs), + do: base end diff --git a/lib/tt_eth/transactions/legacy_transaction.ex b/lib/tt_eth/transactions/legacy_transaction.ex index 5cd3071..0f7a822 100644 --- a/lib/tt_eth/transactions/legacy_transaction.ex +++ b/lib/tt_eth/transactions/legacy_transaction.ex @@ -2,13 +2,12 @@ defmodule TTEth.Transactions.LegacyTransaction do @moduledoc """ Ported from [`Blockchain`](https://hex.pm/packages/blockchain). """ - alias TTEth.{BitHelper, Secp256k1} + alias TTEth.{BitHelper, Wallet} alias TTEth.Behaviours.Transaction + import BitHelper, only: [encode_unsigned: 1] @behaviour Transaction - @type private_key :: <<_::256>> - @type hash_v :: integer() @type hash_r :: integer() @type hash_s :: integer() @@ -17,6 +16,9 @@ defmodule TTEth.Transactions.LegacyTransaction do @type address :: <<_::160>> @type hash :: <<_::256>> + @base_v 27 + @base_v_eip_155 35 + @type t :: %__MODULE__{ nonce: integer(), chain_id: integer(), @@ -43,10 +45,6 @@ defmodule TTEth.Transactions.LegacyTransaction do init: <<>>, data: <<>> - # The follow are the maximum value for x in the signature, as defined in Eq.(212) - @base_recovery_id 27 - @base_recovery_id_eip_155 35 - @impl Transaction def new("" <> to_address, abi_data, nonce, opts) when is_integer(nonce), do: %__MODULE__{ @@ -60,10 +58,10 @@ defmodule TTEth.Transactions.LegacyTransaction do } @impl Transaction - def build(%__MODULE__{} = trx, private_key), + def build(%__MODULE__{} = trx, %Wallet{} = wallet), do: trx - |> sign_transaction(private_key) + |> sign_transaction(wallet) |> serialize(_include_signature = true) |> rlp_encode() @@ -95,20 +93,20 @@ defmodule TTEth.Transactions.LegacyTransaction do @spec serialize(t) :: ExRLP.t() def serialize(trx, include_vrs \\ true) do base = [ - trx.nonce |> BitHelper.encode_unsigned(), - trx.gas_price |> BitHelper.encode_unsigned(), - trx.gas_limit |> BitHelper.encode_unsigned(), + trx.nonce |> encode_unsigned(), + trx.gas_price |> encode_unsigned(), + trx.gas_limit |> encode_unsigned(), trx.to, - trx.value |> BitHelper.encode_unsigned(), + trx.value |> encode_unsigned(), if(trx.to == <<>>, do: trx.init, else: trx.data) ] if include_vrs do base ++ [ - trx.v |> BitHelper.encode_unsigned(), - trx.r |> BitHelper.encode_unsigned(), - trx.s |> BitHelper.encode_unsigned() + trx.v |> encode_unsigned(), + trx.r |> encode_unsigned(), + trx.s |> encode_unsigned() ] else base @@ -122,12 +120,14 @@ defmodule TTEth.Transactions.LegacyTransaction do ## Examples - iex> LegacyTransaction.sign_hash(<<2::256>>, <<1::256>>) + iex> wallet = TTEth.Wallet.from_private_key(_private_key = <<1::256>>) + iex> LegacyTransaction.sign_hash(<<2::256>>, wallet) {28, 38938543279057362855969661240129897219713373336787331739561340553100525404231, 23772455091703794797226342343520955590158385983376086035257995824653222457926} - iex> LegacyTransaction.sign_hash(<<5::256>>, <<1::256>>) + iex> wallet = TTEth.Wallet.from_private_key(_private_key = <<1::256>>) + iex> LegacyTransaction.sign_hash(<<5::256>>, wallet) {27, 74927840775756275467012999236208995857356645681540064312847180029125478834483, 56037731387691402801139111075060162264934372456622294904359821823785637523849} @@ -135,25 +135,19 @@ defmodule TTEth.Transactions.LegacyTransaction do iex> data = "ec098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a764000080018080" |> TTEth.BitHelper.from_hex iex> hash = data |> TTEth.keccak() iex> private_key = "4646464646464646464646464646464646464646464646464646464646464646" |> TTEth.BitHelper.from_hex - iex> LegacyTransaction.sign_hash(hash, private_key, 1) + iex> wallet = TTEth.Wallet.from_private_key(private_key) + iex> LegacyTransaction.sign_hash(hash, wallet, 1) { 37, 18515461264373351373200002665853028612451056578545711640558177340181847433846, 46948507304638947509940763649030358759909902576025900602547168820602576006531 } """ - @spec sign_hash(BitHelper.keccak_hash(), private_key, integer() | nil) :: + @spec sign_hash(BitHelper.keccak_hash(), Wallet.t(), integer() | nil) :: {hash_v, hash_r, hash_s} - def sign_hash(hash, private_key, chain_id \\ nil) do + def sign_hash(hash, %Wallet{} = wallet, chain_id \\ nil) do {:ok, {<>, recovery_id}} = - Secp256k1.ecdsa_sign_compact(hash, private_key) - - # Fork Ψ EIP-155 - recovery_id = - if chain_id do - chain_id * 2 + @base_recovery_id_eip_155 + recovery_id - else - @base_recovery_id + recovery_id - end + wallet + |> Wallet.sign(hash) - {recovery_id, r, s} + {recovery_id |> recovery_id_to_v(chain_id), r, s} end @doc """ @@ -192,20 +186,30 @@ defmodule TTEth.Transactions.LegacyTransaction do ## Examples - iex> LegacyTransaction.sign_transaction(%LegacyTransaction{nonce: 5, gas_price: 6, gas_limit: 7, to: <<>>, value: 5, init: <<1>>}, <<1::256>>) + iex> wallet = TTEth.Wallet.from_private_key(_private_key = <<1::256>>) + iex> LegacyTransaction.sign_transaction(%LegacyTransaction{nonce: 5, gas_price: 6, gas_limit: 7, to: <<>>, value: 5, init: <<1>>}, wallet) %LegacyTransaction{data: <<>>, gas_limit: 7, gas_price: 6, init: <<1>>, nonce: 5, r: 97037709922803580267279977200525583527127616719646548867384185721164615918250, s: 31446571475787755537574189222065166628755695553801403547291726929250860527755, to: "", v: 27, value: 5} - iex> LegacyTransaction.sign_transaction(%LegacyTransaction{nonce: 5, gas_price: 6, gas_limit: 7, to: <<>>, value: 5, init: <<1>>}, <<1::256>>, 1) + iex> wallet = TTEth.Wallet.from_private_key(_private_key = <<1::256>>) + iex> LegacyTransaction.sign_transaction(%LegacyTransaction{nonce: 5, gas_price: 6, gas_limit: 7, to: <<>>, value: 5, init: <<1>>}, wallet, 1) %LegacyTransaction{data: <<>>, gas_limit: 7, gas_price: 6, init: <<1>>, nonce: 5, r: 25739987953128435966549144317523422635562973654702886626580606913510283002553, s: 41423569377768420285000144846773344478964141018753766296386430811329935846420, to: "", v: 38, value: 5} """ - @spec sign_transaction(__MODULE__.t(), private_key, integer() | nil) :: __MODULE__.t() - def sign_transaction(trx, private_key, chain_id \\ nil) do + @spec sign_transaction(__MODULE__.t(), Wallet.t(), integer() | nil) :: __MODULE__.t() + def sign_transaction(trx, %Wallet{} = wallet, chain_id \\ nil) do {v, r, s} = trx |> transaction_hash(chain_id) - |> sign_hash(private_key, chain_id) + |> sign_hash(wallet, chain_id) %{trx | v: v, r: r, s: s} end + + ## Private. + + defp recovery_id_to_v(recovery_id, _chain_id = nil), + do: @base_v + recovery_id + + defp recovery_id_to_v(recovery_id, chain_id) when is_integer(chain_id), + do: chain_id * 2 + @base_v_eip_155 + recovery_id end diff --git a/lib/tt_eth/type/signature.ex b/lib/tt_eth/type/signature.ex index df4d723..35ac6a7 100644 --- a/lib/tt_eth/type/signature.ex +++ b/lib/tt_eth/type/signature.ex @@ -1,23 +1,26 @@ defmodule TTEth.Type.Signature do - @moduledoc "This module is an Ecto-compatible type that can represent Ethereum signatures." + @moduledoc """ + This module is an Ecto-compatible type that can represent Ethereum signatures. + """ use TTEth.Type, size: 65 - alias TTEth.BitHelper - alias TTEth.Secp256k1 + alias TTEth.{BitHelper, Secp256k1} import TTEth, only: [keccak: 1] - @secp256k1n 115_792_089_237_316_195_423_570_985_008_687_907_852_837_564_279_074_904_382_605_163_141_518_161_494_337 - @secp256k1n_2 round(Float.floor(@secp256k1n / 2)) - @base_recovery_id 27 - @base_recovery_id_eip_155 35 + @base_v 27 + @valid_vs [@base_v, @base_v + 1] @ethereum_magic <<0x19, "Ethereum Signed Message:", ?\n>> - @type v :: byte + @type recovery_id :: non_neg_integer() + @type v :: 27 | 28 @type r :: non_neg_integer() @type s :: non_neg_integer() @type components :: {v, r, s} @type chain_id :: number @type digest :: binary + @doc """ + Hashes and decorates the passed plaintext message using `decorate_message/1`. + """ @spec digest(binary) :: binary def digest(msg), do: @@ -25,67 +28,79 @@ defmodule TTEth.Type.Signature do |> decorate_message() |> keccak() + @doc """ + Decorates the passed message with the Ethereum magic as specified in EIP-191. + + SEE: https://eips.ethereum.org/EIPS/eip-191 + + This is version `0x45`. + """ @spec decorate_message(binary) :: String.t() def decorate_message(msg), do: <<@ethereum_magic, "#{byte_size(msg)}"::binary, msg::binary>> - @spec sign(message :: binary, privkey :: binary, chain_id | nil) :: - {:ok, components} | {:error, :cannot_sign} - def sign(msg, priv, chain_id \\ nil) do - msg - |> digest() - |> Secp256k1.ecdsa_sign_compact(priv) - |> case do - {:ok, {<>, v}} -> - {:ok, {v_from_recovery_id(v, chain_id), r, s}} + @doc """ + This is for a signed message, not for transaction data. - {:error, _} -> - {:error, :cannot_sign} - end - end + `TTEth.Wallet.personal_sign/2` should be used instead. - @spec sign!(binary, binary, chain_id | nil) :: components - def sign!(msg, priv, chain_id \\ nil) do - {:ok, ret} = msg |> sign(priv, chain_id) + SEE: https://eips.ethereum.org/EIPS/eip-191 + + SEE: https://ethereum.org/en/developers/docs/apis/json-rpc#eth_sign + """ + @deprecated "Use Wallet.personal_sign/2 instead." + @spec sign(message :: binary, private_key :: binary) :: Secp256k1.compact_signature_return() + def sign(message, private_key), + do: + message + |> digest() + |> Secp256k1.ecdsa_sign_compact(private_key) + |> compact_to_components() + + @spec sign!(binary, binary) :: components + def sign!(message, private_key) do + {:ok, ret} = message |> sign(private_key) ret end - @spec recover(binary, components, chain_id | nil) :: {:ok, binary} | {:error, binary} - def recover(hash, {v, r, s}, chain_id \\ nil) do + @doc """ + Given a hash, signature components and optional chain id, returns the public key. + """ + @spec recover(binary, components) :: {:ok, binary} | {:error, binary} + def recover(digest, {v, r, s}) do sig = BitHelper.pad(:binary.encode_unsigned(r), 32) <> BitHelper.pad(:binary.encode_unsigned(s), 32) - recovery_id = v_to_recovery_id(v, chain_id) + recovery_id = v - @base_v - case Secp256k1.ecdsa_recover_compact(hash, sig, recovery_id) do + case Secp256k1.ecdsa_recover_compact(digest, sig, recovery_id) do {:ok, public_key} -> {:ok, public_key} {:error, reason} -> {:error, to_string(reason)} end end - @spec recover!(digest, components, chain_id | nil) :: binary - def recover!(digest, components, chain_id \\ nil) do - {:ok, pub} = digest |> recover(components, chain_id) + @spec recover!(digest, components) :: binary + def recover!(digest, components) do + {:ok, pub} = digest |> recover(components) pub end - @spec is_signature_valid?(components, chain_id, keyword) :: boolean - def is_signature_valid?({v, r, s}, _, max_s: :secp256k1n), - do: - (v == 27 || v == 28) and - r > 0 and r < @secp256k1n and - s > 0 and s < @secp256k1n + @doc """ + Takes the compact signature and returns the components with `v` added. + """ + @spec compact_to_components(Secp256k1.compact_signature_return()) :: + {:ok, components} | {:error, :cannot_sign} - def is_signature_valid?({r, s, v}, chain_id, max_s: :secp256k1n_2), - do: - (v == 27 || v == 28 || v == chain_id * 2 + 35 || v == chain_id * 2 + 36) and - r > 0 and r < @secp256k1n and - s > 0 and s <= @secp256k1n_2 + def compact_to_components({:ok, {<>, recovery_id}}), + do: {:ok, {@base_v + recovery_id, r, s}} - @spec components(binary) :: {:error, :invalid_signature} | {:ok, components} - def components(sig) do - sig + def compact_to_components({:error, _}), + do: {:error, :cannot_sign} + + @spec components(binary) :: {:ok, components} | {:error, :invalid_signature} + def components(signature) do + signature |> from_human() |> case do {:ok, <>} -> {:ok, {v, r, s}} @@ -94,38 +109,19 @@ defmodule TTEth.Type.Signature do end @spec components!(binary) :: components - def components!(sig) do - <> = sig |> from_human!() - {v, r, s} + def components!(signature) do + {:ok, {_v, _r, _s} = comps} = signature |> components() + comps end - def from_components!({v, r, s}), + @spec from_components!(components) :: binary() + def from_components!({v, r, s} = _components) when v in @valid_vs, do: <> @spec to_human_from_components!(components) :: <<_::16, _::_*8>> - def to_human_from_components!({_, _, _} = components), - do: components |> from_components!() |> to_human!() - - # Private. - - defp uses_chain_id?(v), - do: v >= @base_recovery_id_eip_155 - - @spec v_from_recovery_id(byte, chain_id) :: byte - defp v_from_recovery_id(v, chain_id) do - if is_nil(chain_id) do - @base_recovery_id + v - else - chain_id * 2 + @base_recovery_id_eip_155 + v - end - end - - @spec v_to_recovery_id(number, any) :: number - defp v_to_recovery_id(v, chain_id) do - if not is_nil(chain_id) and uses_chain_id?(v) do - v - chain_id * 2 - @base_recovery_id_eip_155 - else - v - @base_recovery_id - end - end + def to_human_from_components!({_v, _r, _s} = components), + do: + components + |> from_components!() + |> to_human!() end diff --git a/lib/tt_eth/wallet.ex b/lib/tt_eth/wallet.ex index 793e441..4d971a5 100644 --- a/lib/tt_eth/wallet.ex +++ b/lib/tt_eth/wallet.ex @@ -2,45 +2,118 @@ defmodule TTEth.Wallet do @moduledoc """ Provides a handle struct - `TTEth.Wallet.t()` for encapsulating a wallet. """ - alias TTEth.Type.{Address, PublicKey, PrivateKey} + alias TTEth.Type.Signature, as: EthSignature + alias TTEth.Type.Address, as: EthAddress + alias TTEth.Type.PublicKey, as: EthPublicKey - @type t :: %__MODULE__{} + @typedoc """ + Represents a Wallet. + + The underlying adapter lives under `_adapter`. + """ + @type t :: %__MODULE__{ + address: EthAddress.t(), + public_key: EthPublicKey.t(), + human_address: String.t(), + human_public_key: String.t(), + _adapter: struct() + } + + @enforce_keys [:address, :public_key, :human_address, :human_public_key, :_adapter] defstruct [ :address, :public_key, - :private_key, :human_address, :human_public_key, - :human_private_key + :_adapter ] + @doc """ + Looks up a wallet by name from config and creates it. + + See the adapter for specific configuration options. + """ @spec named(atom) :: t() - def named(name), + def named(wallet_name) when is_atom(wallet_name), do: :tt_eth |> Application.fetch_env!(:wallets) - |> Keyword.fetch!(name) - |> from_private_key() + |> Keyword.fetch!(wallet_name) + |> build_with_adapter!( + :tt_eth + |> Application.fetch_env!(:wallet_adapter) + ) + |> new() - @doc "Constructs a wallet from a private key." + @doc """ + Creates a new `TTEth.Wallet.t` struct from a passed private key. + """ @spec from_private_key(binary) :: t() - def from_private_key(priv) when is_binary(priv) do - raw_priv = priv |> PrivateKey.from_human!() |> PublicKey.from_private_key!() - {raw_priv, priv} |> new() + def from_private_key("" <> private_key), + do: + private_key + |> build_with_adapter!() + |> new() + + @doc """ + Creates a new wallet from an underlying wallet or a random one. + """ + def new(%wallet_adapter{} = wallet \\ TTEth.LocalWallet.generate()) + when is_struct(wallet), + do: + __MODULE__ + |> struct!( + wallet + |> wallet_adapter.wallet_attrs() + ) + + @doc """ + Signs a digest using the passed wallet. + """ + def sign(%__MODULE__{_adapter: %wallet_adapter{} = wallet_adapter_state}, "" <> digest), + do: + wallet_adapter_state + |> wallet_adapter.sign(digest) + + @doc """ + Same as `sign/2` but raises if the signing process is not successful. + """ + def sign!(%__MODULE__{} = wallet, "" <> digest) do + {:ok, ret} = wallet |> sign(digest) + ret end - @doc "Constructs a new wallet from a keypair or a random one." - @spec new({binary, binary}) :: t() - def new({pub, priv} \\ TTEth.new_keypair()) when is_binary(pub) and is_binary(priv) do - address = pub |> Address.from_public_key!() - - struct!(%__MODULE__{}, %{ - address: address |> Address.from_human!(), - public_key: pub |> PublicKey.from_human!(), - private_key: priv |> PrivateKey.from_human!(), - human_address: address |> Address.to_human!(), - human_public_key: pub |> PublicKey.to_human!(), - human_private_key: priv |> PrivateKey.to_human!() - }) + @doc """ + Signs a plaintext message using the passed wallet. + + This is for personal signed data, not for transaction data. + + Components of the signature are returned to maintain compatibility with + `TTEth.Type.Signature.sign/2`. + + SEE: https://eips.ethereum.org/EIPS/eip-191 + + SEE: https://ethereum.org/en/developers/docs/apis/json-rpc#eth_sign + """ + def personal_sign(%__MODULE__{} = wallet, "" <> plaintext), + do: + wallet + |> sign(plaintext |> EthSignature.digest()) + |> EthSignature.compact_to_components() + + @doc """ + Same as `personal_sign/2` but raises if the signing process is not successful. + """ + def personal_sign!(%__MODULE__{} = wallet, "" <> plaintext) do + {:ok, comps} = wallet |> personal_sign(plaintext) + comps end + + ## Private. + + defp build_with_adapter!(config, wallet_adapter \\ TTEth.LocalWallet) + when (is_binary(config) or is_map(config)) and is_atom(wallet_adapter), + do: + config + |> wallet_adapter.new() end diff --git a/mix.exs b/mix.exs index dd75a3a..bd38b0f 100644 --- a/mix.exs +++ b/mix.exs @@ -40,6 +40,15 @@ defmodule TTEth.MixProject do defp groups_for_modules(), do: [ + "Transactions Types": [ + TTEth.Transactions.LegacyTransaction, + TTEth.Transactions.EIP1559Transaction + ], + Behaviours: [ + TTEth.Behaviours.ChainClient, + TTEth.Behaviours.Transaction, + TTEth.Behaviours.WalletAdapter + ], Types: [ TTEth.Type.Address, TTEth.Type.Hash, diff --git a/test/support/test_token.ex b/test/support/test_token.ex index 7b11b63..0ff0051 100644 --- a/test/support/test_token.ex +++ b/test/support/test_token.ex @@ -1,4 +1,5 @@ defmodule TTEth.TestToken do + @moduledoc false use TTEth.Contract, abi_file: Application.app_dir(:tt_eth, "priv/fixtures/contract_abis/TestToken.abi") end diff --git a/test/tt_eth/local_wallet_test.exs b/test/tt_eth/local_wallet_test.exs new file mode 100644 index 0000000..aab8834 --- /dev/null +++ b/test/tt_eth/local_wallet_test.exs @@ -0,0 +1,49 @@ +defmodule TTEth.LocalWalletTest do + use TTEth.Case + alias TTEth.LocalWallet + alias TTEth.Type.PrivateKey + + @human_private_key "0xfa015243f2e6d8694ab037a7987dc73b1630fc8cb1ce82860344684c15d24026" + + describe "implements TTEth.Behaviours.Wallet behaviour" do + setup [:build_local_wallet] + + test "new/1 - initializes a new wallet adapter struct" do + decoded_private_key = @human_private_key |> PrivateKey.from_human!() + + %{private_key: @human_private_key} + |> LocalWallet.new() + |> assert_match(%LocalWallet{ + private_key: ^decoded_private_key, + human_private_key: @human_private_key + }) + end + + test "wallet_attrs/1 - returns attributes needed when building a wallet", %{ + local_wallet: %wallet_adapter{} = local_wallet + } do + local_wallet + |> wallet_adapter.wallet_attrs() + |> assert_match(%{ + address: _, + public_key: _, + human_address: "0x" <> _, + human_public_key: "0x" <> _, + _adapter: ^local_wallet + }) + end + + test "sign/2 - signs the given digest with the wallet", %{ + local_wallet: %wallet_adapter{} = local_wallet + } do + local_wallet + |> wallet_adapter.sign("some plaintext" |> TTEth.keccak()) + |> assert_match({:ok, {<<_signature::512>>, recovery_id}} when recovery_id in [0, 1]) + end + end + + ## Private. + + defp build_local_wallet(_), + do: %{local_wallet: @human_private_key |> LocalWallet.new()} +end diff --git a/test/tt_eth/transactions/eip1559_transaction_test.exs b/test/tt_eth/transactions/eip1559_transaction_test.exs index dd31a1c..a19aad8 100644 --- a/test/tt_eth/transactions/eip1559_transaction_test.exs +++ b/test/tt_eth/transactions/eip1559_transaction_test.exs @@ -2,12 +2,12 @@ defmodule TTEth.Transactions.EIP1559TransactionTest do use TTEth.Case alias TTEth.Transactions.EIP1559Transaction alias TTEth.Type.{Address, PrivateKey, PublicKey} + alias TTEth.Wallet # Polygon Mumbai. - @chain_id 80001 + @chain_id 80_001 @private_key_human "0x62aa6ec41b56439d2c5df352c45a00389cef262b3761e13c6481e35ab027d262" - @private_key @private_key_human |> PrivateKey.from_human!() @to_address_human "0x38f153fdd399ff2cf64704c6a4b16d3fd9ddcd69" @to_address @to_address_human |> Address.from_human!() # transfer(address,uint256) @@ -51,20 +51,23 @@ defmodule TTEth.Transactions.EIP1559TransactionTest do end describe "build/2" do - setup :build_trx + setup [ + :build_trx, + :build_wallet + ] - test "builds a signed transaction", %{trx: trx} do + test "builds a signed transaction", %{trx: trx, wallet: wallet} do trx - |> EIP1559Transaction.build(@private_key) + |> EIP1559Transaction.build(wallet) |> encode_and_pad() |> assert_match(@valid_transaction_data) end - test "from address is correct when checking signature", %{trx: trx} do + test "from address is correct when checking signature", %{trx: trx, wallet: wallet} do # Build the trx_data but randomize the nonce. built_trx_data = %{trx | nonce: Enum.random(10..100)} - |> EIP1559Transaction.build(@private_key) + |> EIP1559Transaction.build(wallet) |> encode_and_pad() # Decode the transaction data. @@ -110,13 +113,16 @@ defmodule TTEth.Transactions.EIP1559TransactionTest do data: @tx_data, chain_id: @chain_id, nonce: @nonce, - gas_limit: 21000, + gas_limit: 21_000, max_fee_per_gas: 3_000_000_000, max_priority_fee_per_gas: 3_000_000_001, value: 0 } } + defp build_wallet(_), + do: %{wallet: @private_key_human |> Wallet.from_private_key()} + ## Helpers. defp encode_and_pad(bin), diff --git a/test/tt_eth/wallet_test.exs b/test/tt_eth/wallet_test.exs index 21de7bb..41969a9 100644 --- a/test/tt_eth/wallet_test.exs +++ b/test/tt_eth/wallet_test.exs @@ -1,5 +1,6 @@ defmodule TTEth.WalletTest do use TTEth.Case, async: true + alias TTEth.LocalWallet @human_private_key "0xfa015243f2e6d8694ab037a7987dc73b1630fc8cb1ce82860344684c15d24026" @@ -25,13 +26,6 @@ defmodule TTEth.WalletTest do |> Wallet.from_private_key() |> assert_match_ternary_wallet() end - - test "reconstructs everything from a binary private key" do - @human_private_key - |> TTEth.Type.PrivateKey.from_human!() - |> Wallet.from_private_key() - |> assert_match_ternary_wallet() - end end describe "new/0+1" do @@ -40,18 +34,75 @@ defmodule TTEth.WalletTest do end test "generates a wallet deterministically given a keypair" do - kp = TTEth.new_keypair() + kp = LocalWallet.generate() assert Wallet.new(kp) == Wallet.new(kp) end end + describe "sign/2" do + setup :build_wallet + + test "given a wallet and digest, signs the digest", %{wallet: wallet} do + wallet + |> Wallet.sign("some plaintext" |> TTEth.keccak()) + |> assert_match({:ok, {<<_signature::512>>, recovery_id}} when recovery_id in [0, 1]) + end + + test "returns an error tuple if a failure happens", %{wallet: wallet} do + wallet + |> Wallet.sign("some plaintext") + |> assert_equal({:error, :wrong_message_size}) + end + end + + describe "sign!/2" do + setup :build_wallet + + test "same as sign/2", %{wallet: wallet} do + digest = "some plaintext" |> TTEth.keccak() + + {:ok, compact_signature} = + wallet + |> Wallet.sign(digest) + + wallet + |> Wallet.sign!(digest) + |> assert_match(^compact_signature) + end + end + + describe "personal_sign/2" do + setup :build_wallet + + test "signs a plaintext using the EIP-191 standard", %{wallet: wallet} do + wallet + |> Wallet.personal_sign("some plaintext") + |> assert_match({:ok, {v, _r, _s}} when v in [27, 28]) + end + end + + describe "personal_sign!/2" do + setup :build_wallet + + test "same as personal_sign/2", %{wallet: wallet} do + {:ok, components} = + wallet + |> Wallet.personal_sign("some plaintext") + + wallet + |> Wallet.personal_sign!("some plaintext") + |> assert_match(^components) + end + end + ## Private. defp assert_match_ternary_wallet(wallet) do - private_key = @human_private_key |> TTEth.Type.PrivateKey.from_human!() human_address = "0x0aF6b8a8E5D56F0ab74D47Ac446EEa46817F32bC" address = human_address |> TTEth.Type.Address.from_human!() + private_key = @human_private_key |> TTEth.Type.PrivateKey.from_human!() + human_public_key = "0x58be6efb58e39ce4b5d1ca552d80f8c9009dfecec0e5a31fc8d22ee866320c506be5" <> "8730c77623df9862d8041c1bdef8a031e5d38a1ac1b83d053277391f974c" @@ -60,12 +111,17 @@ defmodule TTEth.WalletTest do wallet |> assert_match(%Wallet{ + address: ^address, + public_key: ^public_key, human_address: ^human_address, - human_private_key: @human_private_key, human_public_key: ^human_public_key, - address: ^address, - private_key: ^private_key, - public_key: ^public_key + _adapter: %LocalWallet{ + private_key: ^private_key, + human_private_key: @human_private_key + } }) end + + defp build_wallet(_), + do: %{wallet: Wallet.new()} end diff --git a/test/tt_eth_test.exs b/test/tt_eth_test.exs index d6b9d46..1971076 100644 --- a/test/tt_eth_test.exs +++ b/test/tt_eth_test.exs @@ -3,7 +3,7 @@ defmodule TTEthTest do import TTEth, only: [binary_to_hex!: 1] doctest TTEth, import: true - @chain_id 12345 + @chain_id 12_345 describe "new_keypair/0" do test "generates a public and private key" do @@ -59,18 +59,16 @@ defmodule TTEthTest do tx_data = to - |> TTEth.ChainClient.build_tx_data(abi_data, wallet.private_key, nonce_dec, - chain_id: @chain_id - ) + |> TTEth.ChainClient.build_tx_data(abi_data, wallet, nonce_dec, chain_id: @chain_id) ChainClientMock # We don't care about this in this test. |> expect(:eth_get_transaction_count, fn _, _ -> {:ok, nonce_hex} end) # We want to make sure that the transaction building function was called with the correct params. - |> expect(:build_tx_data, fn to_, abi_data_, private_key_, nonce_, opts_ -> + |> expect(:build_tx_data, fn to_, abi_data_, wallet_, nonce_, opts_ -> assert to_ == to assert abi_data == abi_data_ - assert private_key_ == wallet.private_key + assert wallet_ == wallet assert nonce_ == nonce_dec assert opts_ == [chain_id: @chain_id] tx_data