X402.Extensions.PaymentIdentifier.Cache behaviour (X402 v0.6.0)

Copy Markdown View Source

Behaviour and adapter helpers for payment identifier idempotency caches.

Cache adapters are configured as {module, cache} tuples where module implements this behaviour and cache is adapter-specific runtime state (for example, a pid, a registered process name, or a connection handle).

X402.Plug.PaymentGate routes all replay-protection calls through this behaviour. The claim that prevents a payment proof from being settled twice is put_new/3, so every adapter must implement it with atomic first-writer-wins semantics: when several processes race to claim the same payment identifier, exactly one call may return :ok and all others must return {:error, :already_exists}.

Clustered deployments: double-execution hazard

Per-node caches do not protect a cluster

The bundled X402.Extensions.PaymentIdentifier.ETSCache adapter stores claims in a per-node ETS table. In a clustered BEAM deployment every node keeps its own table, so a replayed payment proof routed to two different nodes is claimed independently on each — the protected handler (and its side effects) can run once per node for a single payment. The facilitator may still reject the duplicate settlement, but by then the resource has already been served twice.

If you run more than one node, supply an adapter backed by a shared store instead of the default ETS adapter — the bundled X402.Extensions.PaymentIdentifier.RedisCache (over the optional redix dependency), or your own Mnesia- or database-backed implementation.

Writing a distributed adapter

put_new/3 must combine "insert if absent" and "expire after TTL" in a single atomic operation of the backing store. In Redis that operation is SET key value NX PX ttl — the bundled X402.Extensions.PaymentIdentifier.RedisCache adapter implements exactly this contract over a Redix connection you supervise. For a different backing store, a minimal adapter sketch:

defmodule MyApp.RedisPaymentCache do
  @behaviour X402.Extensions.PaymentIdentifier.Cache

  @ttl_ms :timer.hours(1)

  @impl true
  def put_new(conn, payment_id, value) do
    # SET ... NX PX — atomic first-writer-wins with TTL in one command.
    case Redix.command(conn, ["SET", key(payment_id), encode(value), "NX", "PX", @ttl_ms]) do
      {:ok, "OK"} -> :ok
      {:ok, nil} -> {:error, :already_exists}
      {:error, reason} -> {:error, reason}
    end
  end

  @impl true
  def put(conn, payment_id, value) do
    case Redix.command(conn, ["SET", key(payment_id), encode(value), "PX", @ttl_ms]) do
      {:ok, "OK"} -> :ok
      {:error, reason} -> {:error, reason}
    end
  end

  @impl true
  def get(conn, payment_id) do
    case Redix.command(conn, ["GET", key(payment_id)]) do
      {:ok, nil} -> :miss
      {:ok, encoded} -> {:hit, decode(encoded)}
      {:error, reason} -> {:error, reason}
    end
  end

  @impl true
  def delete(conn, payment_id) do
    case Redix.command(conn, ["DEL", key(payment_id)]) do
      {:ok, _count} -> :ok
      {:error, reason} -> {:error, reason}
    end
  end

  defp key(payment_id), do: "x402:payment:" <> payment_id
  # encode/1 and decode/1 map `:verified | {:rejected, reason}` to a
  # string representation of your choice.
end

Configure it on the gate as:

plug X402.Plug.PaymentGate,
  payment_identifier_cache: {MyApp.RedisPaymentCache, MyApp.Redis},
  routes: [...]

Adapter errors other than {:error, :already_exists} fail closed: the gate responds with HTTP 500 and the protected handler does not run.

Summary

Types

Adapter tuple accepted by X402.Plug.PaymentGate.

Result returned by get/2.

Payment identifier cache key.

Result returned by put_new/3.

Value stored for a given payment identifier.

Result returned by write/delete operations.

Callbacks

Removes the entry for key, releasing a previously successful claim.

Reads the value stored for key, or :miss when absent or expired.

Unconditionally stores value for key, resetting its TTL.

Atomically stores value for key only when no live entry exists.

Functions

Deletes a cached value for a payment identifier.

Reads a cached value for a payment identifier.

Stores a cached value for a payment identifier.

Atomically claims a payment identifier through the adapter's put_new/3.

Validates a cache adapter tuple for NimbleOptions custom validation.

Validates an optional cache adapter for NimbleOptions.

Types

adapter()

@type adapter() :: {module(), term()}

Adapter tuple accepted by X402.Plug.PaymentGate.

get_result()

@type get_result() :: {:hit, value()} | :miss | {:error, term()}

Result returned by get/2.

key()

Payment identifier cache key.

put_new_result()

@type put_new_result() ::
  :ok | {:error, :already_exists} | {:error, :cache_full} | {:error, term()}

Result returned by put_new/3.

value()

@type value() :: :verified | {:rejected, term()}

Value stored for a given payment identifier.

write_result()

@type write_result() :: :ok | {:error, term()}

Result returned by write/delete operations.

Callbacks

delete(cache, key)

@callback delete(cache :: term(), key()) :: write_result()

Removes the entry for key, releasing a previously successful claim.

get(cache, key)

@callback get(cache :: term(), key()) :: get_result()

Reads the value stored for key, or :miss when absent or expired.

put(cache, key, value)

@callback put(cache :: term(), key(), value()) :: write_result()

Unconditionally stores value for key, resetting its TTL.

put_new(cache, key, value)

@callback put_new(cache :: term(), key(), value()) :: put_new_result()

Atomically stores value for key only when no live entry exists.

This is the replay-protection claim used by X402.Plug.PaymentGate, and it must be atomic first-writer-wins: under concurrent calls with the same key, exactly one caller receives :ok and every other caller receives {:error, :already_exists}. Checking existence and inserting in two separate store operations is not acceptable — use the backing store's atomic primitive (:ets.insert_new/2, Redis SET NX PX, an INSERT with a unique constraint, and so on).

The entry must expire after the adapter's TTL; an expired entry must not block a new claim for the same key. An adapter with bounded capacity must never evict a live entry to admit a new claim — a live claim is another payment's replay lock; refuse with {:error, :cache_full} instead (X402.Plug.PaymentGate fails closed on it). Return {:error, :already_exists} only for a live (non-expired) duplicate — any other {:error, reason} is treated as an adapter failure and fails the request closed.

Functions

delete(arg1, payment_id)

(since 0.1.0)
@spec delete(adapter(), key()) :: write_result()

Deletes a cached value for a payment identifier.

get(arg1, payment_id)

(since 0.1.0)
@spec get(adapter(), key()) :: get_result()

Reads a cached value for a payment identifier.

put(arg1, payment_id, value)

(since 0.1.0)
@spec put(adapter(), key(), value()) :: write_result()

Stores a cached value for a payment identifier.

put_new(arg1, payment_id, value)

(since 0.6.0)
@spec put_new(adapter(), key(), value()) :: put_new_result()

Atomically claims a payment identifier through the adapter's put_new/3.

Returns :ok when this caller won the claim, {:error, :already_exists} when a live entry already holds it, or {:error, reason} on adapter failure. See put_new/3 for the atomicity contract.

validate_adapter(arg1)

(since 0.1.0)
@spec validate_adapter(term()) :: :ok | {:error, String.t()}

Validates a cache adapter tuple for NimbleOptions custom validation.

validate_optional_adapter(adapter)

(since 0.1.0)
@spec validate_optional_adapter(term()) :: :ok | {:error, String.t()}

Validates an optional cache adapter for NimbleOptions.

nil disables idempotency caching.