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

Block Validation: New Checks #1693

Merged
merged 18 commits into from
Aug 21, 2020
102 changes: 100 additions & 2 deletions apps/omg_watcher/lib/omg_watcher/block_validator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,24 @@ defmodule OMG.Watcher.BlockValidator do
alias OMG.Block
alias OMG.Merkle
alias OMG.State.Transaction
alias OMG.Utxo

@transaction_upper_limit 2 |> :math.pow(16) |> Kernel.trunc()

@doc """
Executes stateless validation of a submitted block:
- Verifies that the number of transactions falls within the accepted range.
- Verifies that transactions are correctly formed.
- Verifies that there are no duplicate inputs at the block level.
- Verifies that given Merkle root matches reconstructed Merkle root.
- Verifies that fee transactions are correctly placed, calculated and unique per currency.
kalouo marked this conversation as resolved.
Show resolved Hide resolved
"""
@spec stateless_validate(Block.t()) :: {:ok, boolean()} | {:error, atom()}
def stateless_validate(submitted_block) do
with {:ok, recovered_transactions} <- verify_transactions(submitted_block.transactions),
with :ok <- number_of_transactions_within_limit(submitted_block.transactions),
{:ok, recovered_transactions} <- verify_transactions(submitted_block.transactions),
{:ok, _fee_transactions} <- verify_fee_transactions(recovered_transactions),
{:ok, _inputs} <- verify_no_duplicate_inputs(recovered_transactions),
{:ok, _block} <- verify_merkle_root(submitted_block, recovered_transactions) do
{:ok, true}
end
Expand All @@ -48,7 +57,7 @@ defmodule OMG.Watcher.BlockValidator do
end
end

@spec verify_transactions(transactions :: list(Transaction.Recovered.t())) ::
@spec verify_transactions(transactions :: list(Transaction.Signed.tx_bytes())) ::
{:ok, list(Transaction.Recovered.t())}
| {:error, Transaction.Recovered.recover_tx_error()}
defp verify_transactions(transactions) do
kalouo marked this conversation as resolved.
Show resolved Hide resolved
Expand All @@ -64,4 +73,93 @@ defmodule OMG.Watcher.BlockValidator do
end
end)
end

@spec number_of_transactions_within_limit([Transaction.Signed.tx_bytes()]) ::
:ok | {:error, atom()}
defp number_of_transactions_within_limit(transactions) do
kalouo marked this conversation as resolved.
Show resolved Hide resolved
case length(transactions) do
# A block with a merge transaction only would have no fee transaction.
0 ->
{:error, :empty_block}

n when n > @transaction_upper_limit ->
{:error, :transactions_exceed_block_limit}

_ ->
:ok
end
end

@spec verify_no_duplicate_inputs([Transaction.Recovered.t()]) :: {:ok, map()}
defp verify_no_duplicate_inputs(transactions) do
Enum.reduce_while(transactions, {:ok, %{}}, fn tx, {:ok, input_set} ->
kalouo marked this conversation as resolved.
Show resolved Hide resolved
tx
kalouo marked this conversation as resolved.
Show resolved Hide resolved
|> Map.get(:signed_tx)
|> Map.get(:raw_tx)
|> Map.get(:inputs, [])
# Setting an empty array as default because fee transactions will not have an `input` key.
|> scan_for_duplicates({:cont, {:ok, input_set}})
end)
end

# Nested reducer executing duplicate verification logic for `verify_no_duplicate_inputs/1`
@spec scan_for_duplicates(Transaction.Recovered.t(), {:cont, {:ok, map()}}) :: {:cont, {:ok, map()}}
defp scan_for_duplicates(tx_input_set, {:cont, {:ok, input_set}}) do
Enum.reduce_while(tx_input_set, {:cont, {:ok, input_set}}, fn input, {:cont, {:ok, input_set}} ->
kalouo marked this conversation as resolved.
Show resolved Hide resolved
input_position = Utxo.Position.encode(input)

case Map.has_key?(input_set, input_position) do
true -> {:halt, {:halt, {:error, :block_duplicate_inputs}}}
false -> {:cont, {:cont, {:ok, Map.put(input_set, input_position, true)}}}
end
end)
end

@spec verify_fee_transactions([Transaction.Recovered.t()]) :: {:ok, [Transaction.Recovered.t()]} | {:error, atom()}
defp verify_fee_transactions(transactions) do
identified_fee_transactions = Enum.filter(transactions, &is_fee/1)

with :ok <- expected_index(transactions, identified_fee_transactions),
:ok <- unique_fee_transaction_per_currency(identified_fee_transactions) do
{:ok, identified_fee_transactions}
end
end

