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

Implement Transaction.decode/1 #166

Merged
merged 8 commits into from
Dec 19, 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
8 changes: 5 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion lib/ethers/signer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
Expand Down
10 changes: 5 additions & 5 deletions lib/ethers/signer/local.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
171 changes: 125 additions & 46 deletions lib/ethers/transaction.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
Expand All @@ -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,
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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(<<unquote(type_envelope)::binary, rest::binary>>) 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.

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
45 changes: 44 additions & 1 deletion lib/ethers/transaction/eip1559.ex
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(),
Expand Down Expand Up @@ -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()

Expand Down
Loading
Loading