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:
ConCacheStoreis 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-replaywith cookie-based sticky sessions, or consistent hashing by session ID), per-nodeConCacheStoreis 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
endThen 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
Callbacks
@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).
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.
Store a key-value pair.
Returns :ok on success or {:error, reason} on store failure.
Functions
@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}.
@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.
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.
nil(absent / not configured) → the app-starteddefault_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.