From e19aa96bf812bfd51c186b2929ba3ec50a42fb18 Mon Sep 17 00:00:00 2001 From: Teerawat Lamanchart Date: Sun, 18 Apr 2021 02:21:25 +0700 Subject: [PATCH] Create ETHTransactions module --- .iex.exs | 1 + lib/satana/application.ex | 27 +++--- lib/satana/eth_transactions.ex | 52 +++++++++++ lib/satana/eth_transactions/store.ex | 95 ++++++++++++++++++++ lib/satana/eth_transactions/transaction.ex | 28 ++++++ mix.exs | 1 + mix.lock | 1 + test/satana/eth_transactions/store_test.exs | 97 +++++++++++++++++++++ test/satana/eth_transactions_test.exs | 95 ++++++++++++++++++++ test/test_helper.exs | 3 + 10 files changed, 389 insertions(+), 11 deletions(-) create mode 100644 .iex.exs create mode 100644 lib/satana/eth_transactions.ex create mode 100644 lib/satana/eth_transactions/store.ex create mode 100644 lib/satana/eth_transactions/transaction.ex create mode 100644 test/satana/eth_transactions/store_test.exs create mode 100644 test/satana/eth_transactions_test.exs diff --git a/.iex.exs b/.iex.exs new file mode 100644 index 0000000..b81c57b --- /dev/null +++ b/.iex.exs @@ -0,0 +1 @@ +alias Satana.ETHTransactions diff --git a/lib/satana/application.ex b/lib/satana/application.ex index 30812a4..4a70268 100644 --- a/lib/satana/application.ex +++ b/lib/satana/application.ex @@ -5,17 +5,14 @@ defmodule Satana.Application do use Application - def start(_type, _args) do - children = [ - # Start the Telemetry supervisor - SatanaWeb.Telemetry, - # Start the PubSub system - {Phoenix.PubSub, name: Satana.PubSub}, - # Start the Endpoint (http/https) - SatanaWeb.Endpoint - # Start a worker by calling: Satana.Worker.start_link(arg) - # {Satana.Worker, arg} - ] + def start(_type, args) do + children = + [ + SatanaWeb.Telemetry, + {Phoenix.PubSub, name: Satana.PubSub}, + SatanaWeb.Endpoint, + {Finch, name: Satana.Finch} + ] ++ list_children_by_env(args[:env]) # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options @@ -29,4 +26,12 @@ defmodule Satana.Application do SatanaWeb.Endpoint.config_change(changed, removed) :ok end + + defp list_children_by_env(:test) do + [] + end + + defp list_children_by_env(_) do + [Satana.ETHTransactions.Store] + end end diff --git a/lib/satana/eth_transactions.ex b/lib/satana/eth_transactions.ex new file mode 100644 index 0000000..0e9be4e --- /dev/null +++ b/lib/satana/eth_transactions.ex @@ -0,0 +1,52 @@ +defmodule Satana.ETHTransactions do + alias Satana.Blocknative + alias Satana.ETHTransactions.Store + alias Satana.ETHTransactions.Transaction + alias Satana.Slack + + require Logger + + @spec list_transactions_by_status(String.t()) :: [Transaction.t()] + def list_transactions_by_status(status) do + Store.list_transactions() + |> Enum.filter(&(&1.status == status)) + end + + @spec add_eth_transaction(String.t()) :: :ok | {:error, String.t()} + def add_eth_transaction(tx_id) do + with :ok <- Store.add_transaction(tx_id, handle_transaction_in: {2, :minutes}, with: &handle_transaction/1), + :ok <- Blocknative.add_eth_transaction_to_watch(tx_id) do + text = "Transaction `#{tx_id}` has been registered :sunglasses:" + Slack.send_message(text) + + :ok + else + {:error, :already_exists} -> + {:error, "transaction #{tx_id} already exists"} + + {:error, msg} -> + Logger.warn(fn -> "Rollback transaction #{tx_id} - #{msg}" end) + + Store.delete_transaction(tx_id) + + {:error, msg} + end + end + + defp handle_transaction(%Transaction{} = transaction) do + if Transaction.pending?(transaction) do + text = "It's been a while but transaction `#{transaction.tx_id}` is still pending :thinking_face:" + Slack.send_message(text) + end + end + + @spec confirm_transaction!(String.t()) :: :ok + def confirm_transaction!(tx_id) do + :ok = Store.update_transaction(tx_id, &Transaction.confirm/1) + + text = "Transaction `#{tx_id}` has been confirmed :tada:" + Slack.send_message(text) + + :ok + end +end diff --git a/lib/satana/eth_transactions/store.ex b/lib/satana/eth_transactions/store.ex new file mode 100644 index 0000000..9a651ef --- /dev/null +++ b/lib/satana/eth_transactions/store.ex @@ -0,0 +1,95 @@ +defmodule Satana.ETHTransactions.Store do + use GenServer + + alias Satana.ETHTransactions.Transaction + + require Logger + + @spec start_link(Keyword.t()) :: GenServer.on_start() + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @spec add_transaction(String.t(), keyword()) :: :ok | {:error, :already_exists} + def add_transaction(tx_id, handle_transaction_in: {num, unit}, with: callback_function) + when unit in [:seconds, :minutes] and is_function(callback_function, 1) do + GenServer.call(__MODULE__, {:add_new_transaction, tx_id, {num, unit}, callback_function}) + end + + @spec delete_transaction(String.t()) :: :ok + def delete_transaction(tx_id) do + GenServer.call(__MODULE__, {:delete_transaction, tx_id}) + end + + @spec update_transaction(String.t(), fun()) :: :ok | {:error, :not_found} + def update_transaction(tx_id, update_function) when is_function(update_function, 1) do + GenServer.call(__MODULE__, {:update_transaction, tx_id, update_function}) + end + + @spec list_transactions :: [Transaction.t()] + def list_transactions do + GenServer.call(__MODULE__, :list_transactions) + end + + ## GenServer callbacks + + @impl GenServer + def init(opts) do + Logger.info(fn -> "Running #{__MODULE__}" end) + initial_state = opts[:initial_state] || [] + {:ok, initial_state} + end + + @impl GenServer + def handle_call({:add_new_transaction, tx_id, {num, unit}, callback_function}, _from, current_transactions) do + if Enum.find(current_transactions, &(&1.tx_id == tx_id)) do + {:reply, {:error, :already_exists}, current_transactions} + else + time = apply(:timer, unit, [num]) + Process.send_after(self(), {:check_transaction_status, tx_id, callback_function}, time) + new_transaction = Transaction.new(tx_id) + + {:reply, :ok, [new_transaction | current_transactions]} + end + end + + @impl GenServer + def handle_call({:delete_transaction, tx_id}, _from, current_transactions) do + index = Enum.find_index(current_transactions, &(&1.tx_id == tx_id)) + + if index do + updated_transactions = List.delete_at(current_transactions, index) + + {:reply, :ok, updated_transactions} + else + {:reply, :ok, current_transactions} + end + end + + @impl GenServer + def handle_call({:update_transaction, tx_id, update_function}, _from, current_transactions) do + index = Enum.find_index(current_transactions, &(&1.tx_id == tx_id)) + + if index do + updated_transactions = List.update_at(current_transactions, index, update_function) + + {:reply, :ok, updated_transactions} + else + {:reply, {:error, :not_found}, current_transactions} + end + end + + @impl GenServer + def handle_call(:list_transactions, _from, transactions) do + {:reply, transactions, transactions} + end + + @impl GenServer + def handle_info({:check_transaction_status, tx_id, callback_function}, transactions) do + transaction = Enum.find(transactions, &(&1.tx_id == tx_id)) + + if transaction, do: callback_function.(transaction) + + {:noreply, transactions} + end +end diff --git a/lib/satana/eth_transactions/transaction.ex b/lib/satana/eth_transactions/transaction.ex new file mode 100644 index 0000000..85cf343 --- /dev/null +++ b/lib/satana/eth_transactions/transaction.ex @@ -0,0 +1,28 @@ +defmodule Satana.ETHTransactions.Transaction do + @enforce_keys [:tx_id, :status] + + defstruct @enforce_keys + + @type t :: %__MODULE__{ + tx_id: String.t(), + status: String.t() + } + + @spec new(String.t()) :: t() + def new(tx_id) do + %__MODULE__{ + tx_id: tx_id, + status: "pending" + } + end + + @spec confirm(t()) :: t() + def confirm(%__MODULE__{} = transaction) do + %{transaction | status: "confirmed"} + end + + @spec pending?(t()) :: boolean() + def pending?(%__MODULE__{status: status}) do + status == "pending" + end +end diff --git a/mix.exs b/mix.exs index a01cdfd..0de62ec 100644 --- a/mix.exs +++ b/mix.exs @@ -36,6 +36,7 @@ defmodule Satana.MixProject do {:bypass, "~> 2.1", only: :test}, {:finch, "~> 0.6"}, {:jason, "~> 1.0"}, + {:mimic, "~> 1.5", only: :test}, {:phoenix, "~> 1.5.8"}, {:plug_cowboy, "~> 2.0"}, {:telemetry_metrics, "~> 0.4"}, diff --git a/mix.lock b/mix.lock index d07ac0c..b6f2fbf 100644 --- a/mix.lock +++ b/mix.lock @@ -7,6 +7,7 @@ "finch": {:hex, :finch, "0.6.3", "18b993653f5d7d5550b0a3c3f9777269b2b99db02726ac6fe776d58c2dd1a0a0", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:mint, "~> 1.2", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3.5", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "015f48d3a43f9d2afd06ef714636545bdb017297a63bf582cac8fdcf8ae6f031"}, "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, + "mimic": {:hex, :mimic, "1.5.0", "ad33740f414412639006afa4092f7bf09f5ddeb51999f94181ff8ad9e5d5d675", [:mix], [], "hexpm", "0f4620b88623c3869a11bd7e5691cccc712290c4bcbf73bf07bfb37c3c6932a4"}, "mint": {:hex, :mint, "1.2.1", "369cc8fecc54afd170e11740aa7efd066709e5ef3b5a2c63f0a47d1542cbd56a", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "053fe2f48c965f31878a16272478d9299fa412bc4df86dee2678986f2e40e018"}, "nimble_options": {:hex, :nimble_options, "0.3.5", "a4f6820cdcb4ee444afd78635f323e58e8a5ddf2fbbe9b9d283a99f972034bae", [:mix], [], "hexpm", "f5507cc90033a8d12769522009c80aa9164af6bab245dbd4ad421d008455f1e1"}, "nimble_pool": {:hex, :nimble_pool, "0.2.4", "1db8e9f8a53d967d595e0b32a17030cdb6c0dc4a451b8ac787bf601d3f7704c3", [:mix], [], "hexpm", "367e8071e137b787764e6a9992ccb57b276dc2282535f767a07d881951ebeac6"}, diff --git a/test/satana/eth_transactions/store_test.exs b/test/satana/eth_transactions/store_test.exs new file mode 100644 index 0000000..1295edd --- /dev/null +++ b/test/satana/eth_transactions/store_test.exs @@ -0,0 +1,97 @@ +defmodule Satana.ETHTransactions.StoreTest do + use ExUnit.Case + + alias Satana.ETHTransactions.Store + alias Satana.ETHTransactions.Transaction + + defmodule Messenger do + require Logger + + def send_message(text) do + Logger.info(text) + end + end + + setup do + start_supervised!(Store) + + :ok + end + + test "behaves as expected" do + {:ok, agent_pid} = Agent.start_link(fn -> [] end) + + tx_id1 = "0x1ded29a6bc74abbcc51313fc261b2f97c7f1fc5b9adbbdbaca00ac959f519e7b" + tx_id2 = "0xeb84a81a973187ff9d79934723eb801c400ed941a2a34215ddb202db152e4b84" + tx_id3 = "0x58c9efa287cedee606bfe264898fc24b22ca4e824883585962d1d0ad1140dfd4" + + callback_function = fn %Transaction{} = transaction -> + if Transaction.pending?(transaction) do + Agent.update(agent_pid, fn state -> [transaction.tx_id | state] end) + end + end + + # check initial state + assert Store.list_transactions() == [] + + # add some transactions + assert Store.add_transaction(tx_id1, + handle_transaction_in: {2, :seconds}, + with: callback_function + ) == :ok + + assert Store.add_transaction(tx_id2, + handle_transaction_in: {2, :seconds}, + with: callback_function + ) == :ok + + assert Store.add_transaction(tx_id3, + handle_transaction_in: {2, :seconds}, + with: callback_function + ) == :ok + + assert Store.add_transaction(tx_id1, + handle_transaction_in: {2, :seconds}, + with: callback_function + ) == {:error, :already_exists} + + # check state immediately after transactions added + assert Store.list_transactions() == [ + %Transaction{ + tx_id: tx_id3, + status: "pending" + }, + %Transaction{ + tx_id: tx_id2, + status: "pending" + }, + %Transaction{ + tx_id: tx_id1, + status: "pending" + } + ] + + # modify state + assert Store.update_transaction(tx_id1, &Transaction.confirm/1) == :ok + assert Store.update_transaction("0x123", &Transaction.confirm/1) == {:error, :not_found} + assert Store.delete_transaction(tx_id3) == :ok + + # wait for callback function to be called + :timer.sleep(2_500) + + # check current state after state modification + assert Store.list_transactions() == [ + %Transaction{ + tx_id: tx_id2, + status: "pending" + }, + %Transaction{ + tx_id: tx_id1, + status: "confirmed" + } + ] + + # check if callback function is called + assert Agent.get(agent_pid, & &1) == [tx_id2] + end +end diff --git a/test/satana/eth_transactions_test.exs b/test/satana/eth_transactions_test.exs new file mode 100644 index 0000000..30c43be --- /dev/null +++ b/test/satana/eth_transactions_test.exs @@ -0,0 +1,95 @@ +defmodule Satana.ETHTransactionsTest do + use ExUnit.Case + use Mimic + + import ExUnit.CaptureLog + + alias Satana.Blocknative + alias Satana.ETHTransactions + alias Satana.ETHTransactions.Store + alias Satana.ETHTransactions.Transaction + alias Satana.Slack + + @transactions [ + %Transaction{tx_id: "0x123", status: "pending"}, + %Transaction{tx_id: "0x456", status: "confirmed"} + ] + + setup do + start_supervised!({Store, initial_state: @transactions}) + + :ok + end + + describe "list_transactions_by_status/1" do + test "returns a list of transactions from the given status" do + assert ETHTransactions.list_transactions_by_status("pending") == [ + %Transaction{tx_id: "0x123", status: "pending"} + ] + + assert ETHTransactions.list_transactions_by_status("confirmed") == [ + %Transaction{tx_id: "0x456", status: "confirmed"} + ] + + assert ETHTransactions.list_transactions_by_status("new") == [] + end + end + + describe "add_eth_transaction/1" do + test "returns error if the given tx_id already exists" do + reject(&Blocknative.add_eth_transaction_to_watch/1) + + result = ETHTransactions.add_eth_transaction("0x123") + + assert result == {:error, "transaction 0x123 already exists"} + end + + test "returns error if there is an error from blocknative" do + tx_id = "0x789" + + expect(Blocknative, :add_eth_transaction_to_watch, fn ^tx_id -> + {:error, "this is error message from blocknative"} + end) + + assert capture_log(fn -> + result = ETHTransactions.add_eth_transaction(tx_id) + + assert result == {:error, "this is error message from blocknative"} + end) =~ "[warn] Rollback transaction 0x789" + end + + test "returns :ok and sends Slack message if there is no error" do + tx_id = "0x789" + + expect(Blocknative, :add_eth_transaction_to_watch, fn ^tx_id -> + :ok + end) + + expect(Slack, :send_message, fn "Transaction `0x789` has been registered :sunglasses:" -> + :ok + end) + + result = ETHTransactions.add_eth_transaction(tx_id) + + assert result == :ok + end + end + + describe "confirm_transaction!/1" do + test "raises an error if the given tx_id does not exists" do + assert_raise MatchError, fn -> + ETHTransactions.confirm_transaction!("0x789") + end + end + + test "returns :ok and sends Slack message if transaction can be confirmed" do + expect(Slack, :send_message, fn "Transaction `0x123` has been confirmed :tada:" -> + :ok + end) + + result = ETHTransactions.confirm_transaction!("0x123") + + assert result == :ok + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..389611c 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,4 @@ +Mimic.copy(Satana.Blocknative) +Mimic.copy(Satana.Slack) + ExUnit.start()