From 01830813fbb2ceee3393f654d3e17a69e3061042 Mon Sep 17 00:00:00 2001 From: Samuel <42943690+samuel-uniris@users.noreply.github.com> Date: Thu, 25 Aug 2022 16:57:41 +0200 Subject: [PATCH] Verify scheduled network transaction only happens once (#534) --- config/test.exs | 2 +- .../mining/pending_transaction_validation.ex | 275 +++++++++++++----- lib/archethic/oracle_chain.ex | 42 ++- lib/archethic/reward.ex | 26 ++ lib/archethic/shared_secrets.ex | 25 ++ .../pending_transaction_validation_test.exs | 59 ++++ 6 files changed, 349 insertions(+), 80 deletions(-) diff --git a/config/test.exs b/config/test.exs index 88f71890a..029372ffd 100755 --- a/config/test.exs +++ b/config/test.exs @@ -115,7 +115,7 @@ config :archethic, Archethic.Mining.PendingTransactionValidation, validate_node_ config :archethic, Archethic.Metrics.Poller, enabled: false config :archethic, Archethic.Metrics.Collector, MockMetricsCollector -config :archethic, Archethic.Reward.Scheduler, enabled: false +config :archethic, Archethic.Reward.Scheduler, enabled: false, interval: "0 0 * * * * *" config :archethic, Archethic.Reward.MemTables.RewardTokens, enabled: false config :archethic, Archethic.Reward.MemTablesLoader, enabled: false diff --git a/lib/archethic/mining/pending_transaction_validation.ex b/lib/archethic/mining/pending_transaction_validation.ex index f1bf956d1..b203c653b 100644 --- a/lib/archethic/mining/pending_transaction_validation.ex +++ b/lib/archethic/mining/pending_transaction_validation.ex @@ -40,14 +40,17 @@ defmodule Archethic.Mining.PendingTransactionValidation do @doc """ Determines if the transaction is accepted into the network """ - @spec validate(Transaction.t()) :: :ok | {:error, any()} - def validate(tx = %Transaction{address: address, type: type}) do + @spec validate(Transaction.t(), DateTime.t()) :: :ok | {:error, any()} + def validate( + tx = %Transaction{address: address, type: type}, + validation_time = %DateTime{} \\ DateTime.utc_now() + ) do start = System.monotonic_time() with true <- Transaction.verify_previous_signature?(tx), :ok <- validate_contract(tx), :ok <- validate_content_size(tx), - :ok <- do_accept_transaction(tx) do + :ok <- do_accept_transaction(tx, validation_time) do :telemetry.execute( [:archethic, :mining, :pending_transaction_validation], %{duration: System.monotonic_time() - start}, @@ -104,30 +107,63 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp do_accept_transaction(%Transaction{ - type: :node_rewards, - data: %TransactionData{ - ledger: %Ledger{ - token: %TokenLedger{transfers: token_transfers} + defp do_accept_transaction( + tx = %Transaction{ + type: :node_rewards, + data: %TransactionData{ + ledger: %Ledger{ + token: %TokenLedger{transfers: token_transfers} + } } - } - }) do - case Reward.get_transfers() do - ^token_transfers -> + }, + validation_time + ) do + last_scheduling_date = Reward.get_last_scheduling_date(validation_time) + + network_pool_address = SharedSecrets.get_network_pool_address() + + previous_address = Transaction.previous_address(tx) + + time_validation = + with {:ok, %Transaction{type: :node_rewards}} <- + TransactionChain.get_transaction(previous_address, [:type]), + {^network_pool_address, _} <- + DB.get_last_chain_address(network_pool_address, last_scheduling_date) do :ok + else + {:ok, %Transaction{type: :mint_rewards}} -> + :ok + + _ -> + {:error, :time} + end + + with :ok <- time_validation, + ^token_transfers <- Reward.get_transfers() do + :ok + else + {:error, :time} -> + Logger.warning("Invalid reward time scheduling", + transaction_address: Base.encode16(tx.address) + ) + + {:error, "Invalid node rewards trigger time"} _ -> {:error, "Invalid network pool transfers"} end end - defp do_accept_transaction(%Transaction{ - type: :node, - data: %TransactionData{ - content: content + defp do_accept_transaction( + %Transaction{ + type: :node, + data: %TransactionData{ + content: content + }, + previous_public_key: previous_public_key }, - previous_public_key: previous_public_key - }) do + _ + ) do with {:ok, ip, port, _http_port, _, _, origin_public_key, key_certificate} <- Node.decode_transaction_content(content), {:auth_origin, true} <- @@ -163,12 +199,15 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp do_accept_transaction(%Transaction{ - type: :origin, - data: %TransactionData{ - content: content - } - }) do + defp do_accept_transaction( + %Transaction{ + type: :origin, + data: %TransactionData{ + content: content + } + }, + _ + ) do with {origin_public_key, rest} <- Utils.deserialize_public_key(content), < 0 and map_size(authorized_keys) > 0 do nodes = P2P.authorized_nodes() ++ NodeRenewal.candidates() - with {:ok, _, _} <- NodeRenewal.decode_transaction_content(content), - true <- Enum.all?(Map.keys(authorized_keys), &Utils.key_in_node_list?(nodes, &1)) do + last_scheduling_date = SharedSecrets.get_last_scheduling_date(validation_time) + + genesis_address = + SharedSecrets.genesis_daily_nonce_public_key() + |> Crypto.derive_address() + + {last_address, _} = DB.get_last_chain_address(genesis_address) + + with {^last_address, _} <- DB.get_last_chain_address(genesis_address, last_scheduling_date), + {:ok, _, _} <- + NodeRenewal.decode_transaction_content(content), + true <- + Enum.all?( + Map.keys(authorized_keys), + &Utils.key_in_node_list?(nodes, &1) + ) do :ok else :error -> @@ -206,17 +262,21 @@ defmodule Archethic.Mining.PendingTransactionValidation do false -> {:error, "Invalid node shared secrets transaction authorized nodes"} + + _address -> + {:error, "Invalid node shared secrets trigger time"} end end - defp do_accept_transaction(%Transaction{type: :node_shared_secrets}) do + defp do_accept_transaction(%Transaction{type: :node_shared_secrets}, _) do {:error, "Invalid node shared secrets transaction"} end defp do_accept_transaction( tx = %Transaction{ type: :code_proposal - } + }, + _ ) do with {:ok, prop} <- CodeProposal.from_transaction(tx), true <- Governance.valid_code_changes?(prop) do @@ -233,7 +293,8 @@ defmodule Archethic.Mining.PendingTransactionValidation do data: %TransactionData{ recipients: [proposal_address] } - } + }, + _ ) do with {:ok, first_public_key} <- get_first_public_key(tx), {:member, true} <- @@ -257,17 +318,23 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp do_accept_transaction(%Transaction{ - type: :keychain, - data: %TransactionData{content: "", ownerships: []} - }) do + defp do_accept_transaction( + %Transaction{ + type: :keychain, + data: %TransactionData{content: "", ownerships: []} + }, + _ + ) do {:error, "Invalid Keychain transaction"} end - defp do_accept_transaction(%Transaction{ - type: :keychain, - data: %TransactionData{content: content, ownerships: _ownerships} - }) do + defp do_accept_transaction( + %Transaction{ + type: :keychain, + data: %TransactionData{content: content, ownerships: _ownerships} + }, + _ + ) do schema = :archethic |> Application.app_dir("priv/json-schemas/did-core.json") @@ -288,18 +355,24 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp do_accept_transaction(%Transaction{ - type: :keychain_access, - data: %TransactionData{ownerships: []} - }) do + defp do_accept_transaction( + %Transaction{ + type: :keychain_access, + data: %TransactionData{ownerships: []} + }, + _ + ) do {:error, "Invalid Keychain access transaction"} end - defp do_accept_transaction(%Transaction{ - type: :keychain_access, - data: %TransactionData{ownerships: ownerships}, - previous_public_key: previous_public_key - }) do + defp do_accept_transaction( + %Transaction{ + type: :keychain_access, + data: %TransactionData{ownerships: ownerships}, + previous_public_key: previous_public_key + }, + _ + ) do if Enum.any?(ownerships, &Ownership.authorized_public_key?(&1, previous_public_key)) do :ok else @@ -307,20 +380,26 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp do_accept_transaction(%Transaction{ - type: :token, - data: %TransactionData{content: content} - }) do + defp do_accept_transaction( + %Transaction{ + type: :token, + data: %TransactionData{content: content} + }, + _ + ) do verify_token_creation(content) end # To accept mint_rewards transaction, we ensure that the supply correspond to the # burned fees from the last summary and that there is no transaction since the last # reward schedule - defp do_accept_transaction(%Transaction{ - type: :mint_rewards, - data: %TransactionData{content: content} - }) do + defp do_accept_transaction( + %Transaction{ + type: :mint_rewards, + data: %TransactionData{content: content} + }, + _ + ) do total_fee = DB.get_latest_burned_fees() with :ok <- verify_token_creation(content), @@ -341,39 +420,79 @@ defmodule Archethic.Mining.PendingTransactionValidation do end end - defp do_accept_transaction(%Transaction{ - type: :oracle, - data: %TransactionData{ - content: content - } - }) do - if OracleChain.valid_services_content?(content) do + defp do_accept_transaction( + %Transaction{ + type: :oracle, + data: %TransactionData{ + content: content + } + }, + validation_time + ) do + last_scheduling_date = OracleChain.get_last_scheduling_date(validation_time) + + genesis_address = + validation_time + |> OracleChain.next_summary_date() + |> Crypto.derive_oracle_address(0) + + {last_address, _} = DB.get_last_chain_address(genesis_address) + + with {^last_address, _} <- + DB.get_last_chain_address(genesis_address, last_scheduling_date), + true <- OracleChain.valid_services_content?(content) do :ok else - {:error, "Invalid oracle transaction"} + {_, _} -> + {:error, "Invalid oracle trigger time"} + + false -> + {:error, "Invalid oracle transaction"} end end - defp do_accept_transaction(%Transaction{ - type: :oracle_summary, - data: %TransactionData{ - content: content + defp do_accept_transaction( + %Transaction{ + type: :oracle_summary, + data: %TransactionData{ + content: content + }, + previous_public_key: previous_public_key }, - previous_public_key: previous_public_key - }) do + validation_time + ) do previous_address = Crypto.derive_address(previous_public_key) + last_scheduling_date = OracleChain.get_last_scheduling_date(validation_time) + + genesis_address = + validation_time + |> OracleChain.next_summary_date() + |> Crypto.derive_oracle_address(0) + + {last_address, _} = DB.get_last_chain_address(genesis_address) + transactions = TransactionChain.stream(previous_address, data: [:content], validation_stamp: [:timestamp]) - if OracleChain.valid_summary?(content, transactions) do + with {^last_address, _} <- DB.get_last_chain_address(genesis_address, last_scheduling_date), + eq when eq in [:gt, :eq] <- + DateTime.compare(validation_time, OracleChain.previous_summary_date(validation_time)), + true <- OracleChain.valid_summary?(content, transactions) do :ok else - {:error, "Invalid oracle summary transaction"} + {_, _} -> + {:error, "Invalid oracle summary trigger time"} + + :lt -> + {:error, "Invalid oracle summary trigger time"} + + false -> + {:error, "Invalid oracle summary transaction"} end end - defp do_accept_transaction(_), do: :ok + defp do_accept_transaction(_, _), do: :ok defp verify_token_creation(content) do schema = diff --git a/lib/archethic/oracle_chain.ex b/lib/archethic/oracle_chain.ex index fd14ecf91..a37fd8151 100644 --- a/lib/archethic/oracle_chain.ex +++ b/lib/archethic/oracle_chain.ex @@ -141,9 +141,49 @@ defmodule Archethic.OracleChain do """ @spec next_summary_date(DateTime.t()) :: DateTime.t() def next_summary_date(date_from = %DateTime{}) do - Scheduler.get_summary_interval() + interval = + Application.get_env(:archethic, Scheduler) + |> Keyword.fetch!(:summary_interval) + + interval |> CronParser.parse!(true) |> CronScheduler.get_next_run_date!(DateTime.to_naive(date_from)) |> DateTime.from_naive!("Etc/UTC") end + + @doc """ + Return the previous oracle summary date + """ + @spec previous_summary_date(DateTime.t()) :: DateTime.t() + def previous_summary_date(date_from = %DateTime{}) do + interval = + Application.get_env(:archethic, Scheduler) + |> Keyword.fetch!(:summary_interval) + + interval + |> CronParser.parse!(true) + |> CronScheduler.get_previous_run_date!(DateTime.to_naive(date_from)) + |> DateTime.from_naive!("Etc/UTC") + end + + @doc """ + Get the previous polling date from the given date + """ + @spec get_last_scheduling_date(DateTime.t()) :: DateTime.t() + def get_last_scheduling_date(from_date = %DateTime{}) do + polling_interval = + Application.get_env(:archethic, Scheduler) + |> Keyword.fetch!(:polling_interval) + + cron_expression = Crontab.CronExpression.Parser.parse!(polling_interval, true) + naive_from_date = from_date |> DateTime.truncate(:second) |> DateTime.to_naive() + + if Crontab.DateChecker.matches_date?(cron_expression, naive_from_date) do + DateTime.truncate(from_date, :second) + else + cron_expression + |> Crontab.Scheduler.get_previous_run_date!(naive_from_date) + |> DateTime.from_naive!("Etc/UTC") + end + end end diff --git a/lib/archethic/reward.ex b/lib/archethic/reward.ex index 823eb9aa6..0466b70ae 100644 --- a/lib/archethic/reward.ex +++ b/lib/archethic/reward.ex @@ -26,6 +26,7 @@ defmodule Archethic.Reward do alias Archethic.Reward.MemTables.RewardTokens alias Archethic.Reward.MemTablesLoader + @unit_uco 100_000_000 @doc """ @@ -216,4 +217,29 @@ defmodule Archethic.Reward do def is_reward_token?(token_address) when is_binary(token_address) do RewardTokens.exists?(token_address) end + + @doc """ + Return the last scheduling date + """ + @spec get_last_scheduling_date(DateTime.t()) :: DateTime.t() + def get_last_scheduling_date(date_from = %DateTime{}) do + interval = + Application.get_env(:archethic, Scheduler) + |> Keyword.fetch!(:interval) + + cron_expression = Crontab.CronExpression.Parser.parse!(interval, true) + + naive_date_from = + date_from + |> DateTime.truncate(:second) + |> DateTime.to_naive() + + if Crontab.DateChecker.matches_date?(cron_expression, naive_date_from) do + DateTime.truncate(date_from, :second) + else + cron_expression + |> Crontab.Scheduler.get_previous_run_date!(naive_date_from) + |> DateTime.from_naive!("Etc/UTC") + end + end end diff --git a/lib/archethic/shared_secrets.ex b/lib/archethic/shared_secrets.ex index b588cb9b7..c79e350b8 100644 --- a/lib/archethic/shared_secrets.ex +++ b/lib/archethic/shared_secrets.ex @@ -118,4 +118,29 @@ defmodule Archethic.SharedSecrets do :biometric end end + + @doc """ + Get the last shared secrets scheduling date from a given date + """ + @spec get_last_scheduling_date(DateTime.t()) :: DateTime.t() + def get_last_scheduling_date(date_from = %DateTime{}) do + interval = + Application.get_env(:archethic, NodeRenewalScheduler) + |> Keyword.fetch!(:interval) + + cron_expression = Crontab.CronExpression.Parser.parse!(interval, true) + + naive_date_from = + date_from + |> DateTime.truncate(:second) + |> DateTime.to_naive() + + if Crontab.DateChecker.matches_date?(cron_expression, naive_date_from) do + DateTime.truncate(date_from, :second) + else + cron_expression + |> Crontab.Scheduler.get_previous_run_date!(naive_date_from) + |> DateTime.from_naive!("Etc/UTC") + end + end end diff --git a/test/archethic/mining/pending_transaction_validation_test.exs b/test/archethic/mining/pending_transaction_validation_test.exs index 140a6bf7e..a5711eafb 100644 --- a/test/archethic/mining/pending_transaction_validation_test.exs +++ b/test/archethic/mining/pending_transaction_validation_test.exs @@ -449,5 +449,64 @@ defmodule Archethic.Mining.PendingTransactionValidationTest do assert {:error, "There is already a mint rewards transaction since last schedule"} = PendingTransactionValidation.validate(tx) end + + test "should return error when there is already a oracle transaction since the last schedule" do + MockDB + |> expect(:get_last_chain_address, fn _, _ -> + {"OtherAddress", DateTime.utc_now()} + end) + + tx = Transaction.new(:oracle, %TransactionData{}, "seed", 0) + + assert {:error, "Invalid oracle trigger time"} = + PendingTransactionValidation.validate(tx, ~U[2022-01-01 00:10:03Z]) + end + + test "should return error when there is already a node shared secrets transaction since the last schedule" do + MockDB + |> expect(:get_last_chain_address, fn _, _ -> + {"OtherAddress", DateTime.utc_now()} + end) + + tx = + Transaction.new( + :node_shared_secrets, + %TransactionData{ + content: :crypto.strong_rand_bytes(32), + ownerships: [ + %Ownership{ + secret: :crypto.strong_rand_bytes(32), + authorized_keys: %{"node_key" => :crypto.strong_rand_bytes(32)} + } + ] + }, + "seed", + 0 + ) + + assert {:error, "Invalid node shared secrets trigger time"} = + PendingTransactionValidation.validate(tx, ~U[2022-01-01 00:00:03Z]) + end + + test "should return error when there is already a node rewards transaction since the last schedule" do + MockDB + |> expect(:get_last_chain_address, fn _, _ -> + {"OtherAddress", DateTime.utc_now()} + end) + |> expect(:get_transaction, fn _, _ -> + {:ok, %Transaction{type: :node_rewards}} + end) + + tx = + Transaction.new( + :node_rewards, + %TransactionData{}, + "seed", + 0 + ) + + assert {:error, "Invalid node rewards trigger time"} = + PendingTransactionValidation.validate(tx, ~U[2022-01-01 00:00:03Z]) + end end end