diff --git a/assets/css/app.scss b/assets/css/app.scss index 3b0c77ec9..92cd352c6 100644 --- a/assets/css/app.scss +++ b/assets/css/app.scss @@ -52,4 +52,4 @@ 95% {opacity: 1;} 100% {opacity: 0;} } -} \ No newline at end of file +} diff --git a/assets/css/variables.scss b/assets/css/variables.scss index 3e98277f7..e7f7bf9c4 100644 --- a/assets/css/variables.scss +++ b/assets/css/variables.scss @@ -12,4 +12,4 @@ $navbar-item-hover-background-color: transparent; $card-header-background-color: #fff; -$modal-background-background-color: #0a0a0a61; \ No newline at end of file +$modal-background-background-color: #0a0a0a61; diff --git a/config/config.exs b/config/config.exs index f7b98c0e8..6a4deab38 100644 --- a/config/config.exs +++ b/config/config.exs @@ -38,6 +38,12 @@ config :logger, :console, ], colors: [enabled: true] +# Faucet rate limit in Number of transactions +config :archethic, :faucet_rate_limit, 3 + +# Faucet rate limit Expiry time in milliseconds +config :archethic, :faucet_rate_limit_expiry, 3_600_000 + # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason diff --git a/lib/archethic/utils.ex b/lib/archethic/utils.ex index 395af8b65..8534c8aee 100644 --- a/lib/archethic/utils.ex +++ b/lib/archethic/utils.ex @@ -612,4 +612,36 @@ defmodule ArchEthic.Utils do {attestation, rest} = ReplicationAttestation.deserialize(rest) deserialize_transaction_attestations(rest, nb_attestations, [attestation | acc]) end + + @doc """ + Convert the seconds to human readable format + + ## Examples + + iex> ArchEthic.Utils.seconds_to_human_readable(3666) + "1 hour 01 minute 06 second" + + iex> ArchEthic.Utils.seconds_to_human_readable(66) + "1 minute 06 second" + + iex> ArchEthic.Utils.seconds_to_human_readable(6) + "0 minute 06 second" + """ + def seconds_to_human_readable(0), do: "00:00:00" + + def seconds_to_human_readable(seconds) do + seconds = round(seconds) + units = [3600, 60, 1] + + [h | t] = + Enum.map_reduce(units, seconds, fn unit, val -> {div(val, unit), rem(val, unit)} end) + |> elem(0) + |> Enum.drop_while(&match?(0, &1)) + + {h, t} = if t == [], do: {0, [h]}, else: {h, t} + + base_unit = if length(t) > 1, do: "hour", else: "minute" + + "#{h} #{base_unit} #{t |> Enum.map_join(" minute ", fn term -> term |> Integer.to_string() |> String.pad_leading(2, "0") end)} second" + end end diff --git a/lib/archethic_web/controllers/faucet_controller.ex b/lib/archethic_web/controllers/faucet_controller.ex index f6ff28e55..e080109d5 100644 --- a/lib/archethic_web/controllers/faucet_controller.ex +++ b/lib/archethic_web/controllers/faucet_controller.ex @@ -13,8 +13,10 @@ defmodule ArchEthicWeb.FaucetController do } alias ArchEthicWeb.TransactionSubscriber + alias ArchEthicWeb.FaucetRateLimiter @pool_seed Application.compile_env(:archethic, [__MODULE__, :seed]) + @faucet_rate_limit_expiry Application.compile_env(:archethic, :faucet_rate_limit_expiry) plug(:enabled) @@ -42,6 +44,8 @@ defmodule ArchEthicWeb.FaucetController do def create_transfer(conn, %{"address" => address}) do with {:ok, recipient_address} <- Base.decode16(address, case: :mixed), true <- Crypto.valid_address?(recipient_address), + %{blocked?: false} <- FaucetRateLimiter.get_address_block_status(address), + :ok <- FaucetRateLimiter.register(address, System.monotonic_time()), {:ok, tx_address} <- transfer(recipient_address) do TransactionSubscriber.register(tx_address, System.monotonic_time()) @@ -57,6 +61,18 @@ defmodule ArchEthicWeb.FaucetController do |> put_flash(:error, "Unable to send the transaction") |> render("index.html", address: address, link_address: "") + %{blocked?: true, blocked_since: blocked_since} -> + now = System.monotonic_time() + blocked_elapsed_time = System.convert_time_unit(now - blocked_since, :native, :second) + blocked_elapsed_diff = div(@faucet_rate_limit_expiry, 1000) - blocked_elapsed_time + + conn + |> put_flash( + :error, + "Blocked address as you exceeded usage limit of UCO temporarily. Try after #{ArchEthic.Utils.seconds_to_human_readable(blocked_elapsed_diff)}" + ) + |> render("index.html", address: address, link_address: "") + _ -> conn |> put_flash(:error, "Malformed address") diff --git a/lib/archethic_web/faucet_rate_limiter.ex b/lib/archethic_web/faucet_rate_limiter.ex new file mode 100644 index 000000000..9e78b991f --- /dev/null +++ b/lib/archethic_web/faucet_rate_limiter.ex @@ -0,0 +1,128 @@ +defmodule ArchEthicWeb.FaucetRateLimiter do + @moduledoc false + + use GenServer + + @faucet_rate_limit Application.compile_env!(:archethic, :faucet_rate_limit) + @faucet_rate_limit_expiry Application.compile_env!(:archethic, :faucet_rate_limit_expiry) + @block_period_expiry @faucet_rate_limit_expiry + @clean_time @faucet_rate_limit_expiry + + @type address_status :: %{ + start_time: non_neg_integer(), + last_time: non_neg_integer(), + tx_count: non_neg_integer(), + blocked?: boolean(), + blocked_since: non_neg_integer() + } + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + ## Client Call backs + + @doc """ + Register a faucet transaction address to monitor + """ + @spec register(binary(), non_neg_integer()) :: :ok + def register(address, start_time) + when is_binary(address) and is_integer(start_time) do + GenServer.cast(__MODULE__, {:register, address, start_time}) + end + + @doc false + @spec clean_address(binary()) :: :ok + def clean_address(address) do + GenServer.call(__MODULE__, {:clean, address}) + end + + @spec get_address_block_status(binary()) :: address_status() + def get_address_block_status(address) + when is_binary(address) do + GenServer.call(__MODULE__, {:block_status, address}) + end + + # Server Call backs + @impl GenServer + def init(_) do + schedule_clean() + {:ok, %{}} + end + + @impl GenServer + def handle_call({:block_status, address}, _from, state) do + reply = + if address_state = Map.get(state, address) do + address_state + else + %{blocked?: false} + end + + {:reply, reply, state} + end + + @impl GenServer + def handle_call({:clean, address}, _from, state) do + {:reply, :ok, Map.delete(state, address)} + end + + @impl GenServer + def handle_cast({:register, address, start_time}, state) do + initial_tx_setup = %{ + start_time: start_time, + last_time: start_time, + tx_count: 1, + blocked?: false, + blocked_since: nil + } + + updated_state = + Map.update(state, address, initial_tx_setup, fn + %{tx_count: tx_count} = transaction when tx_count + 1 == @faucet_rate_limit -> + tx_count = transaction.tx_count + 1 + + Process.send_after(self(), {:clean, address}, @block_period_expiry) + + %{ + transaction + | blocked?: true, + blocked_since: System.monotonic_time(), + last_time: start_time, + tx_count: tx_count + 1 + } + + %{tx_count: tx_count} = transaction -> + transaction + |> Map.put(:last_time, start_time) + |> Map.put(:tx_count, tx_count + 1) + end) + + {:noreply, updated_state} + end + + @impl GenServer + def handle_info({:clean, address}, state) do + {:noreply, Map.delete(state, address)} + end + + @impl GenServer + def handle_info(:clean, state) do + schedule_clean() + now = System.monotonic_time() + + new_state = + Enum.filter(state, fn + {_address, %{last_time: start_time}} -> + millisecond_elapsed = System.convert_time_unit(now - start_time, :native, :millisecond) + millisecond_elapsed <= @block_period_expiry + end) + |> Enum.into(%{}) + + {:noreply, new_state} + end + + defp schedule_clean() do + Process.send_after(self(), :clean, @clean_time) + end +end diff --git a/lib/archethic_web/supervisor.ex b/lib/archethic_web/supervisor.ex index e63dfdf6d..d1a29a39e 100644 --- a/lib/archethic_web/supervisor.ex +++ b/lib/archethic_web/supervisor.ex @@ -6,7 +6,7 @@ defmodule ArchEthicWeb.Supervisor do alias ArchEthic.Networking alias ArchEthicWeb.Endpoint - alias ArchEthicWeb.TransactionSubscriber + alias ArchEthicWeb.{FaucetRateLimiter, TransactionSubscriber} require Logger @@ -20,13 +20,15 @@ defmodule ArchEthicWeb.Supervisor do try_open_port(Keyword.get(endpoint_conf, :http)) - children = [ - {Phoenix.PubSub, [name: ArchEthicWeb.PubSub, adapter: Phoenix.PubSub.PG2]}, - # Start the endpoint when the application starts - Endpoint, - {Absinthe.Subscription, Endpoint}, - TransactionSubscriber - ] + children = + [ + {Phoenix.PubSub, [name: ArchEthicWeb.PubSub, adapter: Phoenix.PubSub.PG2]}, + # Start the endpoint when the application starts + Endpoint, + {Absinthe.Subscription, Endpoint}, + TransactionSubscriber + ] + |> add_facucet_rate_limit_child() opts = [strategy: :one_for_one] Supervisor.init(children, opts) @@ -38,4 +40,14 @@ defmodule ArchEthicWeb.Supervisor do port = Keyword.get(conf, :port) Networking.try_open_port(port, false) end + + defp add_facucet_rate_limit_child(children) do + faucet_config = Application.get_env(:archethic, ArchEthicWeb.FaucetController, []) + + if faucet_config[:enabled] do + children ++ [FaucetRateLimiter] + else + children + end + end end diff --git a/lib/archethic_web/templates/faucet/index.html.eex b/lib/archethic_web/templates/faucet/index.html.eex index cd7f83b18..9e77f3d13 100644 --- a/lib/archethic_web/templates/faucet/index.html.eex +++ b/lib/archethic_web/templates/faucet/index.html.eex @@ -24,6 +24,9 @@ +
+ NOTE : <%= faucet_rate_limit_message() %> +

