Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changelog/merry-dogs-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pympp: minor
---

Added `RedisStore` and `SQLiteStore` backends to `mpp.stores` for replay protection, with optional extras (`pympp[redis]`, `pympp[sqlite]`). Added `store` parameter to `Mpp.__init__` and `Mpp.create()` that automatically wires the store into intents supporting replay protection.
10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 5s
retries: 10

tempo:
image: ghcr.io/tempoxyz/tempo:latest
ports:
Expand Down
9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ tempo = [
"pydantic>=2.0",
]
server = ["pydantic>=2.0", "python-dotenv>=1.0"]
redis = ["redis>=5.0"]
sqlite = ["aiosqlite>=0.20"]
mcp = ["mcp>=1.1.0"]
dev = [
"pytest>=8.0",
Expand All @@ -47,6 +49,8 @@ dev = [
"pyright>=1.1",
"build>=1.0",
"twine>=6.0",
"aiosqlite>=0.20",
"redis>=5.0",
]

[build-system]
Expand Down Expand Up @@ -79,4 +83,7 @@ include = ["src", "tests"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
markers = ["integration: requires TEMPO_RPC_URL (real Tempo node)"]
markers = [
"integration: requires TEMPO_RPC_URL (real Tempo node)",
"redis: requires REDIS_URL (real Redis instance)",
]
1 change: 1 addition & 0 deletions src/mpp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,4 +399,5 @@ def success(

from . import _body_digest as BodyDigest # noqa: E402
from . import _expires as Expires # noqa: E402
from . import stores # noqa: E402
from .store import MemoryStore, Store # noqa: E402
21 changes: 21 additions & 0 deletions src/mpp/server/mpp.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from mpp.server.decorator import wrap_payment_handler
from mpp.server.method import transform_request
from mpp.server.verify import verify_or_challenge
from mpp.store import Store

if TYPE_CHECKING:
from mpp.server.method import Method
Expand Down Expand Up @@ -58,6 +59,7 @@ def __init__(
realm: str,
secret_key: str,
defaults: dict[str, Any] | None = None,
store: Store | None = None,
) -> None:
"""Initialize the payment handler.

Expand All @@ -67,30 +69,49 @@ def __init__(
secret_key: Server secret for HMAC-bound challenge IDs.
Enables stateless challenge verification.
defaults: Default request values merged with per-call request params.
store: Optional key-value store for replay protection.
When provided, automatically wired into intents that
accept a ``store`` (e.g., ``ChargeIntent``).
"""
self.method = method
self.realm = realm
self.secret_key = secret_key
self.defaults = defaults or {}

if store is not None:
self._wire_store(store)

def _wire_store(self, store: Store) -> None:
"""Inject *store* into intents that have a ``_store`` attribute set to None."""
intents = getattr(self.method, "intents", None)
if not isinstance(intents, dict):
return
for intent_obj in intents.values():
if hasattr(intent_obj, "_store") and intent_obj._store is None:
intent_obj._store = store

@classmethod
def create(
cls,
method: Method,
realm: str | None = None,
secret_key: str | None = None,
store: Store | None = None,
) -> Mpp:
"""Create an Mpp instance with smart defaults.

Args:
method: Payment method (e.g., tempo(currency=..., recipient=...)).
realm: Server realm. Auto-detected from environment if omitted.
secret_key: HMAC secret. Required unless `MPP_SECRET_KEY` is set.
store: Optional key-value store for replay protection.
Automatically wired into intents that accept a store.
"""
return cls(
method=method,
realm=detect_realm() if realm is None else realm,
secret_key=detect_secret_key() if secret_key is None else secret_key,
store=store,
)

async def charge(
Expand Down
24 changes: 24 additions & 0 deletions src/mpp/stores/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Concrete store backends for replay protection.

Available backends:

- ``MemoryStore`` – in-memory ``dict``, for development/testing.
- ``RedisStore`` – Redis/Valkey, for multi-instance production deployments.
- ``SQLiteStore`` – local SQLite file, for single-instance production deployments.
"""

from mpp.store import MemoryStore

__all__ = ["MemoryStore", "RedisStore", "SQLiteStore"]

Check warning on line 12 in src/mpp/stores/__init__.py

View workflow job for this annotation

GitHub Actions / test (3.12)

"SQLiteStore" is specified in __all__ but is not present in module (reportUnsupportedDunderAll)

Check warning on line 12 in src/mpp/stores/__init__.py

View workflow job for this annotation

GitHub Actions / test (3.12)

"RedisStore" is specified in __all__ but is not present in module (reportUnsupportedDunderAll)


def __getattr__(name: str): # type: ignore[reportReturnType]
if name == "RedisStore":
from mpp.stores.redis import RedisStore

return RedisStore
if name == "SQLiteStore":
from mpp.stores.sqlite import SQLiteStore

return SQLiteStore
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
66 changes: 66 additions & 0 deletions src/mpp/stores/redis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Redis-backed store for multi-instance deployments.

Uses ``redis-py`` (``redis.asyncio``) as the async driver. Install with::

pip install pympp[redis]

Example::

from redis.asyncio import from_url
from mpp.stores import RedisStore

store = RedisStore(await from_url("redis://localhost:6379"))
"""

from __future__ import annotations

from typing import Any


class RedisStore:
"""Async key-value store backed by Redis.

Each key is prefixed with ``key_prefix`` (default ``"mpp:"``).
Keys do not expire by default; set ``ttl_seconds`` to opt into expiry.

``put_if_absent`` maps to ``SET key value NX`` with an optional
``EX ttl`` — a single atomic Redis command with no TOCTOU race.
"""

def __init__(
self,
client: Any,
*,
key_prefix: str = "mpp:",
ttl_seconds: int | None = None,
) -> None:
self._redis = client
self._prefix = key_prefix
self._ttl = ttl_seconds

def _key(self, key: str) -> str:
return f"{self._prefix}{key}"

async def get(self, key: str) -> Any | None:
return await self._redis.get(self._key(key))

async def put(self, key: str, value: Any) -> None:
if self._ttl is None:
await self._redis.set(self._key(key), value)
return
await self._redis.set(self._key(key), value, ex=self._ttl)

async def delete(self, key: str) -> None:
await self._redis.delete(self._key(key))

async def put_if_absent(self, key: str, value: Any) -> bool:
"""Atomic ``SETNX`` with an optional TTL.

Returns ``True`` when the key was new and the write succeeded,
``False`` when the key already existed (duplicate).
"""
if self._ttl is None:
result = await self._redis.set(self._key(key), value, nx=True)
return result is not None
result = await self._redis.set(self._key(key), value, nx=True, ex=self._ttl)
return result is not None
126 changes: 126 additions & 0 deletions src/mpp/stores/sqlite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""SQLite-backed store for single-instance production deployments.

Uses ``aiosqlite`` for async access to Python's built-in ``sqlite3``.
Install with::

pip install pympp[sqlite]

Example::

from mpp.stores import SQLiteStore

store = await SQLiteStore.create("mpp.db")
"""

from __future__ import annotations

import time
from typing import Any

NO_TTL_EXPIRES_AT = 253402300799.0


class SQLiteStore:
"""Async key-value store backed by a local SQLite file.

Keys are stored in a ``kv`` table with optional TTL. Expired rows
are pruned globally on writes so one-shot replay keys do not
accumulate forever.

``put_if_absent`` uses ``INSERT OR IGNORE`` — a single atomic SQL
statement with no TOCTOU race.
"""

def __init__(
self,
db: Any,
*,
ttl_seconds: int | None = None,
) -> None:
self._db = db
self._ttl = ttl_seconds

@classmethod
async def create(
cls,
path: str = "mpp.db",
*,
ttl_seconds: int | None = None,
) -> SQLiteStore:
"""Open (or create) a SQLite database and initialize the schema.

Args:
path: Filesystem path for the database file.
Use ``":memory:"`` for an ephemeral in-memory database.
ttl_seconds: Optional key TTL in seconds. Defaults to no expiry.
"""
import aiosqlite

db = await aiosqlite.connect(path)
await db.execute(
"CREATE TABLE IF NOT EXISTS kv ("
" key TEXT PRIMARY KEY,"
" value TEXT NOT NULL,"
" expires_at REAL NOT NULL"
")"
)
await db.commit()
return cls(db, ttl_seconds=ttl_seconds)

async def close(self) -> None:
"""Close the underlying database connection."""
await self._db.close()

async def __aenter__(self) -> SQLiteStore:
return self

async def __aexit__(self, *args: Any) -> None:
await self.close()

def _expires_at(self) -> float:
if self._ttl is None:
return NO_TTL_EXPIRES_AT
return time.time() + self._ttl

async def _prune_expired(self, now: float) -> None:
await self._db.execute("DELETE FROM kv WHERE expires_at <= ?", (now,))

async def get(self, key: str) -> Any | None:
now = time.time()
cursor = await self._db.execute(
"SELECT value FROM kv WHERE key = ? AND expires_at > ?",
(key, now),
)
row = await cursor.fetchone()
return row[0] if row else None

async def put(self, key: str, value: Any) -> None:
await self._prune_expired(time.time())
await self._db.execute(
"INSERT INTO kv (key, value, expires_at) VALUES (?, ?, ?)"
" ON CONFLICT(key) DO UPDATE SET value = excluded.value,"
" expires_at = excluded.expires_at",
(key, value, self._expires_at()),
)
await self._db.commit()

async def delete(self, key: str) -> None:
await self._db.execute("DELETE FROM kv WHERE key = ?", (key,))
await self._db.commit()

async def put_if_absent(self, key: str, value: Any) -> bool:
"""Atomic conditional insert.

Prunes expired rows first, then uses ``INSERT OR IGNORE`` so the
write only succeeds when the key does not already exist.

Returns ``True`` if the key was new, ``False`` if it existed.
"""
now = time.time()
await self._prune_expired(now)
cursor = await self._db.execute(
"INSERT OR IGNORE INTO kv (key, value, expires_at) VALUES (?, ?, ?)",
(key, value, self._expires_at()),
)
await self._db.commit()
return cursor.rowcount > 0
Loading
Loading