X402.RateLimiter behaviour (X402 v0.9.0)

Copy Markdown View Source

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 — nil exempts the request. The default value is :payer.

  • :store - A module implementing X402.RateLimiter, or a {module, ref} tuple where ref is passed as the first argument of hit/4. A bare module is called with itself as the ref — for the default X402.RateLimiter.ETS that is the table name. The default value is X402.RateLimiter.ETS.

  • :on_error - What to do when the store returns {:error, _} or raises: :allow lets the request through (the limiter degrades to a no-op with a logged warning), :deny answers 429 with a one-second Retry-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
end

hit/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

config()

@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.

context()

@type context() :: %{
  conn: term(),
  payer: String.t() | nil,
  payment_payload: map(),
  requirements: map()
}

Request context handed to a :key function.

key()

@type key() :: term()

The value being rate limited.

result()

@type result() ::
  {:allow, remaining :: non_neg_integer()}
  | {:deny, retry_after_ms :: pos_integer()}
  | {:error, term()}

Result of a hit.

store_ref()

@type store_ref() :: term()

Store reference passed as the first argument of hit/4.

Callbacks

hit(store_ref, key, limit, window_ms)

@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

check(config, key)

(since 0.9.0)
@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).

payer(payment_payload, verify_response \\ nil, requirements \\ nil)

(since 0.9.0)
@spec payer(map(), map() | nil, map() | nil) :: String.t() | nil

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

resolve_key(arg1, context)

(since 0.9.0)
@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

retry_after_seconds(retry_after_ms)

(since 0.9.0)
@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

validate_options(opts)

(since 0.9.0)
@spec validate_options(term()) :: {:ok, config() | nil} | {:error, String.t()}

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"}