Skip to content

Commit

Permalink
Add faucet rate limiter (#269)
Browse files Browse the repository at this point in the history
  • Loading branch information
blackode authored and Samuel committed May 6, 2022
1 parent fa8804f commit e9cc3fe
Show file tree
Hide file tree
Showing 10 changed files with 266 additions and 10 deletions.
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

# 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
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
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

0 comments on commit e9cc3fe

Please sign in to comment.