@spec expected_index([Transaction.Recovered.t()], [Transaction.Recovered.t()]) :: :ok | {:error, atom()}
defp expected_index(transactions, identified_fee_transactions) do
number_of_fee_txs = length(identified_fee_transactions)
tail = Enum.slice(transactions, -number_of_fee_txs, number_of_fee_txs)

case identified_fee_transactions do
^tail -> :ok
_ -> {:error, :unexpected_transaction_type_at_fee_index}
end
end

@spec unique_fee_transaction_per_currency([Transaction.Recovered.t()]) :: :ok | {:error, atom()}
defp unique_fee_transaction_per_currency(identified_fee_transactions) do
identified_fee_transactions
|> Enum.uniq_by(fn fee_transaction -> fee_transaction |> get_fee_output() |> Map.get(:currency) end)
|> case do
^identified_fee_transactions -> :ok
_ -> {:error, :duplicate_fee_transaction_for_ccy}
end
end

defp is_fee(%Transaction.Recovered{signed_tx: %Transaction.Signed{raw_tx: %Transaction.Fee{}}}),
do: true
kalouo marked this conversation as resolved.
Show resolved Hide resolved

defp is_fee(_), do: false

defp get_fee_output(fee_transaction) do
kalouo marked this conversation as resolved.
Show resolved Hide resolved
case fee_transaction do
%Transaction.Recovered{
signed_tx: %Transaction.Signed{raw_tx: %Transaction.Fee{outputs: [fee_output]}}
} ->
fee_output

_ ->
{:error, :malformed_fee_transaction}
end
end
end
129 changes: 119 additions & 10 deletions apps/omg_watcher/test/omg_watcher/block_validator_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,14 @@ defmodule OMG.WatcherRPC.Web.Validator.BlockValidatorTest do

@alice TestHelper.generate_entity()
@bob TestHelper.generate_entity()

@eth OMG.Eth.zero_address()

@payment_tx_type OMG.WireFormatTypes.tx_type_for(:tx_payment_v1)

@fee_claimer <<27::160>>
@transaction_upper_limit 2 |> :math.pow(16) |> Kernel.trunc()

describe "stateless_validate/1" do
test "returns error if a transaction is not correctly formed (e.g. duplicate inputs)" do
kalouo marked this conversation as resolved.
Show resolved Hide resolved
input_1 = {1, 0, 0, @alice}
Expand Down Expand Up @@ -67,18 +72,14 @@ defmodule OMG.WatcherRPC.Web.Validator.BlockValidatorTest do

test "accepts correctly formed transactions" do
recovered_tx_1 = TestHelper.create_recovered([{1, 0, 0, @alice}, {2, 0, 0, @alice}], @eth, [{@bob, 10}])

recovered_tx_2 = TestHelper.create_recovered([{3, 0, 0, @alice}, {4, 0, 0, @alice}], @eth, [{@bob, 10}])

signed_txbytes_1 = recovered_tx_1.signed_tx_bytes
signed_txbytes_2 = recovered_tx_2.signed_tx_bytes

merkle_root =
[recovered_tx_1, recovered_tx_2]
|> Enum.map(&Transaction.raw_txbytes/1)
|> Merkle.hash()

block = %{
hash: merkle_root,
hash: derive_merkle_root([recovered_tx_1, recovered_tx_2]),
number: 1000,
transactions: [signed_txbytes_1, signed_txbytes_2]
}
Expand Down Expand Up @@ -107,10 +108,7 @@ defmodule OMG.WatcherRPC.Web.Validator.BlockValidatorTest do

signed_txbytes = Enum.map([recovered_tx_1, recovered_tx_2], fn tx -> tx.signed_tx_bytes end)

valid_merkle_root =
[recovered_tx_1, recovered_tx_2]
|> Enum.map(&Transaction.raw_txbytes/1)
|> Merkle.hash()
valid_merkle_root = derive_merkle_root([recovered_tx_1, recovered_tx_2])

