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

Copy Markdown View Source

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.

The transaction path may delete/2 a reserved slot after a definite pre-broadcast failure (hosted fill, co-sign, missing config, simulate revert) so the same signed bytes remain retriable. The reservation value is an attempt token; delete/2 is compare-and-delete on that token so a delayed release cannot remove a later attempt's reservation or mark after TTL eviction. delete/2 is optional: stores that omit it fall back to update/3 that compares the stored value to the expected token, otherwise the key is retained until TTL expiry (the conservative retain path).

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

  # Compare and delete in one ETS operation; a lookup followed by a
  # delete would let a concurrent reserve slip in between.
  def delete(key, expected) do
    :ets.select_delete(:payment_dedup, [{{key, expected}, [], [true]}])
    :ok
  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.

Delete a previously reserved key only when it still holds expected.

Look up a key in the store.

Store a key-value pair.

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

Functions

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

Whether store exports the dedup callbacks every configured store must provide (get/1, put/2, check_and_mark/2).

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

Compare-and-delete a key using either a store module or configured ConCacheStore.

Whether store exports the optional delete/2 callback used to release a reserved dedup slot with compare-and-delete on the attempt token.

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.

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

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

Whether store exports the atomic update/3 callback that fee sponsorship requires.

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

delete(key, expected)

(optional)
@callback delete(key :: String.t(), expected :: term()) :: :ok | {:error, term()}

Delete a previously reserved key only when it still holds expected.

Used to release a transaction-path dedup slot after a failure that definitely did not broadcast (hosted fill, co-sign, missing rpc_url, a reverting or operationally failing eth_simulateV1, which is read-only and cannot broadcast). Ambiguous broadcast outcomes must retain the slot instead of calling this.

The stored value is the attempt token written at reserve time. Delete only when it still matches expected. A mismatch — the slot was replaced after TTL expiry by a later attempt's reservation or mark — is a successful no-op: return :ok and leave the current value.

The compare and the delete MUST be one atomic operation (ETS select_delete, a Redis Lua script or WATCH/MULTI, a SQL DELETE ... WHERE value = ?). A read followed by a separate delete lets a concurrent attempt reserve the slot between the two steps, and the stale release then removes that attempt's reservation — the ownership bug this callback exists to prevent. The built-in MPP.Tempo.ConCacheStore runs both steps inside ConCache.isolated/3.

Optional. The delete/3 dispatcher prefers this callback, falls back to update/3 that compares the stored value to expected when this callback is missing, and returns {:error, :unsupported} when the store exports neither — the caller then retains the key until TTL expiry.

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.

update(key, fun, opts)

(optional)
@callback update(
  key :: String.t(),
  fun :: (term() | :not_found ->
            {: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.

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

dedup_capable?(store)

@spec dedup_capable?(term()) :: boolean()

Whether store exports the dedup callbacks every configured store must provide (get/1, put/2, check_and_mark/2).

Loads the module first: function_exported?/3 answers false for a module that is merely compiled but not yet loaded, so a valid custom store configured before its first call would otherwise be rejected at init.

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.

delete(store, key, expected)

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

Compare-and-delete a key using either a store module or configured ConCacheStore.

Prefers native delete/2 with the expected attempt token. Stores that omit it fall back to update/3 that deletes only when the stored value matches expected. Otherwise returns {:error, :unsupported} so the caller can retain the slot until TTL expiry.

delete_capable?(store)

@spec delete_capable?(term()) :: boolean()

Whether store exports the optional delete/2 callback used to release a reserved dedup slot with compare-and-delete on the attempt token.

Loads the module first, for the same reason as dedup_capable?/1. Stores that return false still participate in delete/3 via the update/3 fallback when they export it.

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)
  • 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(key, opts \\ [])

@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(store, key, fun, opts \\ [])

@spec update(store_ref(), String.t(), (term() | :not_found -> term()), keyword()) ::
  {:ok, term()} | {:error, term()}

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

update_capable?(store)

@spec update_capable?(term()) :: boolean()

Whether store exports the atomic update/3 callback that fee sponsorship requires.

Loads the module first, for the same reason as dedup_capable?/1.