Skip to content

Commit

Permalink
Implement Transaction.decode/1 (#166)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
alisinabh authored Dec 19, 2024
1 parent 9a90ff4 commit 7f8f091
Show file tree
Hide file tree
Showing 13 changed files with 493 additions and 130 deletions.
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

0 comments on commit 7f8f091

Please sign in to comment.