block = %{
hash: valid_merkle_root,
Expand All @@ -120,5 +118,116 @@ defmodule OMG.WatcherRPC.Web.Validator.BlockValidatorTest do

assert {:ok, true} = BlockValidator.stateless_validate(block)
end

test "rejects a block with fewer than two transactions or more transactions than the defined limit" do
kalouo marked this conversation as resolved.
Show resolved Hide resolved
oversize_block = %{
hash: "0x0",
number: 1000,
transactions: List.duplicate("0x0", @transaction_upper_limit + 1)
}

undersize_block = %{
hash: "0x0",
number: 1000,
transactions: []
}

assert {:error, :transactions_exceed_block_limit} = BlockValidator.stateless_validate(oversize_block)

assert {:error, :empty_block} = BlockValidator.stateless_validate(undersize_block)
end

test "rejects a block that uses the same input in different transactions" do
duplicate_input = {1, 0, 0, @alice}

recovered_tx_1 = TestHelper.create_recovered([duplicate_input], @eth, [{@bob, 10}])
recovered_tx_2 = TestHelper.create_recovered([duplicate_input], @eth, [{@bob, 10}])

signed_txbytes_1 = recovered_tx_1.signed_tx_bytes
signed_txbytes_2 = recovered_tx_2.signed_tx_bytes

block = %{
hash: derive_merkle_root([recovered_tx_1, recovered_tx_2]),
number: 1000,
transactions: [signed_txbytes_1, signed_txbytes_2]
}

assert {:error, :block_duplicate_inputs} == BlockValidator.stateless_validate(block)
end
end

describe "stateless_validate/1 (fee validation)" do
test "rejects a block if there are multiple fee transactions of the same currency" do
input_1 = {1, 0, 0, @alice}
input_2 = {2, 0, 0, @alice}

payment_tx_1 = TestHelper.create_recovered([input_1], @eth, [{@bob, 10}])
payment_tx_2 = TestHelper.create_recovered([input_2], @eth, [{@bob, 10}])
fee_tx_1 = TestHelper.create_recovered_fee_tx(1, @fee_claimer, @eth, 1)
fee_tx_2 = TestHelper.create_recovered_fee_tx(1, @fee_claimer, @eth, 1)

signed_txbytes = Enum.map([payment_tx_1, payment_tx_2, fee_tx_1, fee_tx_2], fn tx -> tx.signed_tx_bytes end)

block = %{
hash: derive_merkle_root([payment_tx_1, payment_tx_2, fee_tx_1, fee_tx_2]),
number: 1000,
transactions: signed_txbytes
}

assert {:error, :duplicate_fee_transaction_for_ccy} = BlockValidator.stateless_validate(block)
end

test "rejects a block if fee transactions are not at the tail of the transactions' list (one fee currency)" do
input_1 = {1, 0, 0, @alice}
input_2 = {2, 0, 0, @alice}

payment_tx_1 = TestHelper.create_recovered([input_1], @eth, [{@bob, 10}])
payment_tx_2 = TestHelper.create_recovered([input_2], @eth, [{@bob, 10}])
fee_tx = TestHelper.create_recovered_fee_tx(1, @fee_claimer, @eth, 5)

invalid_ordered_transactions = [payment_tx_1, fee_tx, payment_tx_2]
signed_txbytes = Enum.map(invalid_ordered_transactions, fn tx -> tx.signed_tx_bytes end)

block = %{
hash: derive_merkle_root(invalid_ordered_transactions),
number: 1000,
transactions: signed_txbytes
}

assert {:error, :unexpected_transaction_type_at_fee_index} = BlockValidator.stateless_validate(block)
end

test "rejects a block if fee transactions are not at the tail of the transactions' list (two fee currencies)" do
thec00n marked this conversation as resolved.
Show resolved Hide resolved
ccy_1 = @eth
ccy_2 = <<1::160>>

ccy_1_fee = 1
ccy_2_fee = 1

input_1 = {1, 0, 0, @alice}
input_2 = {2, 0, 0, @alice}

payment_tx_1 = TestHelper.create_recovered([input_1], ccy_1, [{@bob, 10}])
payment_tx_2 = TestHelper.create_recovered([input_2], ccy_2, [{@bob, 10}])

fee_tx_1 = TestHelper.create_recovered_fee_tx(1, @fee_claimer, ccy_1, ccy_1_fee)
fee_tx_2 = TestHelper.create_recovered_fee_tx(1, @fee_claimer, ccy_2, ccy_2_fee)

invalid_ordered_transactions = [payment_tx_1, fee_tx_1, payment_tx_2, fee_tx_2]
signed_txbytes = Enum.map(invalid_ordered_transactions, fn tx -> tx.signed_tx_bytes end)

block = %{
hash: derive_merkle_root(invalid_ordered_transactions),
number: 1000,
transactions: signed_txbytes
}

assert {:error, :unexpected_transaction_type_at_fee_index} = BlockValidator.stateless_validate(block)
end
end

@spec derive_merkle_root([Transaction.Recovered.t()]) :: binary()
defp(derive_merkle_root(transactions)) do
transactions |> Enum.map(&Transaction.raw_txbytes/1) |> Merkle.hash()
end
end