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

111 faucet rate limiter #269

Merged
16 commits merged into from
Apr 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/css/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,4 @@
95% {opacity: 1;}
100% {opacity: 0;}
}
}
}
2 changes: 1 addition & 1 deletion assets/css/variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ $navbar-item-hover-background-color: transparent;

$card-header-background-color: #fff;

$modal-background-background-color: #0a0a0a61;
$modal-background-background-color: #0a0a0a61;
6 changes: 6 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ config :logger, :console,
],
colors: [enabled: true]

# Faucet rate limit in Number of transactions
config :archethic, :faucet_rate_limit, 3
blackode marked this conversation as resolved.
Show resolved Hide resolved

# 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

Expand Down
32 changes: 32 additions & 0 deletions lib/archethic/utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions lib/archethic_web/controllers/faucet_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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())

Expand All @@ -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")
Expand Down
128 changes: 128 additions & 0 deletions lib/archethic_web/faucet_rate_limiter.ex
Original file line number Diff line number Diff line change
@@ -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
blackode marked this conversation as resolved.
Show resolved Hide resolved
GenServer.call(__MODULE__, {:clean, address})
end

@spec get_address_block_status(binary()) :: address_status()
def get_address_block_status(address)
blackode marked this conversation as resolved.
Show resolved Hide resolved
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
28 changes: 20 additions & 8 deletions lib/archethic_web/supervisor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ defmodule ArchEthicWeb.Supervisor do
alias ArchEthic.Networking

alias ArchEthicWeb.Endpoint
alias ArchEthicWeb.TransactionSubscriber
alias ArchEthicWeb.{FaucetRateLimiter, TransactionSubscriber}

require Logger

Expand All @@ -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)
Expand All @@ -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
3 changes: 3 additions & 0 deletions lib/archethic_web/templates/faucet/index.html.eex
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
</button>
</div>
</form>
<div>
<small class="has-text-warning" > <b>NOTE : </b> <%= faucet_rate_limit_message() %></small>
</div>
<br>
Don't have a wallet address? Learn how to create a new one!
<br>
Expand Down
7 changes: 7 additions & 0 deletions lib/archethic_web/views/faucet_view.ex
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions test/archethic_web/controllers/faucet_controller_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ defmodule ArchEthicWeb.FaucetControllerTest do
TransactionData.UCOLedger
}

alias ArchEthicWeb.FaucetRateLimiter

import Mox

@pool_seed Application.compile_env(:archethic, [ArchEthicWeb.FaucetController, :seed])
Expand All @@ -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(
Expand Down Expand Up @@ -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