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

Decode transaction body #76

Merged
merged 13 commits into from
Jan 30, 2024
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@

- Add `Ethers.get_transaction_receipt/2` function to query native chain transaction receipt by transaction hash.

### Enhancements

- Add more metadata to `Ethers.Transaction` struct.
- Return `Ethers.Transaction` struct in `Ethers.get_transaction/2` function.
- Support `get_transaction` in batch requests.

## v0.2.2 (2023-01-08)

### New features
Expand Down
7 changes: 6 additions & 1 deletion lib/ethers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ defmodule Ethers do
gas_price: :eth_gas_price,
get_logs: :eth_get_logs,
get_transaction_count: :eth_get_transaction_count,
get_transaction: :eth_get_transaction_by_hash,
send: :eth_send_transaction
}

Expand Down Expand Up @@ -146,7 +147,7 @@ defmodule Ethers do
- rpc_opts: Specific RPC options to specify for this request.
"""
@spec get_transaction(Types.t_hash(), Keyword.t()) ::
{:ok, map()} | {:error, term()}
{:ok, Transaction.t()} | {:error, term()}
def get_transaction(tx_hash, opts \\ []) when is_binary(tx_hash) do
{rpc_client, rpc_opts} = get_rpc_client(opts)

Expand Down Expand Up @@ -664,6 +665,10 @@ defmodule Ethers do
defp post_process({:ok, nil}, _tx_hash, :get_transaction),
do: {:error, :transaction_not_found}

defp post_process({:ok, tx_data}, _tx_hash, :get_transaction) do
Transaction.from_map(tx_data)
end

defp post_process({:ok, nil}, _tx_hash, :get_transaction_receipt),
do: {:error, :transaction_receipt_not_found}

Expand Down
151 changes: 127 additions & 24 deletions lib/ethers/transaction.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,16 @@ defmodule Ethers.Transaction do
access_list: [],
signature_r: nil,
signature_s: nil,
signature_recovery_id: nil
signature_v: nil,
signature_recovery_id: nil,
signature_y_parity: nil,
block_hash: nil,
block_number: nil,
hash: nil,
transaction_index: nil
]

@type t_transaction_type :: :legacy | :eip1559
@type t_transaction_type :: :legacy | :eip1559 | :eip2930 | :eip4844
@type t :: %__MODULE__{
type: t_transaction_type(),
chain_id: binary() | nil,
Expand All @@ -41,14 +47,37 @@ defmodule Ethers.Transaction do
access_list: [{binary(), [binary()]}],
signature_r: binary() | nil,
signature_s: binary() | nil,
signature_recovery_id: 0 | 1 | nil
signature_v: binary() | non_neg_integer() | nil,
signature_y_parity: binary() | non_neg_integer() | nil,
signature_recovery_id: binary() | 0 | 1 | nil,
block_hash: binary() | nil,
block_number: binary() | nil,
hash: binary() | nil,
transaction_index: binary() | nil
}

@common_fillable_params [:chain_id, :nonce]
@type_fillable_params %{
legacy: [:gas_price],
eip1559: [:max_fee_per_gas]
}
@integer_type_values [
:block_number,
:chain_id,
:gas,
:gas_price,
:max_fee_per_gas,
:max_priority_fee_per_gas,
:nonce,
:signature_recovery_id,
:signature_y_parity,
:signature_v,
:transaction_index,
:value
]
@binary_type_values [:data, :signature_r, :signature_s]

defguardp has_value(v) when not is_nil(v) and v != "" and v != "0x"

def new(params, type \\ :eip1559) do
struct!(__MODULE__, Map.put(params, :type, type))
Expand Down Expand Up @@ -84,7 +113,6 @@ defmodule Ethers.Transaction do
tx.value,
tx.data
]
|> Enum.map(&(&1 || ""))
|> maybe_add_signature(tx)
|> convert_to_binary()
|> ExRLP.encode()
Expand All @@ -102,13 +130,47 @@ defmodule Ethers.Transaction do
tx.data,
tx.access_list
]
|> Enum.map(&(&1 || ""))
|> maybe_add_signature(tx)
|> convert_to_binary()
|> ExRLP.encode()
|> then(&(<<2>> <> &1))
end

def encode(%{type: type}) do
raise "Ethers does not support encoding of #{inspect(type)} transactions"
end

def from_map(tx) do
with {:ok, tx_type} <- decode_tx_type(from_map_value(tx, :type)) do
tx_struct =
%{
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),
data: from_map_value(tx, :input),
from: from_map_value(tx, :from),
gas: from_map_value(tx, :gas),
gas_price: from_map_value(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_v: from_map_value(tx, :v),
signature_recovery_id: from_map_value(tx, :v),
signature_y_parity: from_map_value(tx, :yParity),
to: from_map_value(tx, :to),
transaction_index: from_map_value(tx, :transactionIndex),
value: from_map_value(tx, :value)
}
|> new(tx_type)

{:ok, tx_struct}
end
end

def to_map(%{type: :eip1559} = tx) do
%{
from: tx.from,
Expand All @@ -134,26 +196,26 @@ defmodule Ethers.Transaction do
}
end

@doc """
Decodes a transaction struct values in a new map.
"""
@spec decode_values(t()) :: map()
def decode_values(%__MODULE__{} = tx) do
tx
|> Map.from_struct()
|> Map.new(fn
{k, nil} -> {k, nil}
{k, ""} -> {k, nil}
{k, v} when k in @integer_type_values -> {k, Utils.hex_to_integer!(v)}
{k, v} when k in @binary_type_values -> {k, Utils.hex_decode!(v)}
{k, v} -> {k, v}
end)
end

defp maybe_add_signature(tx_list, tx) do
case tx do
%{signature_r: r, signature_s: s, signature_recovery_id: rec_id} when not is_nil(r) ->
y_parity =
case tx do
%{type: :legacy, chain_id: chain_id} when not is_nil(chain_id) ->
# EIP-155
chain_id = Ethers.Utils.hex_to_integer!(chain_id)
rec_id + 35 + chain_id * 2

%{type: :legacy} ->
# EIP-155
rec_id + 27

_ ->
# EIP-1559
rec_id
end

tx_list ++ [y_parity, trim_leading(r), trim_leading(s)]
%{signature_r: r, signature_s: s} when has_value(r) and has_value(s) ->
tx_list ++ [get_y_parity(tx), trim_leading(r), trim_leading(s)]

%{type: :legacy, chain_id: chain_id} when not is_nil(chain_id) ->
# EIP-155 encoding for signature mitigation intra-chain replay attack
Expand Down Expand Up @@ -197,17 +259,58 @@ defmodule Ethers.Transaction do
Enum.map(list, fn
"0x" <> _ = bin ->
bin
|> Ethers.Utils.hex_decode!()
|> Utils.hex_decode!()
|> trim_leading()

l when is_list(l) ->
convert_to_binary(l)

nil ->
""

item ->
item
end)
end

defp get_y_parity(%{signature_y_parity: y_parity}) when has_value(y_parity) do
y_parity
end

defp get_y_parity(%{signature_recovery_id: rec_id} = tx) when has_value(rec_id) do
case tx do
%{type: :legacy, chain_id: chain_id} when has_value(chain_id) ->
# EIP-155
chain_id = Utils.hex_to_integer!(chain_id)
rec_id + 35 + chain_id * 2

%{type: :legacy} ->
# EIP-155
rec_id + 27

_ ->
# EIP-1559
rec_id
end
end

defp get_y_parity(%{type: :legacy, signature_v: v}) when has_value(v), do: v

defp trim_leading(<<0, rest::binary>>), do: trim_leading(rest)
defp trim_leading(<<bin::binary>>), do: bin

defp decode_tx_type(type) do
case type do
"0x3" -> {:ok, :eip4844}
"0x2" -> {:ok, :eip1559}
"0x1" -> {:ok, :eip2930}
"0x0" -> {:ok, :legacy}
nil -> {:ok, :legacy}
_ -> {:error, :unsupported_tx_type}
end
end

defp from_map_value(tx, key) do
Map.get_lazy(tx, key, fn -> Map.get(tx, to_string(key)) end)
end
end
69 changes: 69 additions & 0 deletions test/ethers/transaction_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
defmodule Ethers.TransactionTest do
use ExUnit.Case

alias Ethers.Transaction

@transaction_fixture %Ethers.Transaction{
type: :eip1559,
chain_id: "0x539",
nonce: "0x516",
gas: "0x5d30",
from: "0x90f8bf6a479f320ead074411a4b0e7944ea8c9c1",
to: "0x95ced938f7991cd0dfcb48f0a06a40fa1af46ebc",
value: "0x0",
data:
"0x435ffe940000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001268656c6c6f206c6f63616c207369676e65720000000000000000000000000000",
gas_price: "0x8",
max_fee_per_gas: "0x8f0d1800",
max_priority_fee_per_gas: "0x0",
access_list: [],
signature_r: "0x639e5b615f34498f3e5a03f4831e4b7a2a1d5b61ed1388181ef7689c01466fc3",
signature_s: "0x34a9311fae88125c4f9df5d0ed61f8e37bbaf62681f3ce96d03899114df8997",
signature_recovery_id: "0x1",
signature_y_parity: "0x1",
signature_v: "0x1",
block_hash: "0xa2b720a9653afd26411e9bc94283cc496cd3d763378a67fd645bf1a4e332f37d",
block_number: "0x595",
hash: "0xdc78c7e7ea3a5980f732e466daf1fdc4f009e973530d7e84f0b2012f1ff2cfc7",
transaction_index: "0x0"
}

describe "decode_values/1" do
test "decodes the transaction values to correct types" do
decoded = Transaction.decode_values(@transaction_fixture)

assert %{
type: :eip1559,
value: 0,
to: "0x95ced938f7991cd0dfcb48f0a06a40fa1af46ebc",
hash: "0xdc78c7e7ea3a5980f732e466daf1fdc4f009e973530d7e84f0b2012f1ff2cfc7",
from: "0x90f8bf6a479f320ead074411a4b0e7944ea8c9c1",
gas: 23_856,
block_number: 1429,
gas_price: 8,
max_fee_per_gas: 2_400_000_000,
chain_id: 1337,
nonce: 1302,
block_hash: "0xa2b720a9653afd26411e9bc94283cc496cd3d763378a67fd645bf1a4e332f37d",
transaction_index: 0,
max_priority_fee_per_gas: 0,
access_list: [],
signature_recovery_id: 1,
signature_y_parity: 1,
signature_v: 1
} = decoded

assert is_binary(decoded.data)
assert is_binary(decoded.signature_r)
assert is_binary(decoded.signature_s)
end

test "does not fail with missing values" do
assert %{signature_recovery_id: nil} =
Transaction.decode_values(%{@transaction_fixture | signature_recovery_id: nil})

assert %{signature_recovery_id: nil} =
Transaction.decode_values(%{@transaction_fixture | signature_recovery_id: ""})
end
end
end
29 changes: 25 additions & 4 deletions test/ethers_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,34 @@ defmodule EthersTest do
downcased_to_addr = String.downcase(@to)

assert {:ok,
%{
"hash" => ^tx_hash,
"from" => @from,
"to" => ^downcased_to_addr
%Ethers.Transaction{
hash: ^tx_hash,
from: @from,
to: ^downcased_to_addr
}} = Ethers.get_transaction(tx_hash)
end

test "works in batch requests" do
{:ok, tx_hash} =
HelloWorldContract.set_hello("hello local signer")
|> Ethers.send(
from: @from,
to: @to,
signer: Ethers.Signer.Local,
signer_opts: [
private_key: "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d"
]
)

assert {:ok,
[
ok: %Ethers.Transaction{hash: ^tx_hash}
]} =
Ethers.batch([
{:get_transaction, tx_hash}
])
end

test "returns error by non-existent tx_hash" do
assert {:error, :transaction_not_found} =
Ethers.get_transaction(
Expand Down
Loading