diff --git a/CHANGELOG.md b/CHANGELOG.md index f0662ed..0f111c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Support [EIP-1191](https://eips.ethereum.org/EIPS/eip-1191): Add chain id to mixed-case checksum address encoding - Add [EIP-4844](https://eips.ethereum.org/EIPS/eip-4844) transaction support +- Add [EIP-2930](https://eips.ethereum.org/EIPS/eip-2930) transaction support ## v0.6.1 (2025-01-02) diff --git a/lib/ethers/transaction.ex b/lib/ethers/transaction.ex index 09745c9..9fd86e1 100644 --- a/lib/ethers/transaction.ex +++ b/lib/ethers/transaction.ex @@ -9,12 +9,33 @@ defmodule Ethers.Transaction do """ alias Ethers.Transaction.Eip1559 + alias Ethers.Transaction.Eip2930 alias Ethers.Transaction.Eip4844 alias Ethers.Transaction.Legacy alias Ethers.Transaction.Protocol, as: TxProtocol alias Ethers.Transaction.Signed alias Ethers.Utils + @default_transaction_types [Eip1559, Eip2930, Eip4844, Legacy] + + @transaction_types Application.compile_env( + :ethers, + :transaction_types, + @default_transaction_types + ) + + @default_transaction_type Eip1559 + + @rpc_fields %{ + access_list: :accessList, + blob_versioned_hashes: :blobVersionedHashes, + chain_id: :chainId, + gas_price: :gasPrice, + max_fee_per_blob_gas: :maxFeePerBlobGas, + max_fee_per_gas: :maxFeePerGas, + max_priority_fee_per_gas: :maxPriorityFeePerGas + } + @typedoc """ EVM Transaction type """ @@ -23,7 +44,12 @@ defmodule Ethers.Transaction do @typedoc """ EVM Transaction payload type """ - @type t_payload :: Eip4844.t() | Eip1559.t() | Legacy.t() + @type t_payload :: + unquote( + @transaction_types + |> Enum.map(&{{:., [], [{:__aliases__, [alias: false], [&1]}, :t]}, [], []}) + |> Enum.reduce(&{:|, [], [&1, &2]}) + ) @doc "Creates a new transaction struct with the given parameters." @callback new(map()) :: {:ok, t()} | {:error, reason :: atom()} @@ -41,24 +67,6 @@ defmodule Ethers.Transaction do @callback from_rlp_list([binary() | [binary()]]) :: {:ok, t(), rest :: [binary() | [binary()]]} | {:error, reason :: term()} - @default_transaction_type Eip1559 - - @transaction_type_modules Application.compile_env(:ethers, :transaction_types, [ - Eip4844, - Eip1559, - Legacy - ]) - - @rpc_fields %{ - access_list: :accessList, - blob_versioned_hashes: :blobVersionedHashes, - chain_id: :chainId, - gas_price: :gasPrice, - max_fee_per_blob_gas: :maxFeePerBlobGas, - max_fee_per_gas: :maxFeePerGas, - max_priority_fee_per_gas: :maxPriorityFeePerGas - } - @doc """ Creates a new transaction struct with the given parameters. @@ -72,7 +80,7 @@ defmodule Ethers.Transaction do @spec new(map()) :: {:ok, t()} | {:error, reason :: term()} def new(params) do case Map.fetch(params, :type) do - {:ok, type} when type in @transaction_type_modules -> + {:ok, type} when type in @transaction_types -> input = params |> Map.get(:input, Map.get(params, :data)) @@ -190,7 +198,7 @@ defmodule Ethers.Transaction do end end - Enum.each(@transaction_type_modules, fn module -> + Enum.each(@transaction_types, fn module -> type_envelope = module.type_envelope() defp decode_transaction_data(<>) do @@ -375,7 +383,7 @@ defmodule Ethers.Transaction do defp decode_type("0x" <> _ = type), do: decode_type(Utils.hex_decode!(type)) - Enum.each(@transaction_type_modules, fn module -> + Enum.each(@transaction_types, fn module -> type_envelope = module.type_envelope() defp decode_type(unquote(type_envelope)), do: {:ok, unquote(module)} end) diff --git a/lib/ethers/transaction/eip2930.ex b/lib/ethers/transaction/eip2930.ex new file mode 100644 index 0000000..579a00e --- /dev/null +++ b/lib/ethers/transaction/eip2930.ex @@ -0,0 +1,126 @@ +defmodule Ethers.Transaction.Eip2930 do + @moduledoc """ + Transaction struct and protocol implementation for Ethereum Improvement Proposal (EIP) 2930 + transactions. EIP-2930 introduced a new transaction type that includes an access list, + allowing transactions to pre-specify and pre-pay for account and storage access to mitigate + gas cost changes from EIP-2929 and prevent contract breakage. The access list format also + enables future use cases like block-wide witnesses and static state access patterns. + + See: https://eips.ethereum.org/EIPS/eip-2930 + """ + + alias Ethers.Types + alias Ethers.Utils + + @behaviour Ethers.Transaction + + @type_id 1 + + @enforce_keys [:chain_id, :nonce, :gas_price, :gas] + defstruct [ + :chain_id, + :nonce, + :gas_price, + :gas, + :to, + :value, + :input, + access_list: [] + ] + + @typedoc """ + A transaction type following EIP-2930 (Type-1) 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 + - `gas_price`: Price willing to pay for each unit of gas (in wei) + - `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(), + gas_price: non_neg_integer(), + gas: non_neg_integer(), + to: Types.t_address() | nil, + value: non_neg_integer(), + input: binary(), + access_list: [{binary(), [binary()]}] + } + + @impl Ethers.Transaction + def new(params) do + to = params[:to] + + {:ok, + %__MODULE__{ + chain_id: params.chain_id, + nonce: params.nonce, + gas_price: params.gas_price, + gas: params.gas, + to: to && Utils.to_checksum_address(to), + value: params[:value] || 0, + input: params[:input] || params[:data] || "", + access_list: params[:access_list] || [] + }} + end + + @impl Ethers.Transaction + def auto_fetchable_fields do + [:chain_id, :nonce, :gas_price, :gas] + end + + @impl Ethers.Transaction + def type_envelope, do: <> + + @impl Ethers.Transaction + def type_id, do: @type_id + + @impl Ethers.Transaction + def from_rlp_list([ + chain_id, + nonce, + gas_price, + gas, + to, + value, + input, + access_list | rest + ]) do + {:ok, + %__MODULE__{ + chain_id: :binary.decode_unsigned(chain_id), + 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: 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() + + def type_envelope(_transaction), do: @for.type_envelope() + + def to_rlp_list(tx, _mode) do + # Eip2930 does not discriminate in RLP encoding between payload and hash + [ + tx.chain_id, + tx.nonce, + tx.gas_price, + tx.gas, + (tx.to && Utils.decode_address!(tx.to)) || "", + tx.value, + tx.input, + tx.access_list || [] + ] + end + end +end diff --git a/test/ethers/transaction_test.exs b/test/ethers/transaction_test.exs index 7575f84..37dc387 100644 --- a/test/ethers/transaction_test.exs +++ b/test/ethers/transaction_test.exs @@ -62,7 +62,7 @@ defmodule Ethers.TransactionTest do end describe "decode/1" do - test "decodes raw EIP-4844 transaction correctly" do + test "decodes raw EIP-4844 transaction and re-encodes it correctly" do raw_tx = "0x03f9043c01830b3444847d2b75008519a4418ab283036fd5941c479675ad559dc151f6ec7ed3fbf8cee79582b680b8a43e5aa08200000000000000000000000000000000000000000000000000000000000bfc5200000000000000000000000000000000000000000000000000000000001bd614000000000000000000000000e64a54e2533fd126c2e452c5fab544d80e2e4eb500000000000000000000000000000000000000000000000000000000101868220000000000000000000000000000000000000000000000000000000010186a47f902c0f8dd941c479675ad559dc151f6ec7ed3fbf8cee79582b6f8c6a00000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000000aa0b53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103a0360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbca0a10aa54071443520884ed767b0684edf43acec528b7da83ab38ce60126562660f90141948315177ab297ba92a06054ce80a67ed4dbd7ed3af90129a00000000000000000000000000000000000000000000000000000000000000006a00000000000000000000000000000000000000000000000000000000000000007a00000000000000000000000000000000000000000000000000000000000000009a0000000000000000000000000000000000000000000000000000000000000000aa0b53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103a0360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbca0a66cc928b5edb82af9bd49922954155ab7b0942694bea4ce44661d9a8742c2d9a0a66cc928b5edb82af9bd49922954155ab7b0942694bea4ce44661d9a8742c2daa0f652222313e28459528d920b65115c16c04f3efc82aaedc97be59f3f3797e352f89b94e64a54e2533fd126c2e452c5fab544d80e2e4eb5f884a00000000000000000000000000000000000000000000000000000000000000004a00000000000000000000000000000000000000000000000000000000000000005a0e85fd79f89ff278fc57d40aecb7947873df9f0beac531c8f71a98f630e1eab62a07686888b19bb7b75e46bb1aa328b65150743f4899443d722f0adf8e252ccda410af863a001e74519daf1b03d40e76d557588db2e9b21396f7aeb6086bd794cc4357083efa00169766b1aff3508331a39e7081e591a3ff3bacf957788571269797db7ff3ccca0017045639ffe91febe66cc4427fcf6331980dd9a0dab4af3e81c5514b918ed6180a036a73bf3fe4b9a375c2564b2b1a4a795c82b3923225af0a2ab5d7a561b0c4b92a0366ac3b831ece20f95d1eac369b1c8d4c2c5ac730655d89c005fe310d1db2086" @@ -145,9 +145,11 @@ defmodule Ethers.TransactionTest do "0x0169766b1aff3508331a39e7081e591a3ff3bacf957788571269797db7ff3ccc", "0x017045639ffe91febe66cc4427fcf6331980dd9a0dab4af3e81c5514b918ed61" ] + + assert Ethers.Utils.hex_encode(Transaction.encode(decoded_tx)) == raw_tx end - test "decodes raw EIP-1559 transaction correctly" do + test "decodes raw EIP-1559 transaction and re-encodes it correctly" do raw_tx = "0x02f8af0177837a12008502c4bfbc3282f88c948881562783028f5c1bcb985d2283d5e170d8888880b844a9059cbb0000000000000000000000002ef7f5c7c727d8845e685f462a5b4f8ac4972a6700000000000000000000000000000000000000000000051ab2ea6fbbb7420000c001a007280557e86f690290f9ea9e26cc17e0cf09a17f6c2d041e95b33be4b81888d0a06c7a24e8fba5cceb455b19950849b9733f0deb92d7e8c2a919f4a82df9c6036a" @@ -172,9 +174,40 @@ defmodule Ethers.TransactionTest do assert decoded_tx.payload.max_priority_fee_per_gas == 8_000_000 assert decoded_tx.payload.to == "0x8881562783028F5c1BCB985d2283D5E170D88888" assert decoded_tx.payload.value == 0 + + assert Ethers.Utils.hex_encode(Transaction.encode(decoded_tx)) == raw_tx end - test "decodes raw legacy transaction correctly" do + test "decodes raw EIP-2930 transaction and re-encodes it correctly" do + raw_tx = + "0x01f903640182dd688503a656ac80830623c4944a137fd5e7a256ef08a7de531a17d0be0cc7b6b680b901a46dbf2fa0000000000000000000000000e592427a0aece92de3edee1f18e0157c05861564000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000104414bf3890000000000000000000000007d1afa7b718fb893db30a3abc0cfc608aacfebb0000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000bb80000000000000000000000004a137fd5e7a256ef08a7de531a17d0be0cc7b6b60000000000000000000000000000000000000000000000000000000060bda78e0000000000000000000000000000000000000000000000cc223b921be6800000000000000000000000000000000000000000000000000000000000017dd4e6ca000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f90153f87a9407a6e955ba4345bae83ac2a6faa771fddd8a2011f863a00000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000008f87a947d1afa7b718fb893db30a3abc0cfc608aacfebb0f863a014d5312942240e565c56aec11806ce58e3c0e38c96269d759c5d35a2a2e4a449a02701fd0b2638f33db225d91c6adbdad46590a86a09a2b2c386405c2f742af842a037b0b82ee5d8a88672df3895a46af48bbcd30d6efcc908136e29456fa30604bbf85994a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48f842a037570cf18c6d95744a154fa2b19b7e958c78ef68b8c60a80dc527fc15e2ceb8fa06e89d31e3fd8d2bf0b411c458e98c7463bf723878c3ce8a845bcf9dc3b2e391780a01d40605de92c503219631e625ca0d023df8dfef9058896804fb1952d386b64e1a00e0ec0714b7956fe29820cb62998936b78ca4b8a3b05291db90e475244d5c63f" + + expected_from = "0x005FdE5294199d5C3Eb5Eb7a6E51954123b74b1c" + expected_hash = "0xdb32a678b6c5855eb3c5ff47513e136a85a391469755d045d8846e37fc99d774" + + assert {:ok, decoded_tx} = Transaction.decode(raw_tx) + assert %Transaction.Signed{payload: %Transaction.Eip2930{}} = 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 == 402_372 + assert decoded_tx.payload.gas_price == 15_675_600_000 + assert decoded_tx.payload.nonce == 56_680 + assert decoded_tx.payload.to == "0x4A137FD5e7a256eF08A7De531A17D0BE0cc7B6b6" + assert decoded_tx.payload.value == 0 + assert Enum.count(decoded_tx.payload.access_list) == 3 + + assert Ethers.Utils.hex_encode(Transaction.encode(decoded_tx)) == raw_tx + end + + test "decodes raw legacy transaction and re-encodes it correctly" do raw_tx = "0xf86c81c6850c92a69c0082520894e48c9a989438606a79a7560cfba3d34bafbac38e87596f744abf34368025a0ee0b54a64cf8130e36cd1d19395d6d434c285c832a7908873a24610ec32896dfa070b5e779cdcaf5c661c1df44e80895f6ab68463d3ede2cf4955855bc3c6edebb" @@ -198,6 +231,8 @@ defmodule Ethers.TransactionTest do 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 + + assert Ethers.Utils.hex_encode(Transaction.encode(decoded_tx)) == raw_tx end end end