# `X402.Extensions.PaymentIdentifier.Cache`
[🔗](https://github.com/cardotrejos/x402/blob/v0.9.0/lib/x402/extensions/payment_identifier/cache.ex#L1)

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 `c: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 {: .warning}
>
> 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

`c: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.

# `adapter`

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

Adapter tuple accepted by `X402.Plug.PaymentGate`.

# `get_result`

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

Result returned by `get/2`.

# `key`

```elixir
@type key() :: X402.Extensions.PaymentIdentifier.payment_id()
```

Payment identifier cache key.

# `put_new_result`

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

Result returned by `put_new/3`.

# `value`

```elixir
@type value() ::
  :verified
  | {:rejected, term()}
  | {:bound, String.t()}
  | {:siwx_nonce, :issued | :used}
```

Value stored for a given key.

`:verified` and `{:rejected, reason}` mark replay claims;
`{:bound, fingerprint}` binds a client payment id (stored under a
`"pid:"`-prefixed key) to the request fingerprint it was first used with;
`{:siwx_nonce, :issued | :used}` tracks Sign-In-With-X challenge nonces
(stored under `"siwx:issued:"` / `"siwx:used:"`-prefixed keys).

# `write_result`

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

Result returned by write/delete operations.

# `delete`

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

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

# `get`

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

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

# `put`

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

Unconditionally stores `value` for `key`, resetting its TTL.

# `put_new`

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

# `delete`
*since 0.1.0* 

```elixir
@spec delete(adapter(), key()) :: write_result()
```

Deletes a cached value for a payment identifier.

# `get`
*since 0.1.0* 

```elixir
@spec get(adapter(), key()) :: get_result()
```

Reads a cached value for a payment identifier.

# `put`
*since 0.1.0* 

```elixir
@spec put(adapter(), key(), value()) :: write_result()
```

Stores a cached value for a payment identifier.

# `put_new`
*since 0.6.0* 

```elixir
@spec put_new(adapter(), key(), value()) :: put_new_result()
```

Atomically claims a payment identifier through the adapter's `c: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 `c:put_new/3` for the atomicity contract.

# `validate_adapter`
*since 0.1.0* 

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

Validates a cache adapter tuple for `NimbleOptions` custom validation.

# `validate_optional_adapter`
*since 0.1.0* 

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

Validates an optional cache adapter for `NimbleOptions`.

`nil` disables idempotency caching.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