Don't have a wallet address? Learn how to create a new one!
diff --git a/lib/archethic_web/views/faucet_view.ex b/lib/archethic_web/views/faucet_view.ex index db6097111..72c09a505 100644 --- a/lib/archethic_web/views/faucet_view.ex +++ b/lib/archethic_web/views/faucet_view.ex @@ -1,3 +1,10 @@ defmodule ArchEthicWeb.FaucetView do use ArchEthicWeb, :view + + def faucet_rate_limit_message() do + rate_limit = Application.get_env(:archethic, :faucet_rate_limit) + expiry = Application.get_env(:archethic, :faucet_rate_limit_expiry, 0) + + "Allowed only #{rate_limit} transactions for the period of #{ArchEthic.Utils.seconds_to_human_readable(expiry / 1000)}" + end end diff --git a/test/archethic_web/controllers/faucet_controller_test.exs b/test/archethic_web/controllers/faucet_controller_test.exs index ec36373c1..3d6163e52 100644 --- a/test/archethic_web/controllers/faucet_controller_test.exs +++ b/test/archethic_web/controllers/faucet_controller_test.exs @@ -25,6 +25,8 @@ defmodule ArchEthicWeb.FaucetControllerTest do TransactionData.UCOLedger } + alias ArchEthicWeb.FaucetRateLimiter + import Mox @pool_seed Application.compile_env(:archethic, [ArchEthicWeb.FaucetController, :seed]) @@ -48,6 +50,7 @@ defmodule ArchEthicWeb.FaucetControllerTest do describe "create_transfer/2" do test "should show success flash with tx URL on valid transaction", %{conn: conn} do recipient_address = "000098fe10e8633bce19c59a40a089731c1f72b097c5a8f7dc71a37eb26913aa4f80" + FaucetRateLimiter.clean_address(recipient_address) tx = Transaction.new( @@ -95,5 +98,54 @@ defmodule ArchEthicWeb.FaucetControllerTest do assert html_response(conn, 200) =~ "Malformed address" end + + test "should show error flash on faucet rate limit is reached", %{conn: conn} do + faucet_rate_limit = Application.get_env(:archethic, :faucet_rate_limit) + + recipient_address = "000098fe10e8633bce19c59a40a089731c1f72b097c5a8f7dc71a37eb26913aa4f80" + + tx = + Transaction.new( + :transfer, + %TransactionData{ + ledger: %Ledger{ + uco: %UCOLedger{ + transfers: [ + %UCOLedger.Transfer{ + to: recipient_address, + amount: 10_000_000_000 + } + ] + } + } + }, + @pool_seed, + 0, + Crypto.default_curve() + ) + + MockClient + |> stub(:send_message, fn + _, %GetLastTransactionAddress{}, _ -> + {:ok, %LastTransactionAddress{address: "1234"}} + + _, %GetTransactionChainLength{}, _ -> + {:ok, %TransactionChainLength{length: 0}} + + _, %StartMining{}, _ -> + PubSub.notify_new_transaction(tx.address) + + {:ok, %Ok{}} + end) + + faucet_requests = + for _request_index <- 1..(faucet_rate_limit + 1) do + post(conn, Routes.faucet_path(conn, :create_transfer), address: recipient_address) + end + + conn = List.last(faucet_requests) + + assert html_response(conn, 200) =~ "Blocked address" + end end end