Behaviour and option handling for per-wallet request rate limiting.
X402.Plug.PaymentGate consults a rate limiter after a payment has been
verified and before it is settled, so the key is derived from an
authenticated payer: the from of an unverified payload is whatever the
sender typed, and counting it earlier would let anyone exhaust a victim
wallet's allowance with forged proofs. The limit therefore bounds how many
verified payments a wallet can spend per window; it is not a substitute
for an edge/IP limiter against unauthenticated floods. A store implements
hit/4; X402.RateLimiter.ETS ships a per-node fixed-window
implementation and any shared store (Redis, a database) can be plugged in
through the same callback.
Options
The gate's :rate_limit option is validated with validate_options/1:
:limit(pos_integer/0) - Required. Maximum hits per window.:window_ms(pos_integer/0) - Required. Window length in milliseconds.:key- What is being limited::payer(the verified payer address, falling back to the remote IP when neither the facilitator nor the verified scheme names one),:ip(the remote IP), or a 1-arity function of the request context (%{conn: conn, payer: payer, payment_payload: payload, requirements: requirements}) returning any term —nilexempts the request. The default value is:payer.:store- A module implementingX402.RateLimiter, or a{module, ref}tuple whererefis passed as the first argument ofhit/4. A bare module is called with itself as the ref — for the defaultX402.RateLimiter.ETSthat is the table name. The default value isX402.RateLimiter.ETS.:on_error- What to do when the store returns{:error, _}or raises::allowlets the request through (the limiter degrades to a no-op with a logged warning),:denyanswers 429 with a one-secondRetry-After. The default value is:allow.
Implementing a store
defmodule MyApp.RedisRateLimiter do
@behaviour X402.RateLimiter
@impl true
def hit(conn_name, key, limit, window_ms) do
# INCR + PEXPIRE on "rl:" <> inspect(key), then compare with limit
end
endhit/4 must count the hit atomically and report either the remaining
allowance or how long the caller should wait. It should not raise for
store failures — return {:error, reason} and let :on_error decide.
Summary
Types
Validated :rate_limit configuration.
Request context handed to a :key function.
The value being rate limited.
Result of a hit.
Store reference passed as the first argument of hit/4.
Callbacks
Records one hit for key and reports whether it is within limit per window_ms.
Functions
Records a hit for key against the configured store.
Recovers the verified payer address of a payment.
Resolves the rate-limit key for a request, or :skip when it is exempt.
Converts a retry delay in milliseconds to whole seconds, rounded up, for Retry-After.
Validates a :rate_limit option value into a config/0 map.
Types
@type config() :: %{ limit: pos_integer(), window_ms: pos_integer(), key: :payer | :ip | (context() -> term()), store: {module(), store_ref()}, on_error: :allow | :deny }
Validated :rate_limit configuration.
@type context() :: %{ conn: term(), payer: String.t() | nil, payment_payload: map(), requirements: map() }
Request context handed to a :key function.
@type key() :: term()
The value being rate limited.
@type result() :: {:allow, remaining :: non_neg_integer()} | {:deny, retry_after_ms :: pos_integer()} | {:error, term()}
Result of a hit.
@type store_ref() :: term()
Store reference passed as the first argument of hit/4.
Callbacks
@callback hit(store_ref(), key(), limit :: pos_integer(), window_ms :: pos_integer()) :: result()
Records one hit for key and reports whether it is within limit per window_ms.
Functions
@spec check(config(), key()) :: {:allow, non_neg_integer()} | {:deny, pos_integer()}
Records a hit for key against the configured store.
Store errors and exceptions never propagate: they are logged and resolved
according to :on_error (:allow reports the full limit as remaining;
:deny reports a one-second retry).
Recovers the verified payer address of a payment.
Prefers the payer the facilitator reported in its verify response
(verify_response is the response map or its body), and otherwise reads
the signer field authenticated by the verified scheme: EIP-3009
authorization.from for exact EVM, or permit2Authorization.from for
exact EVM with Permit2 and upto EVM. Other schemes require a reported
payer; unrelated authorization fields are ignored.
The payload's from is attacker-controlled until verification succeeds;
call this with a verified payment only. Pass the requirements used for
verification as the third argument, or omit it to use the payload's
verified accepted requirements.
Examples
iex> requirements = %{"scheme" => "exact", "network" => "eip155:8453"}
iex> X402.RateLimiter.payer(%{"accepted" => requirements, "payload" => %{"authorization" => %{"from" => "0xabc"}}})
"0xabc"
iex> requirements = %{"scheme" => "upto", "network" => "eip155:8453"}
iex> X402.RateLimiter.payer(%{"payload" => %{"permit2Authorization" => %{"from" => "0xdef"}}}, nil, requirements)
"0xdef"
iex> payload = %{"payload" => %{"authorization" => %{"from" => "0xabc"}}}
iex> X402.RateLimiter.payer(payload, %{status: 200, body: %{"isValid" => true, "payer" => "0x123"}})
"0x123"
iex> X402.RateLimiter.payer(%{"payload" => %{"transaction" => "AQID"}}, %{"isValid" => true})
nil
@spec resolve_key( %{:key => :payer | :ip | (context() -> term()), optional(atom()) => term()}, context() ) :: key() | :skip
Resolves the rate-limit key for a request, or :skip when it is exempt.
:payer uses the payer address (lower-cased when it is a 0x address)
and falls back to {:ip, remote_ip} when no verified payer is available;
:ip uses the remote IP; a function receives the whole context.
Examples
iex> context = %{conn: %{remote_ip: {127, 0, 0, 1}}, payer: "0xABC", payment_payload: %{}, requirements: %{}}
iex> X402.RateLimiter.resolve_key(%{key: :payer}, context)
{:payer, "0xabc"}
iex> context = %{conn: %{remote_ip: {127, 0, 0, 1}}, payer: nil, payment_payload: %{}, requirements: %{}}
iex> X402.RateLimiter.resolve_key(%{key: :payer}, context)
{:ip, {127, 0, 0, 1}}
iex> context = %{conn: %{remote_ip: {10, 0, 0, 2}}, payer: "0xabc", payment_payload: %{}, requirements: %{}}
iex> X402.RateLimiter.resolve_key(%{key: :ip}, context)
{:ip, {10, 0, 0, 2}}
iex> context = %{conn: %{remote_ip: {10, 0, 0, 2}}, payer: "0xabc", payment_payload: %{}, requirements: %{}}
iex> X402.RateLimiter.resolve_key(%{key: fn _context -> nil end}, context)
:skip
@spec retry_after_seconds(non_neg_integer()) :: pos_integer()
Converts a retry delay in milliseconds to whole seconds, rounded up, for Retry-After.
Examples
iex> X402.RateLimiter.retry_after_seconds(1)
1
iex> X402.RateLimiter.retry_after_seconds(1_000)
1
iex> X402.RateLimiter.retry_after_seconds(1_001)
2
iex> X402.RateLimiter.retry_after_seconds(0)
1
Validates a :rate_limit option value into a config/0 map.
Usable as a NimbleOptions custom type. nil disables limiting.
Examples
iex> X402.RateLimiter.validate_options(nil)
{:ok, nil}
iex> {:ok, config} = X402.RateLimiter.validate_options(limit: 10, window_ms: 60_000)
iex> config
%{limit: 10, window_ms: 60_000, key: :payer, store: {X402.RateLimiter.ETS, X402.RateLimiter.ETS}, on_error: :allow}
iex> {:error, message} = X402.RateLimiter.validate_options(limit: 0, window_ms: 1)
iex> message =~ ":limit"
true
iex> X402.RateLimiter.validate_options(:nope)
{:error, "expected nil or a keyword list of rate limit options"}