From 7f8f091979e2939c1554ad1edf3404e75ff2d025 Mon Sep 17 00:00:00 2001 From: Alisina Bahadori Date: Wed, 18 Dec 2024 22:22:22 -0500 Subject: [PATCH] Implement `Transaction.decode/1` (#166) * Initial decode implementation * Rename SignedTransaction to Signed * Add tests for decode and fix bugs * Fix credo and dialyzer warnings * Fix transaction decoding from RPC maps * Rename signed payload key * Add Transaction.Metadata * Update CHANGELOG.md --- CHANGELOG.md | 8 +- lib/ethers/signer.ex | 2 +- lib/ethers/signer/local.ex | 10 +- lib/ethers/transaction.ex | 171 +++++++++++++----- lib/ethers/transaction/eip1559.ex | 45 ++++- lib/ethers/transaction/legacy.ex | 25 +++ lib/ethers/transaction/metadata.ex | 30 ++++ lib/ethers/transaction/protocol.ex | 14 +- lib/ethers/transaction/signed.ex | 177 +++++++++++++++++++ lib/ethers/transaction/signed_transaction.ex | 62 ------- mix.exs | 4 + test/ethers/transaction_test.exs | 55 ++++++ test/ethers_test.exs | 20 ++- 13 files changed, 493 insertions(+), 130 deletions(-) create mode 100644 lib/ethers/transaction/metadata.ex create mode 100644 lib/ethers/transaction/signed.ex delete mode 100644 lib/ethers/transaction/signed_transaction.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index aae5c78..ebccece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,17 +7,19 @@ - Removed `Ethers.Transaction` struct and replaced with separate EIP-1559 and Legacy transaction structs for improved type safety - Deprecated `Ethers.Transaction.from_map/1` - use `Ethers.Transaction.from_rpc_map/1` instead for RPC response parsing - Deprecated `Ethers.Utils.maybe_add_gas_limit/2` - gas limits should now be set explicitly -- Changed input format requirements: All inputs to `Ethers` functions must use native types (e.g., integers) instead of hex strings encoded values. +- Changed input format requirements: All inputs to `Ethers` functions must use native types (e.g., integers) instead of hex strings encoded values - Removed auto-gas estimation from send_transaction calls -- `tx_type` option in transaction overrides has been replaced with `type`, now requiring explicit struct modules (e.g. `Ethers.Transaction.Eip1559`, `Ethers.Transaction.Legacy`). +- `tx_type` option in transaction overrides has been replaced with `type`, now requiring explicit struct modules (e.g. `Ethers.Transaction.Eip1559`, `Ethers.Transaction.Legacy`) +- Moved `Ethers.Transaction.calculate_y_parity_or_v/1` to `Ethers.Transaction.Signed` module ### New features - Added **EIP-3668 CCIP-Read** support via `Ethers.CcipRead` module for off-chain data resolution - Extended NameService to handle off-chain and cross-chain name resolution using CCIP-Read protocol -- Introduced `Ethers.Transaction.Protocol` behaviour for improved transaction handling. +- Introduced `Ethers.Transaction.Protocol` behaviour for improved transaction handling - Added dedicated *EIP-1559* and *Legacy* transaction struct types with validation - New address utilities: `Ethers.Utils.decode_address/1` and `Ethers.Utils.encode_address/1` +- Added `Transaction.decode/1` to decode raw transactions ### Enhancements diff --git a/lib/ethers/signer.ex b/lib/ethers/signer.ex index d4aa6aa..761562f 100644 --- a/lib/ethers/signer.ex +++ b/lib/ethers/signer.ex @@ -40,7 +40,7 @@ defmodule Ethers.Signer do - opts: Other options passed to the signer as `signer_opts`. """ @callback sign_transaction( - tx :: Ethers.Transaction.t(), + tx :: Ethers.Transaction.t_payload(), opts :: Keyword.t() ) :: {:ok, encoded_signed_transaction :: binary()} | {:error, reason :: term()} diff --git a/lib/ethers/signer/local.ex b/lib/ethers/signer/local.ex index 253e6de..d57071d 100644 --- a/lib/ethers/signer/local.ex +++ b/lib/ethers/signer/local.ex @@ -18,7 +18,7 @@ defmodule Ethers.Signer.Local do import Ethers, only: [secp256k1_module: 0, keccak_module: 0] alias Ethers.Transaction - alias Ethers.Transaction.SignedTransaction + alias Ethers.Transaction.Signed alias Ethers.Utils if not Code.ensure_loaded?(secp256k1_module()) do @@ -33,15 +33,15 @@ defmodule Ethers.Signer.Local do def sign_transaction(transaction, opts) do with {:ok, private_key} <- private_key(opts), :ok <- validate_private_key(private_key, Keyword.get(opts, :from)), - encoded = Transaction.encode(transaction, :hash), + encoded = Transaction.encode(transaction), sign_hash = keccak_module().hash_256(encoded), {:ok, {r, s, recovery_id}} <- secp256k1_module().sign(sign_hash, private_key) do signed_transaction = - %SignedTransaction{ - transaction: transaction, + %Signed{ + payload: transaction, signature_r: r, signature_s: s, - signature_y_parity_or_v: Transaction.calculate_y_parity_or_v(transaction, recovery_id) + signature_y_parity_or_v: Signed.calculate_y_parity_or_v(transaction, recovery_id) } encoded_signed_transaction = Transaction.encode(signed_transaction) diff --git a/lib/ethers/transaction.ex b/lib/ethers/transaction.ex index d72a947..f2440ac 100644 --- a/lib/ethers/transaction.ex +++ b/lib/ethers/transaction.ex @@ -11,16 +11,21 @@ defmodule Ethers.Transaction do alias Ethers.Transaction.Eip1559 alias Ethers.Transaction.Legacy alias Ethers.Transaction.Protocol, as: TxProtocol - alias Ethers.Transaction.SignedTransaction + alias Ethers.Transaction.Signed alias Ethers.Utils @typedoc """ EVM Transaction type """ - @type t :: Eip1559.t() | Legacy.t() | SignedTransaction.t() + @type t :: t_payload() | Signed.t() + + @typedoc """ + EVM Transaction payload type + """ + @type t_payload :: Eip1559.t() | Legacy.t() @doc "Creates a new transaction struct with the given parameters." - @callback new(map()) :: {:ok, struct()} | {:error, atom()} + @callback new(map()) :: {:ok, t()} | {:error, reason :: atom()} @doc "Returns a list of fields that can be auto-fetched from the network." @callback auto_fetchable_fields() :: [atom()] @@ -31,12 +36,13 @@ defmodule Ethers.Transaction do @doc "Returns the type ID for the transaction. e.g Legacy: 0, EIP-1559: 2" @callback type_id() :: non_neg_integer() - @default_transaction_type Eip1559 + @doc "Constructs a transaction from a decoded RLP list" + @callback from_rlp_list([binary() | [binary()]]) :: + {:ok, t(), rest :: [binary() | [binary()]]} | {:error, reason :: term()} - @transaction_type_modules Application.compile_env(:ethers, :transaction_types, [Legacy, Eip1559]) + @default_transaction_type Eip1559 - @legacy_parity_magic_number 27 - @legacy_parity_with_chain_magic_number 35 + @transaction_type_modules Application.compile_env(:ethers, :transaction_types, [Eip1559, Legacy]) @rpc_fields %{ access_list: :accessList, @@ -80,8 +86,8 @@ defmodule Ethers.Transaction do case Map.fetch(params, :signature_r) do {:ok, sig_r} when not is_nil(sig_r) -> params - |> Map.put(:transaction, transaction) - |> SignedTransaction.new() + |> Map.put(:payload, transaction) + |> Signed.new() :error -> {:ok, transaction} @@ -137,13 +143,88 @@ defmodule Ethers.Transaction do * `binary` - RLP encoded transaction with appropriate type envelope """ @spec encode(t()) :: binary() - def encode(transaction, mode \\ :payload) do + def encode(%mod{} = transaction) do + mode = if mod == Signed, do: :payload, else: :hash + transaction |> TxProtocol.to_rlp_list(mode) |> ExRLP.encode() |> prepend_type_envelope(transaction) end + @doc """ + Decodes a raw transaction from a binary or hex-encoded string. + + Transaction strings must be prefixed with "0x" for hex-encoded inputs. + Handles both legacy and typed transactions (EIP-1559, etc). + + ## Parameters + * `raw_transaction` - Raw transaction data as a binary or hex string starting with "0x" + + ## Returns + * `{:ok, transaction}` - Decoded transaction struct + * `{:error, reason}` - Error decoding transaction + """ + @spec decode(String.t()) :: {:ok, t()} | {:error, term()} + def decode("0x" <> raw_transaction) do + case raw_transaction + |> Utils.hex_decode!() + |> decode_transaction_data() do + {:ok, transaction, signature} -> + maybe_decode_signature(transaction, signature) + + {:error, reason} -> + {:error, reason} + end + end + + Enum.each(@transaction_type_modules, fn module -> + type_envelope = module.type_envelope() + + defp decode_transaction_data(<>) do + rlp_decoded = ExRLP.decode(rest) + unquote(module).from_rlp_list(rlp_decoded) + end + end) + + defp decode_transaction_data(legacy_transaction) when is_binary(legacy_transaction) do + rlp_decoded = ExRLP.decode(legacy_transaction) + + Legacy.from_rlp_list(rlp_decoded) + end + + defp maybe_decode_signature(transaction, rlp_list) do + case Signed.from_rlp_list(rlp_list, transaction) do + {:ok, signed_transaction} -> {:ok, signed_transaction} + {:error, :no_signature} -> {:ok, transaction} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Calculates the transaction hash. + + ## Parameters + - `transaction` - Transaction struct to hash + - `format` - Format to return the hash in (default: `:hex`) + + ## Returns + - `binary` - Transaction hash in binary format (when `format` is `:bin`) + - `String.t()` - Transaction hash in hex format prefixed with "0x" (when `format` is `:hex`) + """ + @spec transaction_hash(t(), :bin | :hex) :: binary() | String.t() + def transaction_hash(transaction, format \\ :hex) do + hash_bin = + transaction + |> encode() + |> Ethers.keccak_module().hash_256() + + case format do + :bin -> hash_bin + :hex -> Utils.hex_encode(hash_bin) + end + end + @doc """ Converts a map (typically from JSON-RPC response) into a Transaction struct. @@ -164,57 +245,37 @@ defmodule Ethers.Transaction do new(%{ access_list: from_map_value(tx, :accessList), block_hash: from_map_value(tx, :blockHash), - block_number: from_map_value(tx, :blockNumber), - chain_id: from_map_value(tx, :chainId), + block_number: from_map_value_int(tx, :blockNumber), + chain_id: from_map_value_int(tx, :chainId), input: from_map_value(tx, :input), from: from_map_value(tx, :from), - gas: from_map_value(tx, :gas), - gas_price: from_map_value(tx, :gasPrice), + gas: from_map_value_int(tx, :gas), + gas_price: from_map_value_int(tx, :gasPrice), hash: from_map_value(tx, :hash), - max_fee_per_gas: from_map_value(tx, :maxFeePerGas), - max_priority_fee_per_gas: from_map_value(tx, :maxPriorityFeePerGas), - nonce: from_map_value(tx, :nonce), - signature_r: from_map_value(tx, :r), - signature_s: from_map_value(tx, :s), - signature_y_parity_or_v: from_map_value(tx, :yParity) || from_map_value(tx, :v), + max_fee_per_gas: from_map_value_int(tx, :maxFeePerGas), + max_priority_fee_per_gas: from_map_value_int(tx, :maxPriorityFeePerGas), + nonce: from_map_value_int(tx, :nonce), + signature_r: from_map_value_bin(tx, :r), + signature_s: from_map_value_bin(tx, :s), + signature_y_parity_or_v: from_map_value_int(tx, :yParity) || from_map_value_int(tx, :v), to: from_map_value(tx, :to), - transaction_index: from_map_value(tx, :transactionIndex), - value: from_map_value(tx, :value), + transaction_index: from_map_value_int(tx, :transactionIndex), + value: from_map_value_int(tx, :value), type: type }) end end @doc """ - Calculates the y-parity or v value for transaction signatures. - - Handles both legacy and EIP-1559 transaction types according to their specifications. + Converts a Transaction struct into a map suitable for JSON-RPC. ## Parameters - - `tx` - Transaction struct - - `recovery_id` - Recovery ID from the signature + - `transaction` - Transaction struct to convert ## Returns - - `integer` - Calculated y-parity or v value + - map containing transaction parameters with RPC field names and "0x" prefixed hex values """ - @spec calculate_y_parity_or_v(t(), binary() | non_neg_integer()) :: - non_neg_integer() - def calculate_y_parity_or_v(tx, recovery_id) do - case tx do - %Legacy{chain_id: nil} -> - # EIP-155 - recovery_id + @legacy_parity_magic_number - - %Legacy{chain_id: chain_id} -> - # EIP-155 - recovery_id + chain_id * 2 + @legacy_parity_with_chain_magic_number - - _tx -> - # EIP-1559 - recovery_id - end - end - + @spec to_rpc_map(t()) :: map() def to_rpc_map(transaction) do transaction |> then(fn @@ -256,6 +317,10 @@ defmodule Ethers.Transaction do ) end + @doc false + @deprecated "Use Transaction.Signed.calculate_y_parity_or_v/2 instead" + defdelegate calculate_y_parity_or_v(tx, recovery_id), to: Signed + defp prepend_type_envelope(encoded_tx, transaction) do TxProtocol.type_envelope(transaction) <> encoded_tx end @@ -305,6 +370,20 @@ defmodule Ethers.Transaction do defp decode_type(nil), do: {:ok, Legacy} defp decode_type(_type), do: {:error, :unsupported_type} + defp from_map_value_bin(tx, key) do + case from_map_value(tx, key) do + nil -> nil + hex -> Utils.hex_decode!(hex) + end + end + + defp from_map_value_int(tx, key) do + case from_map_value(tx, key) do + nil -> nil + hex -> Utils.hex_to_integer!(hex) + end + end + defp from_map_value(tx, key) do Map.get_lazy(tx, key, fn -> Map.get(tx, to_string(key)) end) end diff --git a/lib/ethers/transaction/eip1559.ex b/lib/ethers/transaction/eip1559.ex index 0404777..aa9e4a9 100644 --- a/lib/ethers/transaction/eip1559.ex +++ b/lib/ethers/transaction/eip1559.ex @@ -1,6 +1,9 @@ defmodule Ethers.Transaction.Eip1559 do @moduledoc """ - EIP1559 transaction struct and implementation of Transaction.Protocol. + Transaction struct and protocol implementation for Ethereum Improvement Proposal (EIP) 1559 + transactions. EIP-1559 introduced a new fee market mechanism with base fee and priority fee. + + See: https://eips.ethereum.org/EIPS/eip-1559 """ alias Ethers.Types @@ -23,6 +26,18 @@ defmodule Ethers.Transaction.Eip1559 do access_list: [] ] + @typedoc """ + A transaction type following EIP-1559 (Type-2) and incorporating the following fields: + - `chain_id` - chain ID of network where the transaction is to be executed + - `nonce` - sequence number for the transaction from this sender + - `max_priority_fee_per_gas` - maximum fee per gas (in wei) to give to validators as priority fee (introduced in EIP-1559) + - `max_fee_per_gas` - maximum total fee per gas (in wei) willing to pay (introduced in EIP-1559) + - `gas` - maximum amount of gas allowed for transaction execution + - `to` - destination address for transaction, nil for contract creation + - `value` - amount of ether (in wei) to transfer + - `input` - data payload of the transaction + - `access_list` - list of addresses and storage keys to warm up (introduced in EIP-2930) + """ @type t :: %__MODULE__{ chain_id: non_neg_integer(), nonce: non_neg_integer(), @@ -62,6 +77,34 @@ defmodule Ethers.Transaction.Eip1559 do @impl Ethers.Transaction def type_id, do: @type_id + @impl Ethers.Transaction + def from_rlp_list([ + chain_id, + nonce, + max_priority_fee_per_gas, + max_fee_per_gas, + gas, + to, + value, + input, + access_list | rest + ]) do + {:ok, + %__MODULE__{ + chain_id: :binary.decode_unsigned(chain_id), + nonce: :binary.decode_unsigned(nonce), + max_priority_fee_per_gas: :binary.decode_unsigned(max_priority_fee_per_gas), + max_fee_per_gas: :binary.decode_unsigned(max_fee_per_gas), + gas: :binary.decode_unsigned(gas), + to: (to != "" && Utils.encode_address!(to)) || nil, + value: :binary.decode_unsigned(value), + input: Utils.hex_encode(input), + access_list: access_list + }, rest} + end + + def from_rlp_list(_rlp_list), do: {:error, :transaction_decode_failed} + defimpl Ethers.Transaction.Protocol do def type_id(_transaction), do: @for.type_id() diff --git a/lib/ethers/transaction/legacy.ex b/lib/ethers/transaction/legacy.ex index 4afe390..4d7d98f 100644 --- a/lib/ethers/transaction/legacy.ex +++ b/lib/ethers/transaction/legacy.ex @@ -21,6 +21,16 @@ defmodule Ethers.Transaction.Legacy do :chain_id ] + @typedoc """ + Legacy transaction type (Type-0) incorporating the following fields: + - nonce: Transaction sequence number for the sending account + - gas_price: Price willing to pay for each unit of gas (in wei) + - gas: Maximum number of gas units willing to pay for + - to: Recipient address or nil for contract creation + - value: Amount of ether to transfer in wei + - input: Transaction data payload, also called 'data' + - chain_id: Network ID from [EIP-155](https://eips.ethereum.org/EIPS/eip-155), defaults to nil for legacy + """ @type t :: %__MODULE__{ nonce: non_neg_integer(), gas_price: non_neg_integer(), @@ -57,6 +67,21 @@ defmodule Ethers.Transaction.Legacy do @impl Ethers.Transaction def type_id, do: @type_id + @impl Ethers.Transaction + def from_rlp_list([nonce, gas_price, gas, to, value, input | rest]) do + {:ok, + %__MODULE__{ + nonce: :binary.decode_unsigned(nonce), + gas_price: :binary.decode_unsigned(gas_price), + gas: :binary.decode_unsigned(gas), + to: (to != "" && Utils.encode_address!(to)) || nil, + value: :binary.decode_unsigned(value), + input: Utils.hex_encode(input) + }, rest} + end + + def from_rlp_list(_rlp_list), do: {:error, :transaction_decode_failed} + defimpl Ethers.Transaction.Protocol do def type_id(_transaction), do: @for.type_id() diff --git a/lib/ethers/transaction/metadata.ex b/lib/ethers/transaction/metadata.ex new file mode 100644 index 0000000..d914f63 --- /dev/null +++ b/lib/ethers/transaction/metadata.ex @@ -0,0 +1,30 @@ +defmodule Ethers.Transaction.Metadata do + @moduledoc """ + Metadata for a transaction like block hash, block number, and transaction index. + """ + + defstruct block_hash: nil, + block_number: nil, + transaction_index: nil + + @typedoc """ + Transaction metadata type incorporating the following fields: + - `block_hash` - hash of the block where the transaction was included + - `block_number` - block number where the transaction was included + - `transaction_index` - index of the transaction in the block + """ + @type t :: %__MODULE__{ + block_hash: binary() | nil, + block_number: non_neg_integer() | nil, + transaction_index: non_neg_integer() | nil + } + + @doc false + def new!(params) do + %__MODULE__{ + block_hash: params[:block_hash], + block_number: params[:block_number], + transaction_index: params[:transaction_index] + } + end +end diff --git a/lib/ethers/transaction/protocol.ex b/lib/ethers/transaction/protocol.ex index 6f697f5..c3a6d54 100644 --- a/lib/ethers/transaction/protocol.ex +++ b/lib/ethers/transaction/protocol.ex @@ -1,17 +1,18 @@ defprotocol Ethers.Transaction.Protocol do @moduledoc """ - EVM Transaction Protocol + Protocol for handling Ethereum Virtual Machine (EVM) transactions. """ @doc """ - Returns the binary value of the transaction type envelope or empty binary if legacy transaction. + Returns the binary value of the transaction type envelope. + For legacy transactions, returns an empty binary. """ @fallback_to_any true @spec type_envelope(t) :: binary() def type_envelope(transaction) @doc """ - Returns type of transaction as an integer (e.g. Legacy: 0, EIP-1559: 2) + Returns type of transaction as an integer. """ @fallback_to_any true @spec type_id(t) :: non_neg_integer() @@ -21,9 +22,10 @@ defprotocol Ethers.Transaction.Protocol do Returns a list ready to be RLP encoded for a given transaction. ## Parameters - - `transaction` - Transaction struct - - `mode` - Specifies what RLP mode is. `:payload` for encoding the transaction payload, - `:hash` for encoding the transaction hash + - `transaction` - Transaction struct containing the transaction data + - `mode` - Encoding mode: + - `:payload` - For encoding the transaction payload + - `:hash` - For encoding the transaction hash """ @spec to_rlp_list(t, mode :: :payload | :hash) :: [binary() | [binary()]] def to_rlp_list(transaction, mode) diff --git a/lib/ethers/transaction/signed.ex b/lib/ethers/transaction/signed.ex new file mode 100644 index 0000000..cc7e9d1 --- /dev/null +++ b/lib/ethers/transaction/signed.ex @@ -0,0 +1,177 @@ +defmodule Ethers.Transaction.Signed do + @moduledoc """ + A struct that wraps a transaction and its signature values. + """ + + alias Ethers.Transaction + alias Ethers.Transaction.Legacy + alias Ethers.Transaction.Metadata + + @enforce_keys [:payload, :signature_r, :signature_s, :signature_y_parity_or_v] + defstruct [ + :payload, + :signature_r, + :signature_s, + :signature_y_parity_or_v, + metadata: nil + ] + + @typedoc """ + A transaction signature envelope that wraps transaction data with its signature components. + + This type supports both Legacy (pre-EIP-155), EIP-155 Legacy, and EIP-1559 transaction formats. + The signature components consist of: + - `signature_r`, `signature_s`: The ECDSA signature values as defined in Ethereum's Yellow Paper + - `signature_y_parity_or_v`: The recovery value that varies by transaction type: + - For pre-EIP-155 Legacy transactions: v = recovery_id + 27 + - For EIP-155 Legacy transactions: v = recovery_id + chain_id * 2 + 35 + - For EIP-1559 transactions: Just the recovery_id (0 or 1) as specified in EIP-2930 + + Related EIPs: + - [EIP-155](https://eips.ethereum.org/EIPS/eip-155): Simple replay attack protection + - [EIP-1559](https://eips.ethereum.org/EIPS/eip-1559): Fee market change for ETH 1.0 chain + - [EIP-2930](https://eips.ethereum.org/EIPS/eip-2930): Optional access lists + """ + @type t :: %__MODULE__{ + payload: Transaction.t_payload(), + signature_r: binary(), + signature_s: binary(), + signature_y_parity_or_v: non_neg_integer(), + metadata: Metadata.t() | nil + } + + @legacy_parity_magic_number 27 + @legacy_parity_with_chain_magic_number 35 + + @doc false + def new(params) do + {:ok, + %__MODULE__{ + payload: params.payload, + signature_r: params.signature_r, + signature_s: params.signature_s, + signature_y_parity_or_v: params.signature_y_parity_or_v, + metadata: Metadata.new!(params) + }} + end + + @doc false + def from_rlp_list(rlp_list, payload) do + case rlp_list do + [signature_y_parity_or_v, signature_r, signature_s] -> + signed_tx = + maybe_add_chain_id(%__MODULE__{ + payload: payload, + signature_r: signature_r, + signature_s: signature_s, + signature_y_parity_or_v: :binary.decode_unsigned(signature_y_parity_or_v) + }) + + {:ok, signed_tx} + + [] -> + {:error, :no_signature} + + _rlp_list -> + {:error, :signature_decode_failed} + end + end + + defp maybe_add_chain_id(%__MODULE__{payload: %Legacy{chain_id: nil} = legacy_tx} = signed_tx) do + {chain_id, _recovery_id} = extract_chain_id_and_recovery_id(signed_tx) + %__MODULE__{signed_tx | payload: %Legacy{legacy_tx | chain_id: chain_id}} + end + + defp maybe_add_chain_id(%__MODULE__{} = tx), do: tx + + @doc """ + Calculates the from address of a signed transaction using its signature. + + The from address is inferred from the signature of the transaction rather than being explicitly + specified. This is done by recovering the signer's public key from the signature and then + deriving the corresponding Ethereum address. + + ## Returns + - `{:ok, address}` - Successfully recovered from address + - `{:error, reason}` - Failed to recover address + """ + @spec from_address(t()) :: {:ok, Ethers.Types.t_address()} | {:error, atom()} + def from_address(%__MODULE__{} = transaction) do + hash_bin = Transaction.transaction_hash(transaction.payload, :bin) + + {_chain_id, recovery_id} = extract_chain_id_and_recovery_id(transaction) + + case Ethers.secp256k1_module().recover( + hash_bin, + transaction.signature_r, + transaction.signature_s, + recovery_id + ) do + {:ok, pubkey} -> Ethers.Utils.public_key_to_address(pubkey) + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Calculates the y-parity or v value for transaction signatures. + + Handles both legacy and EIP-1559 transaction types according to their specifications. + + ## Parameters + - `tx` - Transaction struct + - `recovery_id` - Recovery ID from the signature + + ## Returns + - `integer` - Calculated y-parity or v value + """ + @spec calculate_y_parity_or_v(Transaction.t_payload(), binary() | non_neg_integer()) :: + non_neg_integer() + def calculate_y_parity_or_v(tx, recovery_id) do + case tx do + %Legacy{chain_id: nil} -> + # EIP-155 + recovery_id + @legacy_parity_magic_number + + %Legacy{chain_id: chain_id} -> + # EIP-155 + recovery_id + chain_id * 2 + @legacy_parity_with_chain_magic_number + + _tx -> + # EIP-1559 + recovery_id + end + end + + @spec extract_chain_id_and_recovery_id(t()) :: {non_neg_integer() | nil, non_neg_integer()} + defp extract_chain_id_and_recovery_id(%__MODULE__{payload: tx, signature_y_parity_or_v: v}) do + case tx do + %Legacy{} -> + if v >= @legacy_parity_with_chain_magic_number do + chain_id = div(v - @legacy_parity_with_chain_magic_number, 2) + recovery_id = v - chain_id * 2 - @legacy_parity_with_chain_magic_number + {chain_id, recovery_id} + else + {nil, v - @legacy_parity_magic_number} + end + + _tx -> + {tx.chain_id, v} + end + end + + defimpl Transaction.Protocol do + def type_id(signed_tx), do: Transaction.Protocol.type_id(signed_tx.pyalod) + + def type_envelope(signed_tx), do: Transaction.Protocol.type_envelope(signed_tx.payload) + + def to_rlp_list(signed_tx, mode) do + base_list = Transaction.Protocol.to_rlp_list(signed_tx.payload, mode) + + base_list ++ signature_fields(signed_tx) + end + + defp signature_fields(signed_tx) do + [signed_tx.signature_y_parity_or_v, signed_tx.signature_r, signed_tx.signature_s] + end + end +end diff --git a/lib/ethers/transaction/signed_transaction.ex b/lib/ethers/transaction/signed_transaction.ex deleted file mode 100644 index e1fe8c2..0000000 --- a/lib/ethers/transaction/signed_transaction.ex +++ /dev/null @@ -1,62 +0,0 @@ -defmodule Ethers.Transaction.SignedTransaction do - @moduledoc """ - A struct that wraps a transaction and its signature values. - """ - - alias Ethers.Transaction - - @behaviour Ethers.Transaction - - @enforce_keys [:transaction, :signature_r, :signature_s, :signature_y_parity_or_v] - defstruct [ - :transaction, - :signature_r, - :signature_s, - :signature_y_parity_or_v - ] - - @type t :: %__MODULE__{ - transaction: Transaction.Legacy.t() | Transaction.Eip1559.t(), - signature_r: binary(), - signature_s: binary(), - signature_y_parity_or_v: binary() | non_neg_integer() - } - - @impl Ethers.Transaction - def new(params) do - {:ok, - %__MODULE__{ - transaction: params.transaction, - signature_r: params.signature_r, - signature_s: params.signature_s, - signature_y_parity_or_v: params.signature_y_parity_or_v - }} - end - - @impl Ethers.Transaction - def auto_fetchable_fields, do: [] - - @dialyzer {:nowarn_function, {:type_envelope, 0}} - @impl Ethers.Transaction - def type_envelope, do: raise("Not supported") - - @dialyzer {:nowarn_function, {:type_id, 0}} - @impl Ethers.Transaction - def type_id, do: raise("Not supported") - - defimpl Transaction.Protocol do - def type_id(signed_tx), do: Transaction.Protocol.type_id(signed_tx.transaction) - - def type_envelope(signed_tx), do: Transaction.Protocol.type_envelope(signed_tx.transaction) - - def to_rlp_list(signed_tx, mode) do - base_list = Transaction.Protocol.to_rlp_list(signed_tx.transaction, mode) - - base_list ++ signature_fields(signed_tx) - end - - defp signature_fields(signed_tx) do - [signed_tx.signature_y_parity_or_v, signed_tx.signature_r, signed_tx.signature_s] - end - end -end diff --git a/mix.exs b/mix.exs index 600359b..8404ad7 100644 --- a/mix.exs +++ b/mix.exs @@ -68,6 +68,10 @@ defmodule Ethers.MixProject do Ethers.Contracts ], groups_for_modules: [ + Transactions: [ + "Ethers.Transaction", + ~r/^Ethers\.Transaction\..*$/ + ], "Builtin Contracts": [ ~r/^Ethers\.Contracts\.(?:(?!EventFilters$|Errors\.).)*$/ ], diff --git a/test/ethers/transaction_test.exs b/test/ethers/transaction_test.exs index 71a0df1..db1344a 100644 --- a/test/ethers/transaction_test.exs +++ b/test/ethers/transaction_test.exs @@ -36,4 +36,59 @@ defmodule Ethers.TransactionTest do Transaction.encode(transaction) |> Ethers.Utils.hex_encode() end end + + describe "decode/1" do + test "decodes raw EIP-1559 transaction correctly" do + raw_tx = + "0x02f8af0177837a12008502c4bfbc3282f88c948881562783028f5c1bcb985d2283d5e170d8888880b844a9059cbb0000000000000000000000002ef7f5c7c727d8845e685f462a5b4f8ac4972a6700000000000000000000000000000000000000000000051ab2ea6fbbb7420000c001a007280557e86f690290f9ea9e26cc17e0cf09a17f6c2d041e95b33be4b81888d0a06c7a24e8fba5cceb455b19950849b9733f0deb92d7e8c2a919f4a82df9c6036a" + + expected_from = "0xCD543881D298BB4dd626b273200ed61867fB395D" + expected_hash = "0x224d121387e3bbabfc7bad271b22dddc0dc2743aaf49d850161f628ac9514179" + + assert {:ok, decoded_tx} = Transaction.decode(raw_tx) + assert %Transaction.Signed{payload: %Transaction.Eip1559{}} = decoded_tx + + # Verify transaction hash matches + assert Transaction.transaction_hash(decoded_tx) == expected_hash + + # Verify recovered from address + recovered_from = Transaction.Signed.from_address(decoded_tx) + assert String.downcase(recovered_from) == String.downcase(expected_from) + + # Verify other transaction fields + assert decoded_tx.payload.chain_id == 1 + assert decoded_tx.payload.gas == 63_628 + assert decoded_tx.payload.max_fee_per_gas == 11_890_834_482 + assert decoded_tx.payload.nonce == 119 + assert decoded_tx.payload.max_priority_fee_per_gas == 8_000_000 + assert decoded_tx.payload.to == "0x8881562783028f5c1bcb985d2283d5e170d88888" + assert decoded_tx.payload.value == 0 + end + + test "decodes raw legacy transaction correctly" do + raw_tx = + "0xf86c81c6850c92a69c0082520894e48c9a989438606a79a7560cfba3d34bafbac38e87596f744abf34368025a0ee0b54a64cf8130e36cd1d19395d6d434c285c832a7908873a24610ec32896dfa070b5e779cdcaf5c661c1df44e80895f6ab68463d3ede2cf4955855bc3c6edebb" + + expected_from = "0xB24D14a32CF2fC733209525235937736fC81C1dB" + expected_hash = "0x5a456fc4bb92a075552d1b8b2ce0e61c75b87a237a8108819ea735d13b7d52aa" + + assert {:ok, decoded_tx} = Transaction.decode(raw_tx) + assert %Transaction.Signed{payload: %Transaction.Legacy{}} = decoded_tx + + # Verify transaction hash matches + assert Transaction.transaction_hash(decoded_tx) == expected_hash + + # Verify recovered from address + recovered_from = Transaction.Signed.from_address(decoded_tx) + assert String.downcase(recovered_from) == String.downcase(expected_from) + + # Verify other transaction fields + assert decoded_tx.payload.chain_id == 1 + assert decoded_tx.payload.gas == 21_000 + assert decoded_tx.payload.nonce == 198 + assert decoded_tx.payload.gas_price == 54_000_000_000 + assert decoded_tx.payload.to == "0xe48c9a989438606a79a7560cfba3d34bafbac38e" + assert decoded_tx.payload.value == 25_173_818_188_182_582 + end + end end diff --git a/test/ethers_test.exs b/test/ethers_test.exs index 1dbb3c9..972f97d 100644 --- a/test/ethers_test.exs +++ b/test/ethers_test.exs @@ -105,14 +105,23 @@ defmodule EthersTest do ] ) + wait_for_transaction!(tx_hash) + downcased_to_addr = String.downcase(@to) assert {:ok, - %Ethers.Transaction.SignedTransaction{ - transaction: %Ethers.Transaction.Eip1559{ + %Ethers.Transaction.Signed{ + payload: %Ethers.Transaction.Eip1559{ to: ^downcased_to_addr + }, + metadata: %Ethers.Transaction.Metadata{ + block_hash: "0x" <> _, + block_number: block_number, + transaction_index: 0 } }} = Ethers.get_transaction(tx_hash) + + assert is_integer(block_number) and block_number >= 0 end test "works in batch requests" do @@ -129,8 +138,8 @@ defmodule EthersTest do assert {:ok, [ - ok: %Ethers.Transaction.SignedTransaction{ - transaction: %Ethers.Transaction.Eip1559{} + ok: %Ethers.Transaction.Signed{ + payload: %Ethers.Transaction.Eip1559{} } ]} = Ethers.batch([ @@ -464,8 +473,7 @@ defmodule EthersTest do wait_for_transaction!(tx_hash) - assert {:ok, - %Ethers.Transaction.SignedTransaction{transaction: %Ethers.Transaction.Legacy{}}} = + assert {:ok, %Ethers.Transaction.Signed{payload: %Ethers.Transaction.Legacy{}}} = Ethers.get_transaction(tx_hash) assert {:ok, "hello local signer"} =