# `MPP.Tempo.Store`
[🔗](https://github.com/ZenHive/mpp/blob/v0.12.0/lib/mpp/tempo/store.ex#L1)

Behaviour for atomic Tempo state stores used by `MPP.Methods.Tempo`.

Prevents replay attacks by tracking which transaction hashes have already
been used. HMAC-bound challenges prevent cross-request replay; this store
prevents a client from resubmitting the same signed transaction within
the store's TTL window.

**Important:** The store's TTL must be ≥ your challenge `expires_in` to
ensure a tx hash cannot be evicted and replayed while the challenge is
still valid. A good default is 2× the challenge expiry.

## Built-in Store

`MPP.Tempo.ConCacheStore` provides an ETS-based implementation with automatic
TTL expiry via ConCache. It is started by the `:mpp` application as the **default**
store, so replay protection is on out of the box (issue #7). For most
single-node deployments this is all you need; for multi-node, configure a shared
backend (Redis/Postgres) as your `:store`.

## Custom Implementations

Implement this behaviour with your choice of backend (Redis, database, etc.)
for custom needs. Store lifecycle and cleanup are the consumer's responsibility.

Fee sponsorship additionally requires `update/3`. The update callback must be
atomic for one key and must honor `:ttl_ms` without expiring the value earlier.
All nodes and endpoints sponsoring the same wallet must select the same physical
backend. Separate store instances provide separate bounds even when their module
and configuration are otherwise identical.

## Deployment Strategies

The store choice depends on your deployment topology:

- **Single node:** `ConCacheStore` is sufficient — all requests hit the same ETS table.
- **Multi-node with sticky routing:** If your load balancer pins clients to specific
  nodes (e.g., Fly.io's `fly-replay` with cookie-based sticky sessions, or consistent
  hashing by session ID), per-node `ConCacheStore` is effectively global — the same
  client always hits the same node's store.
- **Multi-node without sticky routing:** Use a shared backend (Redis, Postgres)
  to ensure a tx replayed on a different node is still caught.

Keys are formatted as `"mpp:charge:<lowercase_hex_value>"` where the value is
the transaction hash (for `type="hash"`) or the full serialized transaction
hex (for `type="transaction"`).

## Multi-tenant key prefixes

When several endpoints share one `ConCacheStore` instance, pass a distinct
`:key_prefix` in the store opts so identical transaction hashes do not collide:

    "store" => {MPP.Tempo.ConCacheStore, name: :shared_dedup, key_prefix: "billing:"}

Prefixes are prepended to every backing key (mppx `Store.from/2` parity).
Default is no prefix — behavior is unchanged when omitted.

## Example

    defmodule MyApp.PaymentStore do
      @behaviour MPP.Tempo.Store

      def get(key) do
        case :ets.lookup(:payment_dedup, key) do
          [{^key, value}] -> {:ok, value}
          [] -> :not_found
        end
      end

      def put(key, value) do
        :ets.insert(:payment_dedup, {key, value})
        :ok
      end

      def check_and_mark(key, value) do
        if :ets.insert_new(:payment_dedup, {key, value}) do
          :ok
        else
          {:error, :already_exists}
        end
      end
    end

Then pass it in method_config:

    plug MPP.Plug,
      method: MPP.Methods.Tempo,
      method_config: %{
        "rpc_url" => "https://rpc.moderato.tempo.xyz",
        "store" => MyApp.PaymentStore
      }

# `store_ref`

```elixir
@type store_ref() :: module() | {module(), keyword()}
```

# `check_and_mark`

```elixir
@callback check_and_mark(key :: String.t(), value :: term()) ::
  :ok | {:error, :already_exists} | {:error, term()}
```

Atomically check if a key exists and mark it if not.

This is the critical operation for preventing concurrent replay attacks.
The store MUST implement it atomically (Redis SETNX, DB upsert with a unique
constraint, ConCache row isolation, etc.) so concurrent requests with the same
key are serialized — only the first succeeds.

Returns `:ok` if the key was not present and is now marked,
`{:error, :already_exists}` if the key was already present,
or `{:error, reason}` on store failure.

**Required.** A store that does not export `check_and_mark/2` is rejected at
`Plug.init` / `validate_config!` — the library never falls back to a
non-atomic `get/1` + `put/2`, which would leave a TOCTOU replay window
(see GHSA-w8j7-7qc3-5f24).

# `get`

```elixir
@callback get(key :: String.t()) :: {:ok, term()} | :not_found | {:error, term()}
```

Look up a key in the store.

Returns `{:ok, value}` if found, `:not_found` if the key doesn't exist,
or `{:error, reason}` on store failure.

# `put`

```elixir
@callback put(key :: String.t(), value :: term()) :: :ok | {:error, term()}
```

Store a key-value pair.

Returns `:ok` on success or `{:error, reason}` on store failure.

# `update`
*optional* 

```elixir
@callback update(
  key :: String.t(),
  fun :: (term() | :not_found -&gt;
            {:put, term(), result} | {:delete, result} | {:noop, result}),
  opts :: keyword()
) :: {:ok, result} | {:error, term()}
when result: term()
```

Atomically update one key and return a caller-defined result.

The callback receives `:not_found` or the current value and returns one of:

  * `{:put, new_value, result}` — store `new_value`
  * `{:delete, result}` — delete the key
  * `{:noop, result}` — leave the key unchanged

Options used by sponsor budgets:

  * `:ttl_ms` — a positive millisecond TTL or a function deriving it from the
    exact new value inside the atomic update
  * `:ignore_key_prefix` — bypass caller-specific replay prefixes

This callback is optional for replay-only stores, but a sponsorship-enabled
configuration is rejected unless the explicitly selected store exports it.

# `check_and_mark`

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

Atomically check and mark a key using either a store module or `{MPP.Tempo.ConCacheStore, opts}`.

# `default_store`

```elixir
@spec default_store() :: module()
```

Return the default dedup store started by the `:mpp` application.

Replay protection is on by default (issue #7); this is the store
used when a method/plug is configured without an explicit `:store`.

# `get`

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

Look up a key using either a store module or `{MPP.Tempo.ConCacheStore, opts}`.

# `put`

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

Store a key-value pair using either a store module or `{MPP.Tempo.ConCacheStore, opts}`.

# `resolve`

```elixir
@spec resolve(store_ref() | nil | false) :: store_ref() | nil
```

Resolve a configured `:store` option to the store actually used at runtime.

  * `nil` (absent / not configured) → the app-started `default_store/0` (on by default)
  * `false` → `nil` (explicit opt-out — no replay protection)
  * any other ref → returned unchanged

Callers validate the ref separately (see each method's `validate_store!/1`);
this only applies the default-on / opt-out policy.

# `storage_key`

```elixir
@spec storage_key(
  String.t(),
  keyword()
) :: String.t()
```

Apply an optional `:key_prefix` from store opts to a logical dedup key.

Used by `MPP.Tempo.ConCacheStore`; custom stores may call this for parity with
mppx's `Store.from(store, { keyPrefix })` wrapper. Default is no prefix.

# `update`

```elixir
@spec update(store_ref(), String.t(), (term() | :not_found -&gt; term()), keyword()) ::
  {:ok, term()} | {:error, term()}
```

Atomically update a key using either a store module or configured `ConCacheStore`.

---

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