MPP.Tempo.Store behaviour (mpp v0.7.0)

Copy Markdown View Source

Behaviour for transaction dedup 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.

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").

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
  }

Summary

Callbacks

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

Look up a key in the store.

Store a key-value pair.

Functions

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

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

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

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

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

Types

store_ref()

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

Callbacks

check_and_mark(key, value)

@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(key)

@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(key, value)

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

Functions

check_and_mark(store, key, value)

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

@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(store, key)

@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(store, key, value)

@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(store)

@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)
  • falsenil (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.