From 012e71d02837f9ac894ec99f3898f15514dac012 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:40:38 -0700 Subject: [PATCH] feat: add split credential lifecycle and Tempo relay --- .changelog/tempo-api-relay.md | 6 + examples/README.md | 1 + examples/charge-relay/README.md | 46 +++ examples/charge-relay/pyproject.toml | 13 + examples/charge-relay/server.py | 70 +++++ src/mpp/_validation.py | 22 ++ src/mpp/errors.py | 15 +- src/mpp/extensions/mcp/verify.py | 8 +- src/mpp/methods/tempo/__init__.py | 1 + src/mpp/methods/tempo/__init__.pyi | 4 + src/mpp/methods/tempo/client.py | 12 +- src/mpp/methods/tempo/intents.py | 97 ++++-- src/mpp/methods/tempo/relay.py | 315 +++++++++++++++++++ src/mpp/server/__init__.py | 11 +- src/mpp/server/intent.py | 95 +++++- src/mpp/server/mpp.py | 143 ++++++++- src/mpp/server/verify.py | 7 +- tests/test_errors.py | 4 + tests/test_server.py | 386 ++++++++++++++++++++++- tests/test_tempo.py | 35 +++ tests/test_tempo_relay.py | 445 +++++++++++++++++++++++++++ 21 files changed, 1697 insertions(+), 39 deletions(-) create mode 100644 .changelog/tempo-api-relay.md create mode 100644 examples/charge-relay/README.md create mode 100644 examples/charge-relay/pyproject.toml create mode 100644 examples/charge-relay/server.py create mode 100644 src/mpp/_validation.py create mode 100644 src/mpp/methods/tempo/relay.py create mode 100644 tests/test_tempo_relay.py diff --git a/.changelog/tempo-api-relay.md b/.changelog/tempo-api-relay.md new file mode 100644 index 00000000..4a73ce6a --- /dev/null +++ b/.changelog/tempo-api-relay.md @@ -0,0 +1,6 @@ +--- +pympp: minor +--- + +Add split, non-mutating credential validation and terminal broadcast lifecycle APIs, +including server-side Tempo API relay configuration for charge finalization. diff --git a/examples/README.md b/examples/README.md index c59a5faf..f9371b24 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,6 +6,7 @@ Code examples for using the Machine Payments Protocol (pympp). | Example | Description | |---------|-------------| +| [charge-relay/](charge-relay/) | Tempo API relay-backed FastAPI charge server | | [fetch/](fetch/) | CLI tool for fetching URLs with automatic payment handling | | [mcp-server/](mcp-server/) | MCP server with payment-protected tools | | [stripe/](stripe/) | Stripe SPT payment flow (server + headless client) | diff --git a/examples/charge-relay/README.md b/examples/charge-relay/README.md new file mode 100644 index 00000000..9334edc2 --- /dev/null +++ b/examples/charge-relay/README.md @@ -0,0 +1,46 @@ +# FastAPI Charge Relay + +A single-file FastAPI server that accepts pathUSD on Tempo Moderato. pympp +issues and binds charge challenges, then delegates validation and broadcast to +the Tempo API Moderato relay—the same setup as mppx's `charge-relay` example. + +## Setup + +Create a Tempo API key with the `mpp:write` scope and provide it only to the +server process: + +```bash +export TEMPO_API_KEY=tempo:sk:... +export TEMPO_API_URL=https://api.tempo.xyz +export MPP_SECRET_KEY=$(openssl rand -base64 32) +uv sync +uv run server.py +``` + +The server starts at `http://127.0.0.1:5173`. `TEMPO_API_URL` can target a +compatible self-hosted or preview Tempo API. `MPP_SECRET_KEY` protects the +server-issued challenges; the example has a development-only default so it can +run locally without one. + +## Routes + +| Route | Description | +|---|---| +| `/api/photo` | Payment-gated image URL | +| `/api/health` | Free health check | + +## Flow + +1. The server returns a `tempo/charge` challenge for pathUSD. +2. The payer signs a Tempo transaction and retries with its credential. +3. `Relay` calls `POST /v1/mpp/validate`, then `POST /v1/mpp/broadcast`. +4. The relay receipt becomes the `Payment-Receipt` response header. + +The normal payment route uses the same split lifecycle as mppx. For standalone +credentials, `Mpp.validate_credential()` performs only the advisory validation +phase and `Mpp.broadcast_credential()` revalidates before the terminal phase. +`Mpp.verify_credential()` remains as a backward-compatible terminal alias. + +The relay broadcasts pull credentials. It finalizes push credentials that +contain an already-broadcast transaction hash without broadcasting them again. +Relay failures become payment errors without exposing API details. diff --git a/examples/charge-relay/pyproject.toml b/examples/charge-relay/pyproject.toml new file mode 100644 index 00000000..50f16bb1 --- /dev/null +++ b/examples/charge-relay/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "charge-relay-example" +version = "0.1.0" +description = "Tempo API relay-backed pympp charge example" +requires-python = ">=3.12" +dependencies = [ + "pympp[tempo,server]", + "fastapi", + "uvicorn", +] + +[tool.uv.sources] +pympp = { path = "../.." } diff --git a/examples/charge-relay/server.py b/examples/charge-relay/server.py new file mode 100644 index 00000000..02416c1e --- /dev/null +++ b/examples/charge-relay/server.py @@ -0,0 +1,70 @@ +"""FastAPI charge server backed by the Tempo API MPP relay.""" + +import os +import secrets + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from mpp import Challenge +from mpp.methods.tempo import ChargeIntent, Relay, TempoAccount, tempo +from mpp.methods.tempo._defaults import PATH_USD, TESTNET_CHAIN_ID +from mpp.server import Mpp + +api_key = os.environ.get("TEMPO_API_KEY") +if not api_key: + raise RuntimeError("Set TEMPO_API_KEY to a Tempo API key with the mpp:write scope") + +TEMPO_API_URL = os.environ.get("TEMPO_API_URL", "https://api.tempo.xyz") +RECIPIENT = os.environ.get("PAYMENT_DESTINATION") +if not RECIPIENT: + RECIPIENT = TempoAccount.from_key("0x" + secrets.token_hex(32)).address + +payments = Mpp.create( + method=tempo( + chain_id=TESTNET_CHAIN_ID, + currency=PATH_USD, + recipient=RECIPIENT, + intents={"charge": ChargeIntent()}, + relay=Relay(api_key=api_key, api_base_url=TEMPO_API_URL), + ), + secret_key=os.environ.get( + "MPP_SECRET_KEY", + "pympp-demo-tempo-api-relay-secret-key", + ), +) + +app = FastAPI() + + +@app.get("/api/health") +async def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.get("/api/photo") +async def photo(request: Request): + result = await payments.charge( + authorization=request.headers.get("Authorization"), + amount="0.01", + chain_id=TESTNET_CHAIN_ID, + description="Random stock photo", + ) + if isinstance(result, Challenge): + return JSONResponse( + status_code=402, + content={"error": "Payment required"}, + headers={"WWW-Authenticate": result.to_www_authenticate(payments.realm)}, + ) + + _, receipt = result + return JSONResponse( + content={"url": "https://picsum.photos/1024/1024"}, + headers={"Payment-Receipt": receipt.to_payment_receipt()}, + ) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("PORT", "5173"))) diff --git a/src/mpp/_validation.py b/src/mpp/_validation.py new file mode 100644 index 00000000..d5116c71 --- /dev/null +++ b/src/mpp/_validation.py @@ -0,0 +1,22 @@ +"""Dependency-neutral credential validation result.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from mpp import ChallengeEcho, Credential + + +@dataclass(frozen=True, slots=True) +class Validation: + """A non-mutating method-specific credential validation result.""" + + challenge: ChallengeEcho + credential: Credential + details: Any + intent: str + method: str + request: dict[str, Any] + source: str | None = None diff --git a/src/mpp/errors.py b/src/mpp/errors.py index 56dbdfe3..931a9069 100644 --- a/src/mpp/errors.py +++ b/src/mpp/errors.py @@ -39,6 +39,10 @@ def __init_subclass__(cls, **kwargs: Any) -> None: type: str = f"{_BASE_URI}/payment-error" + def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None: + super().__init__(message) + self.details = details + def to_problem_details(self, challenge_id: str | None = None) -> dict[str, Any]: """Convert to RFC 9457 Problem Details format.""" details: dict[str, Any] = { @@ -49,6 +53,8 @@ def to_problem_details(self, challenge_id: str | None = None) -> dict[str, Any]: } if challenge_id is not None: details["challengeId"] = challenge_id + if self.details is not None: + details["details"] = self.details return details @@ -84,11 +90,16 @@ def __init__(self, challenge_id: str | None = None, reason: str | None = None) - class VerificationFailedError(PaymentError): """Payment proof is invalid or verification failed.""" - def __init__(self, reason: str | None = None) -> None: + def __init__( + self, + reason: str | None = None, + *, + details: dict[str, Any] | None = None, + ) -> None: msg = ( f"Payment verification failed: {reason}." if reason else "Payment verification failed." ) - super().__init__(msg) + super().__init__(msg, details=details) class PaymentExpiredError(PaymentError): diff --git a/src/mpp/extensions/mcp/verify.py b/src/mpp/extensions/mcp/verify.py index 8388821f..4f6f343e 100644 --- a/src/mpp/extensions/mcp/verify.py +++ b/src/mpp/extensions/mcp/verify.py @@ -194,12 +194,16 @@ def new_challenge() -> MCPChallenge: if expires_dt < datetime.now(UTC): return new_challenge() - from mpp.server.intent import VerificationError + from mpp.server.intent import VerificationError, broadcast_credential core_credential = mcp_credential.to_core() try: - core_receipt = await intent.verify(core_credential, request) + core_receipt = await broadcast_credential( + intent=intent, + credential=core_credential, + request=request, + ) except VerificationError as e: raise PaymentVerificationError( challenges=[new_challenge()], diff --git a/src/mpp/methods/tempo/__init__.py b/src/mpp/methods/tempo/__init__.py index 6ec6aa3a..8189d43d 100644 --- a/src/mpp/methods/tempo/__init__.py +++ b/src/mpp/methods/tempo/__init__.py @@ -44,6 +44,7 @@ "mpp.methods.tempo.account": ("TempoAccount",), "mpp.methods.tempo.client": ("TempoMethod", "TransactionError", "tempo"), "mpp.methods.tempo.intents": ("ChargeIntent", "Transfer", "get_transfers"), + "mpp.methods.tempo.relay": ("Relay", "RelayErrorCode"), "mpp.methods.tempo.schemas": ("Split",), } diff --git a/src/mpp/methods/tempo/__init__.pyi b/src/mpp/methods/tempo/__init__.pyi index 0d0cd24c..040981fc 100644 --- a/src/mpp/methods/tempo/__init__.pyi +++ b/src/mpp/methods/tempo/__init__.pyi @@ -12,6 +12,8 @@ from mpp.methods.tempo.client import tempo as _tempo from mpp.methods.tempo.intents import ChargeIntent as _ChargeIntent from mpp.methods.tempo.intents import Transfer as _Transfer from mpp.methods.tempo.intents import get_transfers as _get_transfers +from mpp.methods.tempo.relay import Relay as _Relay +from mpp.methods.tempo.relay import RelayErrorCode as _RelayErrorCode from mpp.methods.tempo.schemas import Split as _Split CHAIN_ID = _CHAIN_ID @@ -28,4 +30,6 @@ tempo = _tempo ChargeIntent = _ChargeIntent Transfer = _Transfer get_transfers = _get_transfers +Relay = _Relay +RelayErrorCode = _RelayErrorCode Split = _Split diff --git a/src/mpp/methods/tempo/client.py b/src/mpp/methods/tempo/client.py index 3bd5201b..4503542b 100644 --- a/src/mpp/methods/tempo/client.py +++ b/src/mpp/methods/tempo/client.py @@ -23,6 +23,7 @@ if TYPE_CHECKING: from mpp.methods.tempo.account import TempoAccount + from mpp.methods.tempo.relay import Relay from mpp.server.intent import Intent @@ -393,6 +394,7 @@ def tempo( recipient: str | None = None, decimals: int = 6, client_id: str | None = None, + relay: Relay | None = None, ) -> TempoMethod: """Create a Tempo payment method. @@ -414,6 +416,8 @@ def tempo( recipient: Default recipient address for charges. decimals: Token decimal places for amount conversion (default: 6). client_id: Optional client identity for attribution memos. + relay: Optional server-side Tempo API relay adapter. Applies only to + the charge intent. Returns: A configured TempoMethod instance. @@ -468,5 +472,11 @@ def tempo( intent.rpc_url = rpc_url # type: ignore[union-attr] if hasattr(intent, "_method"): intent._method = method # type: ignore[union-attr] - method._intents = dict(intents) + configured_intents = dict(intents) + if relay is not None: + charge_intent = configured_intents.get("charge") + if charge_intent is None: + raise ValueError("relay requires a charge intent") + configured_intents["charge"] = relay.configure(charge_intent) + method._intents = configured_intents return method diff --git a/src/mpp/methods/tempo/intents.py b/src/mpp/methods/tempo/intents.py index a43e95bc..eb567b87 100644 --- a/src/mpp/methods/tempo/intents.py +++ b/src/mpp/methods/tempo/intents.py @@ -14,6 +14,7 @@ from mpp import Credential, Receipt from mpp._defaults import DEFAULT_TIMEOUT +from mpp._validation import Validation from mpp.errors import VerificationError from mpp.methods.tempo._defaults import PATH_USD, rpc_url_for_chain from mpp.methods.tempo.fee_payer_policy import get_policy @@ -413,23 +414,12 @@ async def _get_client(self) -> httpx.AsyncClient: self._http_client = httpx.AsyncClient(timeout=self._timeout) return self._http_client - async def verify( + def _prepare_credential( self, credential: Credential, request: dict[str, Any], - ) -> Receipt: - """Verify a charge credential. - - Args: - credential: The payment credential from the client. - request: The original payment request parameters. - - Returns: - A receipt indicating success or failure. - - Raises: - VerificationError: If verification fails. - """ + ) -> tuple[ChargeRequest, CredentialPayload]: + """Parse and validate the non-mutating credential envelope.""" req = ChargeRequest.model_validate(request) # Expiry is conveyed via the challenge-level expires auth-param, @@ -453,24 +443,76 @@ async def verify( else: raise VerificationError(f"Invalid credential type: {payload_data['type']}") + return req, payload + + async def validate( + self, + credential: Credential, + request: dict[str, Any], + ) -> Validation: + """Validate a charge credential without consuming or broadcasting it.""" + req, payload = self._prepare_credential(credential, request) + if isinstance(payload, HashCredentialPayload): - return await self._verify_hash( + await self._validate_hash( payload, req, challenge_id=credential.challenge.id, realm=credential.challenge.realm, ) + details: dict[str, Any] = {"mode": "push"} else: - return await self._verify_transaction(payload, req) + self._validate_transaction_payload(payload.signature, req) + details = { + "mode": "pull", + "serializedTransaction": payload.signature, + } + + return Validation( + challenge=credential.challenge, + credential=credential, + details=details, + intent=self.name, + method=credential.challenge.method, + request=dict(request), + source=credential.source, + ) + + async def broadcast( + self, + credential: Credential, + request: dict[str, Any], + ) -> Receipt: + """Revalidate and perform the terminal charge operation.""" + req, payload = self._prepare_credential(credential, request) - async def _verify_hash( + if isinstance(payload, HashCredentialPayload): + receipt = await self._validate_hash( + payload, + req, + challenge_id=credential.challenge.id, + realm=credential.challenge.realm, + ) + await self._mark_hash_used(payload.hash) + return receipt + return await self._broadcast_transaction(payload, req) + + async def verify( + self, + credential: Credential, + request: dict[str, Any], + ) -> Receipt: + """Legacy alias for the mutating broadcast path.""" + return await self.broadcast(credential, request) + + async def _validate_hash( self, payload: HashCredentialPayload, request: ChargeRequest, challenge_id: str, realm: str, ) -> Receipt: - """Verify a credential with a transaction hash.""" + """Validate a hash credential without consuming its replay key.""" client = await self._get_client() rpc_url = self._get_rpc_url() @@ -513,13 +555,15 @@ async def _verify_hash( realm=realm, ) - if self._store is not None: - store_key = f"mpp:charge:{payload.hash.lower()}" - if not await self._store.put_if_absent(store_key, payload.hash): - raise VerificationError("Transaction hash already used") - return Receipt.success(payload.hash) + async def _mark_hash_used(self, tx_hash: str) -> None: + if self._store is None: + return + store_key = f"mpp:charge:{tx_hash.lower()}" + if not await self._store.put_if_absent(store_key, tx_hash): + raise VerificationError("Transaction hash already used") + def _assert_challenge_bound_memo( self, matched_logs: list[MatchedTransferLog], @@ -705,7 +749,7 @@ def _verify_transfer_logs( return all_matches - async def _verify_transaction( + async def _broadcast_transaction( self, payload: TransactionCredentialPayload, request: ChargeRequest, @@ -788,10 +832,7 @@ async def _verify_transaction( if not tx_hash: raise VerificationError("No transaction hash returned") - if self._store is not None: - store_key = f"mpp:charge:{tx_hash.lower()}" - if not await self._store.put_if_absent(store_key, tx_hash): - raise VerificationError("Transaction hash already used") + await self._mark_hash_used(tx_hash) return Receipt.success(tx_hash) diff --git a/src/mpp/methods/tempo/relay.py b/src/mpp/methods/tempo/relay.py new file mode 100644 index 00000000..7584a1f0 --- /dev/null +++ b/src/mpp/methods/tempo/relay.py @@ -0,0 +1,315 @@ +"""Tempo API relay adapter for server-side charge verification.""" + +from __future__ import annotations + +import hashlib +import json +import logging +from datetime import datetime +from typing import TYPE_CHECKING, Any, Final, Literal, cast + +from mpp import Credential, Receipt +from mpp._defaults import DEFAULT_TIMEOUT +from mpp._parsing import ParseError, _b64_decode +from mpp.errors import PaymentExpiredError, VerificationFailedError + +if TYPE_CHECKING: + import httpx + + from mpp.server.intent import Intent, SplitIntent + +from mpp.server.intent import Validation + +DEFAULT_API_BASE_URL = "https://api.tempo.xyz" +logger = logging.getLogger(__name__) + +_VALIDATE_PATH: Final = "v1/mpp/validate" +_BROADCAST_PATH: Final = "v1/mpp/broadcast" +_ACCEPT_HEADER: Final = "Accept" +_CONTENT_TYPE_HEADER: Final = "content-type" +_TEMPO_API_KEY_HEADER: Final = "tempo-api-key" +_IDEMPOTENCY_KEY_HEADER: Final = "idempotency-key" +_JSON_MEDIA_TYPE: Final = "application/json" + +RelayErrorCode = Literal[ + "already_used", + "broadcast_failed", + "expired", + "invalid_payment", + "insufficient_funds", + "policy_denied", + "screen_rejected", + "simulation_failed", + "temporarily_unavailable", + "unsupported", + "unknown", +] + +_RELAY_ERROR_CODES = frozenset(cast("tuple[str, ...]", RelayErrorCode.__args__)) +_SAFE_ERROR_CODES = frozenset( + { + "already_used", + "broadcast_failed", + "invalid_payment", + "insufficient_funds", + "simulation_failed", + "temporarily_unavailable", + "unsupported", + } +) + + +class Relay: + """Delegate Tempo charge validation and finalization to an MPP relay. + + The relay validates every submitted credential, broadcasts pull-mode + transactions, and finalizes already-broadcast push-mode transaction hashes. + """ + + def __init__( + self, + api_key: str, + api_base_url: str = DEFAULT_API_BASE_URL, + http_client: httpx.AsyncClient | None = None, + timeout: float = DEFAULT_TIMEOUT, + ) -> None: + """Create a relay adapter. + + Args: + api_key: Tempo API key with the ``mpp:write`` scope. + api_base_url: Tempo API or compatible relay base URL. A path prefix + is preserved. Defaults to ``https://api.tempo.xyz``. + http_client: Optional HTTP client. The caller owns injected clients. + timeout: HTTP timeout used by an internally created client. + """ + if not api_key: + raise ValueError("api_key is required") + if not api_base_url: + raise ValueError("api_base_url is required") + + self.api_key = api_key + self.api_base_url = api_base_url.rstrip("/") + "/" + self._http_client = http_client + self._owns_client = http_client is None + self._timeout = timeout + + def configure(self, intent: Intent) -> SplitIntent: + """Wrap a charge intent with relay-backed verification.""" + if intent.name != "charge": + raise ValueError("Relay can only configure a charge intent") + return _RelayChargeIntent(self) + + async def __aenter__(self) -> Relay: + await self._get_client() + return self + + async def __aexit__(self, *args: Any) -> None: + await self.aclose() + + async def aclose(self) -> None: + """Close the internally owned HTTP client, if one was created.""" + if self._owns_client and self._http_client is not None: + await self._http_client.aclose() + self._http_client = None + + async def _get_client(self) -> httpx.AsyncClient: + if self._http_client is None: + import httpx + + self._http_client = httpx.AsyncClient(timeout=self._timeout) + return self._http_client + + async def _post( + self, + path: str, + relay_input: dict[str, Any], + headers: dict[str, str] | None = None, + ) -> Any: + client = await self._get_client() + logger.debug("relay request method=POST path=/%s", path) + try: + response = await client.post( + self.api_base_url + path, + json=relay_input, + headers={ + _ACCEPT_HEADER: _JSON_MEDIA_TYPE, + _CONTENT_TYPE_HEADER: _JSON_MEDIA_TYPE, + _TEMPO_API_KEY_HEADER: self.api_key, + **(headers or {}), + }, + ) + except Exception: + raise _failure() from None + + logger.debug("relay response path=/%s status=%d", path, response.status_code) + if not response.is_success: + raise _failure() + try: + return response.json() + except Exception: + raise _failure() from None + + async def _validate(self, relay_input: dict[str, Any]) -> None: + result = await self._post(_VALIDATE_PATH, relay_input) + if not isinstance(result, dict) or result.get("success") is not True: + raise _failure(result) + + async def _broadcast(self, relay_input: dict[str, Any]) -> Receipt: + result = await self._post( + _BROADCAST_PATH, + relay_input, + {_IDEMPOTENCY_KEY_HEADER: _idempotency_key(relay_input)}, + ) + if not isinstance(result, dict) or result.get("success") is not True: + raise _failure(result) + return _receipt_from(result.get("receipt")) + + +class _RelayChargeIntent: + name = "charge" + + def __init__(self, relay: Relay) -> None: + self._relay = relay + + async def validate( + self, + credential: Credential, + request: dict[str, Any], + ) -> Validation: + """Validate a credential through the relay without finalizing it.""" + relay_input = _relay_input(credential) + await self._relay._validate(relay_input) + return Validation( + challenge=credential.challenge, + credential=credential, + details={}, + intent=self.name, + method=credential.challenge.method, + request=request, + source=credential.source, + ) + + async def broadcast(self, credential: Credential, request: dict[str, Any]) -> Receipt: + """Finalize a credential through the relay.""" + relay_input = _relay_input(credential) + return await self._relay._broadcast(relay_input) + + async def verify(self, credential: Credential, request: dict[str, Any]) -> Receipt: + """Legacy combined validation and finalization hook.""" + await self.validate(credential, request) + return await self.broadcast(credential, request) + + async def aclose(self) -> None: + await self._relay.aclose() + + async def __aenter__(self) -> _RelayChargeIntent: + await self._relay.__aenter__() + return self + + async def __aexit__(self, *args: Any) -> None: + await self.aclose() + + +def _relay_input(credential: Credential) -> dict[str, Any]: + try: + request = _b64_decode(credential.challenge.request) + except ParseError: + raise _failure() from None + + challenge = { + "id": credential.challenge.id, + "realm": credential.challenge.realm, + "method": credential.challenge.method, + "intent": credential.challenge.intent, + "request": request, + } + if credential.challenge.expires is not None: + challenge["expires"] = credential.challenge.expires + if credential.challenge.digest is not None: + challenge["digest"] = credential.challenge.digest + if credential.challenge.opaque is not None: + challenge["opaque"] = credential.challenge.opaque + + relay_input: dict[str, Any] = { + "challenge": challenge, + "payload": credential.payload, + } + if credential.source: + relay_input["source"] = credential.source + return relay_input + + +def _idempotency_key(relay_input: dict[str, Any]) -> str: + payload = relay_input.get("payload") + if isinstance(payload, dict): + signature = payload.get("signature") + if payload.get("type") == "transaction" and isinstance(signature, str): + try: + raw_signature = bytes.fromhex(signature.removeprefix("0x")) + except ValueError: + pass + else: + from eth_hash.auto import keccak + + return f"pympp_0x{keccak(raw_signature).hex()}" + + canonical = json.dumps( + relay_input, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode() + return f"pympp_0x{hashlib.sha256(canonical).hexdigest()}" + + +def _receipt_from(value: Any) -> Receipt: + if not isinstance(value, dict): + raise _failure() + + method = value.get("method") + reference = value.get("reference") + timestamp_value = value.get("timestamp") + external_id = value.get("externalId") + if ( + method != "tempo" + or not isinstance(reference, str) + or not isinstance(timestamp_value, str) + or (external_id is not None and not isinstance(external_id, str)) + ): + raise _failure() + + try: + timestamp = datetime.fromisoformat(timestamp_value.replace("Z", "+00:00")) + except ValueError: + raise _failure() from None + if timestamp.tzinfo is None: + raise _failure() + + return Receipt.success( + reference, + timestamp=timestamp, + method=method, + external_id=external_id, + ) + + +def _failure(value: Any = None) -> VerificationFailedError | PaymentExpiredError: + code = _relay_error_code(value) + if code == "expired": + return PaymentExpiredError() + if code in _SAFE_ERROR_CODES: + details = {"code": code} + if code == "temporarily_unavailable": + details["retry"] = "same_credential" + return VerificationFailedError(details=details) + return VerificationFailedError() + + +def _relay_error_code(value: Any) -> str | None: + if not isinstance(value, dict): + return None + error = value.get("error") + if not isinstance(error, dict): + return None + code = error.get("code") + return code if isinstance(code, str) and code in _RELAY_ERROR_CODES else None diff --git a/src/mpp/server/__init__.py b/src/mpp/server/__init__.py index 45bf854d..c373fa07 100644 --- a/src/mpp/server/__init__.py +++ b/src/mpp/server/__init__.py @@ -32,7 +32,16 @@ VerificationFailedError, ) from mpp.server.decorator import pay -from mpp.server.intent import Intent, VerificationError, intent +from mpp.server.intent import ( + Intent, + SplitIntent, + Validation, + VerificationError, + broadcast_credential, + intent, + validate_credential, + verify_credential, +) from mpp.server.method import Method, transform_request from mpp.server.mpp import Mpp from mpp.server.verify import verify_or_challenge diff --git a/src/mpp/server/intent.py b/src/mpp/server/intent.py index a785f14c..def09397 100644 --- a/src/mpp/server/intent.py +++ b/src/mpp/server/intent.py @@ -7,12 +7,13 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable if TYPE_CHECKING: from mpp import Credential, Receipt +from mpp._validation import Validation from mpp.errors import VerificationError as VerificationError # noqa: F401 — re-export @@ -58,6 +59,98 @@ async def verify( ... +@runtime_checkable +class SplitIntent(Intent, Protocol): + """Intent with separate non-mutating validation and terminal broadcast hooks.""" + + async def validate( + self, + credential: Credential, + request: dict[str, Any], + ) -> Validation: + """Validate without settling, reserving, or consuming payment state.""" + ... + + async def broadcast( + self, + credential: Credential, + request: dict[str, Any], + ) -> Receipt: + """Perform the terminal payment operation and return its receipt.""" + ... + + +async def validate_credential( + *, + intent: Intent, + credential: Credential, + request: dict[str, Any], +) -> Validation: + """Run an intent's non-mutating validation hook. + + Legacy intents that only implement ``verify`` cannot safely support this + operation because verification may consume payment state. + """ + validate = cast( + "Callable[[Credential, dict[str, Any]], Awaitable[Validation]] | None", + getattr(intent, "validate", None), + ) + if not callable(validate): + from mpp.errors import VerificationFailedError + + raise VerificationFailedError( + f"{intent.name} does not support non-mutating credential validation" + ) + + result = await validate(credential, request) + if not isinstance(result, Validation): + from mpp.errors import VerificationFailedError + + raise VerificationFailedError("Intent returned an invalid validation result") + return result + + +async def broadcast_credential( + *, + intent: Intent, + credential: Credential, + request: dict[str, Any], +) -> Receipt: + """Revalidate and perform an intent's terminal payment operation. + + Split intents run ``validate`` before ``broadcast``. Legacy intents fall + back to their combined ``verify`` hook. + """ + broadcast = cast( + "Callable[[Credential, dict[str, Any]], Awaitable[Receipt]] | None", + getattr(intent, "broadcast", None), + ) + if callable(broadcast): + await validate_credential(intent=intent, credential=credential, request=request) + return await broadcast(credential, request) + + verify = cast( + "Callable[[Credential, dict[str, Any]], Awaitable[Receipt]] | None", + getattr(intent, "verify", None), + ) + if callable(verify): + return await verify(credential, request) + + from mpp.errors import VerificationFailedError + + raise VerificationFailedError(f"{intent.name} does not support credential broadcast") + + +async def verify_credential( + *, + intent: Intent, + credential: Credential, + request: dict[str, Any], +) -> Receipt: + """Legacy alias for :func:`broadcast_credential`.""" + return await broadcast_credential(intent=intent, credential=credential, request=request) + + class FunctionalIntent: """Intent wrapper for function-based definitions.""" diff --git a/src/mpp/server/mpp.py b/src/mpp/server/mpp.py index 1a3f7e7a..3027f3c6 100644 --- a/src/mpp/server/mpp.py +++ b/src/mpp/server/mpp.py @@ -6,15 +6,38 @@ from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any, TypeVar -from mpp import Challenge, Credential, Receipt -from mpp._units import parse_units +from mpp import ( + Challenge, + Credential, + Receipt, + _constant_time_equal, + generate_challenge_id, +) +from mpp._parsing import ParseError, _b64_decode +from mpp._units import parse_units, transform_units +from mpp.errors import ( + InvalidChallengeError, + MalformedCredentialError, + PaymentExpiredError, + PaymentMethodUnsupportedError, +) from mpp.server._defaults import detect_realm, detect_secret_key from mpp.server.decorator import wrap_payment_handler +from mpp.server.intent import ( + Validation, +) +from mpp.server.intent import ( + broadcast_credential as broadcast_intent_credential, +) +from mpp.server.intent import ( + validate_credential as validate_intent_credential, +) 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.intent import Intent from mpp.server.method import Method R = TypeVar("R") @@ -90,6 +113,122 @@ def _wire_store(self, store: Store) -> None: if hasattr(intent_obj, "_store") and intent_obj._store is None: intent_obj._store = store + def _prepare_standalone_credential( + self, + value: Credential | str, + *, + intent: str | None, + request: dict[str, Any] | None, + ) -> tuple[Credential, Intent, dict[str, Any]]: + """Authenticate and resolve a credential outside the HTTP challenge flow.""" + if isinstance(value, Credential): + credential = value + else: + value = value.strip() + authorization = value if value.lower().startswith("payment ") else f"Payment {value}" + try: + credential = Credential.from_authorization(authorization) + except ParseError as err: + raise MalformedCredentialError() from err + + echo = credential.challenge + try: + echoed_request = _b64_decode(echo.request) if echo.request else {} + echoed_opaque = _b64_decode(echo.opaque) if echo.opaque else None + except ParseError as err: + raise MalformedCredentialError() from err + + if echo.realm != self.realm: + raise InvalidChallengeError(echo.id) + if echo.method != self.method.name: + raise PaymentMethodUnsupportedError(echo.method) + if intent is not None and echo.intent != intent: + raise InvalidChallengeError(echo.id) + + expected_id = generate_challenge_id( + secret_key=self.secret_key, + realm=echo.realm, + method=echo.method, + intent=echo.intent, + request=echoed_request, + expires=echo.expires, + digest=echo.digest, + opaque=echoed_opaque, + ) + if not _constant_time_equal(echo.id, expected_id): + raise InvalidChallengeError(echo.id) + + if not echo.expires: + raise PaymentExpiredError() + try: + expires_at = datetime.fromisoformat(echo.expires.replace("Z", "+00:00")) + if expires_at.tzinfo is None: + raise ValueError("expires must include a timezone") + except (TypeError, ValueError) as err: + raise PaymentExpiredError() from err + if expires_at < datetime.now(UTC): + raise PaymentExpiredError(echo.expires) + + if request is not None and transform_units(request) != echoed_request: + raise InvalidChallengeError(echo.id, "credential request does not match") + + intent_obj = self.method.intents.get(echo.intent) + if intent_obj is None: + raise PaymentMethodUnsupportedError(f"{echo.method}/{echo.intent}") + return credential, intent_obj, echoed_request + + async def validate_credential( + self, + credential: Credential | str, + *, + intent: str | None = None, + request: dict[str, Any] | None = None, + ) -> Validation: + """Validate a bound credential without consuming or broadcasting it.""" + prepared, intent_obj, echoed_request = self._prepare_standalone_credential( + credential, + intent=intent, + request=request, + ) + return await validate_intent_credential( + intent=intent_obj, + credential=prepared, + request=echoed_request, + ) + + async def broadcast_credential( + self, + credential: Credential | str, + *, + intent: str | None = None, + request: dict[str, Any] | None = None, + ) -> Receipt: + """Revalidate and perform the terminal operation for a bound credential.""" + prepared, intent_obj, echoed_request = self._prepare_standalone_credential( + credential, + intent=intent, + request=request, + ) + return await broadcast_intent_credential( + intent=intent_obj, + credential=prepared, + request=echoed_request, + ) + + async def verify_credential( + self, + credential: Credential | str, + *, + intent: str | None = None, + request: dict[str, Any] | None = None, + ) -> Receipt: + """Legacy alias for :meth:`broadcast_credential`.""" + return await self.broadcast_credential( + credential, + intent=intent, + request=request, + ) + @classmethod def create( cls, diff --git a/src/mpp/server/verify.py b/src/mpp/server/verify.py index 418cacaf..76ec6887 100644 --- a/src/mpp/server/verify.py +++ b/src/mpp/server/verify.py @@ -8,6 +8,7 @@ from mpp import Challenge, Credential, Receipt, _constant_time_equal, generate_challenge_id from mpp._parsing import ParseError, _b64_decode from mpp._units import transform_units +from mpp.server.intent import broadcast_credential DEFAULT_EXPIRES_MINUTES = 5 @@ -158,7 +159,11 @@ def new_challenge() -> Challenge: if expires_dt < datetime.now(UTC): return new_challenge() - receipt: Receipt = await intent.verify(credential, request) + receipt: Receipt = await broadcast_credential( + intent=intent, + credential=credential, + request=request, + ) return (credential, receipt) diff --git a/tests/test_errors.py b/tests/test_errors.py index 0f4649d1..2bc0221e 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -126,6 +126,10 @@ def test_verification_failed_no_reason(self) -> None: err = VerificationFailedError() assert "verification failed" in str(err).lower() + def test_verification_failed_safe_details(self) -> None: + err = VerificationFailedError(details={"code": "insufficient_funds"}) + assert err.to_problem_details()["details"] == {"code": "insufficient_funds"} + def test_payment_expired_with_timestamp(self) -> None: err = PaymentExpiredError(expires="2024-01-01T00:00:00Z") assert "2024-01-01" in str(err) diff --git a/tests/test_server.py b/tests/test_server.py index 255c34a3..3cfca3cd 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,9 +1,29 @@ """Tests for server-side verification.""" +from dataclasses import replace +from datetime import UTC, datetime, timedelta + import pytest from mpp import Challenge, Credential, Receipt -from mpp.server import Mpp, intent, pay, verify_or_challenge +from mpp.errors import ( + InvalidChallengeError, + MalformedCredentialError, + PaymentExpiredError, + PaymentMethodUnsupportedError, + VerificationFailedError, +) +from mpp.server import ( + Intent, + Mpp, + Validation, + broadcast_credential, + intent, + pay, + validate_credential, + verify_credential, + verify_or_challenge, +) from mpp.server.intent import VerificationError from tests import make_bound_credential, make_credential @@ -140,6 +160,370 @@ async def verify(self, credential: Credential, request: dict) -> Receipt: assert receipt.reference == "custom-ref" +class TestSplitIntentLifecycle: + class SplitCharge: + name = "charge" + + def __init__(self) -> None: + self.calls: list[str] = [] + + async def validate(self, credential: Credential, request: dict) -> Validation: + self.calls.append("validate") + return Validation( + challenge=credential.challenge, + credential=credential, + details={"mode": "pull"}, + intent=self.name, + method=credential.challenge.method, + request=request, + source=credential.source, + ) + + async def broadcast(self, credential: Credential, request: dict) -> Receipt: + self.calls.append("broadcast") + return Receipt.success("split-ref") + + async def verify(self, credential: Credential, request: dict) -> Receipt: + self.calls.append("legacy-verify") + return Receipt.success("legacy-ref") + + @staticmethod + def server(split: Intent) -> Mpp: + class TestMethod: + name = "tempo" + intents: dict[str, Intent] = {"charge": split} + + async def create_credential(self, challenge: Challenge) -> Credential: + raise NotImplementedError + + return Mpp.create( + method=TestMethod(), + realm="api.example.com", + secret_key="test-secret", + ) + + @pytest.mark.asyncio + async def test_validate_does_not_broadcast(self) -> None: + split = self.SplitCharge() + credential = make_credential(payload={}) + + result = await validate_credential(intent=split, credential=credential, request={}) + + assert result.details == {"mode": "pull"} + assert split.calls == ["validate"] + + @pytest.mark.asyncio + async def test_broadcast_revalidates_before_terminal_operation(self) -> None: + split = self.SplitCharge() + credential = make_credential(payload={}) + + receipt = await broadcast_credential(intent=split, credential=credential, request={}) + + assert receipt.reference == "split-ref" + assert split.calls == ["validate", "broadcast"] + + @pytest.mark.asyncio + async def test_mpp_broadcast_calls_validate_hook_first(self) -> None: + split = self.SplitCharge() + credential = make_bound_credential( + payload={}, + request={}, + realm="api.example.com", + secret_key="test-secret", + ) + + receipt = await self.server(split).broadcast_credential(credential) + + assert receipt.reference == "split-ref" + assert split.calls == ["validate", "broadcast"] + + @pytest.mark.asyncio + async def test_mpp_broadcast_stops_when_validate_hook_fails(self) -> None: + class RejectingSplit(self.SplitCharge): + async def validate(self, credential: Credential, request: dict) -> Validation: + self.calls.append("validate") + raise VerificationFailedError("rejected") + + split = RejectingSplit() + credential = make_bound_credential( + payload={}, + request={}, + realm="api.example.com", + secret_key="test-secret", + ) + + with pytest.raises(VerificationFailedError, match="rejected"): + await self.server(split).broadcast_credential(credential) + + assert split.calls == ["validate"] + + @pytest.mark.asyncio + async def test_route_uses_split_lifecycle(self) -> None: + split = self.SplitCharge() + credential = make_bound_credential( + payload={}, + request={"amount": "1000"}, + realm="api.example.com", + secret_key="test-secret", + ) + + result = await verify_or_challenge( + authorization=credential.to_authorization(), + intent=split, + request={"amount": "1000"}, + realm="api.example.com", + secret_key="test-secret", + ) + + assert isinstance(result, tuple) + assert result[1].reference == "split-ref" + assert split.calls == ["validate", "broadcast"] + + @pytest.mark.asyncio + async def test_validation_failure_prevents_broadcast(self) -> None: + class FailingSplit(self.SplitCharge): + async def validate(self, credential: Credential, request: dict) -> Validation: + self.calls.append("validate") + raise VerificationFailedError("invalid") + + split = FailingSplit() + + with pytest.raises(VerificationFailedError, match="invalid"): + await broadcast_credential( + intent=split, + credential=make_credential(payload={}), + request={}, + ) + + assert split.calls == ["validate"] + + @pytest.mark.asyncio + async def test_legacy_intent_only_supports_terminal_lifecycle(self) -> None: + calls: list[str] = [] + + class LegacyCharge: + name = "charge" + + async def verify(self, credential: Credential, request: dict) -> Receipt: + calls.append("verify") + return Receipt.success("legacy-ref") + + legacy = LegacyCharge() + credential = make_credential(payload={}) + + with pytest.raises(VerificationFailedError, match="does not support non-mutating"): + await validate_credential(intent=legacy, credential=credential, request={}) + + receipt = await broadcast_credential(intent=legacy, credential=credential, request={}) + assert receipt.reference == "legacy-ref" + assert calls == ["verify"] + + @pytest.mark.asyncio + async def test_broadcast_hook_requires_non_mutating_validation(self) -> None: + class UnsafeBroadcast: + name = "charge" + + async def broadcast(self, credential: Credential, request: dict) -> Receipt: + return Receipt.success("unsafe-ref") + + with pytest.raises(VerificationFailedError, match="does not support non-mutating"): + await broadcast_credential( + intent=UnsafeBroadcast(), # type: ignore[arg-type] + credential=make_credential(payload={}), + request={}, + ) + + @pytest.mark.asyncio + async def test_rejects_invalid_validation_result(self) -> None: + class InvalidSplit(self.SplitCharge): + async def validate( # type: ignore[override] + self, credential: Credential, request: dict + ) -> dict: + return {} + + with pytest.raises(VerificationFailedError, match="invalid validation result"): + await validate_credential( + intent=InvalidSplit(), + credential=make_credential(payload={}), + request={}, + ) + + @pytest.mark.asyncio + async def test_mpp_exposes_bound_standalone_lifecycle(self) -> None: + split = self.SplitCharge() + server = self.server(split) + request = {"amount": "1000"} + credential = make_bound_credential( + payload={}, + request=request, + realm="api.example.com", + secret_key="test-secret", + ) + + validation = await server.validate_credential( + credential.to_authorization(), request=request + ) + receipt = await server.broadcast_credential(credential, intent="charge", request=request) + bare_credential = credential.to_authorization().removeprefix("Payment ") + legacy_receipt = await server.verify_credential(bare_credential) + + assert validation.request == request + assert receipt.reference == "split-ref" + assert legacy_receipt.reference == "split-ref" + assert split.calls == [ + "validate", + "validate", + "broadcast", + "validate", + "broadcast", + ] + + with pytest.raises(InvalidChallengeError, match="credential request does not match"): + await server.validate_credential(credential, request={"amount": "2000"}) + + @pytest.mark.asyncio + async def test_low_level_verify_alias_uses_split_lifecycle(self) -> None: + split = self.SplitCharge() + + receipt = await verify_credential( + intent=split, + credential=make_credential(payload={}), + request={}, + ) + + assert receipt.reference == "split-ref" + assert split.calls == ["validate", "broadcast"] + + @pytest.mark.asyncio + async def test_rejects_intent_without_terminal_hook(self) -> None: + class ValidationOnly: + name = "charge" + + async def validate(self, credential: Credential, request: dict) -> Validation: + return Validation( + challenge=credential.challenge, + credential=credential, + details={}, + intent=self.name, + method=credential.challenge.method, + request=request, + ) + + with pytest.raises(VerificationFailedError, match="does not support credential broadcast"): + await broadcast_credential( + intent=ValidationOnly(), # type: ignore[arg-type] + credential=make_credential(payload={}), + request={}, + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("field", "value", "error"), + [ + ("realm", "other.example.com", InvalidChallengeError), + ("method", "stripe", PaymentMethodUnsupportedError), + ("id", "tampered", InvalidChallengeError), + ], + ) + async def test_standalone_rejects_mismatched_challenge_fields( + self, + field: str, + value: str, + error: type[Exception], + ) -> None: + credential = make_bound_credential( + payload={}, + request={}, + realm="api.example.com", + secret_key="test-secret", + ) + credential = replace( + credential, + challenge=replace(credential.challenge, **{field: value}), + ) + + with pytest.raises(error): + await self.server(self.SplitCharge()).validate_credential(credential) + + @pytest.mark.asyncio + async def test_standalone_rejects_requested_intent_mismatch(self) -> None: + credential = make_bound_credential( + payload={}, + request={}, + realm="api.example.com", + secret_key="test-secret", + ) + + with pytest.raises(InvalidChallengeError): + await self.server(self.SplitCharge()).validate_credential( + credential, + intent="refund", + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("value", ["not-a-credential", "Payment not-base64"]) + async def test_standalone_rejects_malformed_serialized_credential(self, value: str) -> None: + with pytest.raises(MalformedCredentialError): + await self.server(self.SplitCharge()).validate_credential(value) + + @pytest.mark.asyncio + @pytest.mark.parametrize("field", ["request", "opaque"]) + async def test_standalone_rejects_malformed_bound_fields(self, field: str) -> None: + credential = make_bound_credential( + payload={}, + request={}, + realm="api.example.com", + secret_key="test-secret", + ) + credential = replace( + credential, + challenge=replace(credential.challenge, **{field: "not-base64"}), + ) + + with pytest.raises(MalformedCredentialError): + await self.server(self.SplitCharge()).validate_credential(credential) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "expires", + [ + None, + "not-a-date", + (datetime.now() + timedelta(hours=1)).isoformat(), + (datetime.now(UTC) - timedelta(hours=1)).isoformat(), + ], + ) + async def test_standalone_rejects_missing_or_invalid_expiry( + self, + expires: str | None, + ) -> None: + challenge = Challenge.create( + secret_key="test-secret", + realm="api.example.com", + method="tempo", + intent="charge", + request={}, + expires=expires, + ) + credential = Credential(challenge=challenge.to_echo(), payload={}) + + with pytest.raises(PaymentExpiredError): + await self.server(self.SplitCharge()).validate_credential(credential) + + @pytest.mark.asyncio + async def test_standalone_rejects_unregistered_intent(self) -> None: + credential = make_bound_credential( + payload={}, + request={}, + realm="api.example.com", + secret_key="test-secret", + intent="refund", + ) + + with pytest.raises(PaymentMethodUnsupportedError): + await self.server(self.SplitCharge()).validate_credential(credential) + + class TestVerificationError: @pytest.mark.asyncio async def test_returns_challenge_on_parse_error(self) -> None: diff --git a/tests/test_tempo.py b/tests/test_tempo.py index a3a1a6b5..04a6f49a 100644 --- a/tests/test_tempo.py +++ b/tests/test_tempo.py @@ -980,6 +980,41 @@ async def test_verify_transaction_accepts_transfer_with_memo_logs(self) -> None: assert receipt.status == "success" assert receipt.reference == "0xtxhash123" + @pytest.mark.asyncio + async def test_validate_hash_is_non_mutating_until_broadcast(self) -> None: + from mpp import Receipt + from mpp.store import MemoryStore + + future = (datetime.now(UTC) + timedelta(hours=1)).isoformat() + store = MemoryStore() + intent = ChargeIntent(rpc_url="https://rpc.test", store=store) + credential = make_credential( + payload={"type": "hash", "hash": "0xabc123"}, + expires=future, + ) + request = { + "amount": "1000", + "currency": "0x1234567890123456789012345678901234567890", + "recipient": "0x4567890123456789012345678901234567890123", + } + + with patch.object( + intent, + "_validate_hash", + new=AsyncMock(return_value=Receipt.success("0xabc123")), + ) as validate_hash: + validation = await intent.validate(credential, request) + assert validation.details == {"mode": "push"} + assert await store.get("mpp:charge:0xabc123") is None + + receipt = await intent.broadcast(credential, request) + assert receipt.reference == "0xabc123" + assert await store.get("mpp:charge:0xabc123") == "0xabc123" + assert validate_hash.await_count == 2 + + with pytest.raises(VerificationError, match="Transaction hash already used"): + await intent.broadcast(credential, request) + @pytest.mark.asyncio async def test_verify_transaction_records_hash_and_blocks_hash_reuse(self) -> None: from mpp.store import MemoryStore diff --git a/tests/test_tempo_relay.py b/tests/test_tempo_relay.py new file mode 100644 index 00000000..b17ea56b --- /dev/null +++ b/tests/test_tempo_relay.py @@ -0,0 +1,445 @@ +"""Tests for the Tempo API relay adapter.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import replace +from datetime import UTC, datetime, timedelta +from typing import Any + +import httpx +import pytest +from eth_hash.auto import keccak + +from mpp.errors import PaymentExpiredError, VerificationFailedError +from mpp.methods.tempo import ChargeIntent, Relay, tempo +from mpp.server import broadcast_credential +from tests import make_credential + +API_BASE_URL = "https://relay.example/mpp" +API_KEY = "tempo_api_key" + + +def _credential( + *, + payload: dict[str, Any] | None = None, + source: str | None = "did:pkh:eip155:42431:0x123", +): + expires = (datetime.now(UTC) + timedelta(minutes=5)).isoformat().replace("+00:00", "Z") + return make_credential( + payload=payload or {"type": "transaction", "signature": "0x1234"}, + challenge_id="challenge_123", + request="eyJhbW91bnQiOiIxMDAifQ", + source=source, + expires=expires, + ) + + +def _success_receipt() -> dict[str, Any]: + return { + "success": True, + "receipt": { + "externalId": "order_123", + "method": "tempo", + "reference": "0xabc", + "timestamp": "2026-07-22T00:00:00.000Z", + }, + } + + +def _relay(handler: Any) -> tuple[Relay, httpx.AsyncClient]: + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + return Relay(API_KEY, API_BASE_URL, http_client=client), client + + +async def test_validates_then_broadcasts_complete_credential() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path.endswith("/validate"): + return httpx.Response(200, json={"success": True}) + return httpx.Response(200, json=_success_receipt()) + + relay, client = _relay(handler) + try: + receipt = await relay.configure(ChargeIntent()).verify(_credential(), {}) + finally: + await client.aclose() + + assert [request.url.path for request in requests] == [ + "/mpp/v1/mpp/validate", + "/mpp/v1/mpp/broadcast", + ] + body = json.loads(requests[0].content) + assert body["challenge"]["id"] == "challenge_123" + assert body["challenge"]["request"] == {"amount": "100"} + assert body["payload"] == {"type": "transaction", "signature": "0x1234"} + assert body["source"] == "did:pkh:eip155:42431:0x123" + assert requests[0].headers["tempo-api-key"] == API_KEY + assert requests[0].headers["accept"] == "application/json" + assert ( + requests[1].headers["idempotency-key"] == f"pympp_0x{keccak(bytes.fromhex('1234')).hex()}" + ) + assert receipt.reference == "0xabc" + assert receipt.external_id == "order_123" + assert receipt.timestamp == datetime(2026, 7, 22, tzinfo=UTC) + + +async def test_split_validate_does_not_broadcast() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json={"success": True}) + + relay, client = _relay(handler) + try: + validation = await relay.configure(ChargeIntent()).validate(_credential(), {}) + finally: + await client.aclose() + + assert validation.details == {} + assert [request.url.path for request in requests] == ["/mpp/v1/mpp/validate"] + + +async def test_split_broadcast_calls_terminal_endpoint_only() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=_success_receipt()) + + relay, client = _relay(handler) + try: + receipt = await relay.configure(ChargeIntent()).broadcast(_credential(), {}) + finally: + await client.aclose() + + assert receipt.reference == "0xabc" + assert [request.url.path for request in requests] == ["/mpp/v1/mpp/broadcast"] + + +async def test_lifecycle_helper_revalidates_relay_before_broadcast() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + result = {"success": True} if request.url.path.endswith("/validate") else _success_receipt() + return httpx.Response(200, json=result) + + relay, client = _relay(handler) + try: + receipt = await broadcast_credential( + intent=relay.configure(ChargeIntent()), + credential=_credential(), + request={}, + ) + finally: + await client.aclose() + + assert receipt.reference == "0xabc" + assert [request.url.path for request in requests] == [ + "/mpp/v1/mpp/validate", + "/mpp/v1/mpp/broadcast", + ] + + +async def test_default_url_omits_absent_source() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + result = {"success": True} if request.url.path.endswith("/validate") else _success_receipt() + return httpx.Response(200, json=result) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + relay = Relay(API_KEY, http_client=client) + try: + await relay.configure(ChargeIntent()).verify(_credential(source=None), {}) + finally: + await client.aclose() + + assert str(requests[0].url) == "https://api.tempo.xyz/v1/mpp/validate" + assert "source" not in json.loads(requests[0].content) + + +async def test_non_transaction_idempotency_key_uses_canonical_input() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + result = {"success": True} if request.url.path.endswith("/validate") else _success_receipt() + return httpx.Response(200, json=result) + + relay, client = _relay(handler) + try: + await relay.configure(ChargeIntent()).verify( + _credential(payload={"type": "proof", "proof": "proof_123"}), + {}, + ) + finally: + await client.aclose() + + relay_input = json.loads(requests[1].content) + canonical = json.dumps( + relay_input, ensure_ascii=False, separators=(",", ":"), sort_keys=True + ).encode() + assert requests[1].headers["idempotency-key"] == ( + f"pympp_0x{hashlib.sha256(canonical).hexdigest()}" + ) + + +async def test_invalid_transaction_hex_uses_canonical_idempotency_key() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + result = {"success": True} if request.url.path.endswith("/validate") else _success_receipt() + return httpx.Response(200, json=result) + + relay, client = _relay(handler) + try: + await relay.configure(ChargeIntent()).verify( + _credential(payload={"type": "transaction", "signature": "not-hex"}), + {}, + ) + finally: + await client.aclose() + + assert requests[1].headers["idempotency-key"].startswith("pympp_0x") + + +async def test_network_failure_is_opaque() -> None: + async def handler(_request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("private relay hostname") + + relay, client = _relay(handler) + try: + with pytest.raises(VerificationFailedError) as exc_info: + await relay.configure(ChargeIntent()).verify(_credential(), {}) + finally: + await client.aclose() + + assert str(exc_info.value) == "Payment verification failed." + assert "hostname" not in str(exc_info.value) + + +@pytest.mark.parametrize( + ("operation", "response"), + [ + ("validate", httpx.Response(500, json={"error": {"code": "invalid_payment"}})), + ("validate", httpx.Response(200, text="not JSON")), + ("validate", httpx.Response(200, json={"success": False})), + ("broadcast", httpx.Response(200, text="not JSON")), + ("broadcast", httpx.Response(200, json={"success": False})), + ("broadcast", httpx.Response(200, json={"success": True, "receipt": {}})), + ( + "broadcast", + httpx.Response( + 200, + json={ + "success": True, + "receipt": { + "method": "stripe", + "reference": "0xabc", + "timestamp": "2026-07-22T00:00:00Z", + }, + }, + ), + ), + ], +) +async def test_boundary_failures_are_opaque(operation: str, response: httpx.Response) -> None: + async def handler(request: httpx.Request) -> httpx.Response: + if operation == "validate" or request.url.path.endswith("/broadcast"): + return response + return httpx.Response(200, json={"success": True}) + + relay, client = _relay(handler) + try: + with pytest.raises(VerificationFailedError) as exc_info: + await relay.configure(ChargeIntent()).verify(_credential(), {}) + finally: + await client.aclose() + + assert str(exc_info.value) == "Payment verification failed." + assert exc_info.value.details is None + + +@pytest.mark.parametrize( + ("code", "details"), + [ + ("already_used", {"code": "already_used"}), + ("broadcast_failed", {"code": "broadcast_failed"}), + ("invalid_payment", {"code": "invalid_payment"}), + ("insufficient_funds", {"code": "insufficient_funds"}), + ("simulation_failed", {"code": "simulation_failed"}), + ("unsupported", {"code": "unsupported"}), + ( + "temporarily_unavailable", + {"code": "temporarily_unavailable", "retry": "same_credential"}, + ), + ], +) +async def test_exposes_safe_relay_error_codes(code: str, details: dict[str, str]) -> None: + async def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "success": False, + "error": {"code": code, "message": "private relay detail"}, + }, + ) + + relay, client = _relay(handler) + try: + with pytest.raises(VerificationFailedError) as exc_info: + await relay.configure(ChargeIntent()).verify(_credential(), {}) + finally: + await client.aclose() + + assert exc_info.value.details == details + assert "private relay detail" not in str(exc_info.value) + + +@pytest.mark.parametrize("code", ["policy_denied", "screen_rejected", "unknown"]) +async def test_keeps_sensitive_relay_codes_opaque(code: str) -> None: + async def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"success": False, "error": {"code": code}}) + + relay, client = _relay(handler) + try: + with pytest.raises(VerificationFailedError) as exc_info: + await relay.configure(ChargeIntent()).verify(_credential(), {}) + finally: + await client.aclose() + + assert exc_info.value.details is None + + +async def test_maps_expired_to_payment_expired() -> None: + async def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"success": False, "error": {"code": "expired"}}) + + relay, client = _relay(handler) + try: + with pytest.raises(PaymentExpiredError): + await relay.configure(ChargeIntent()).verify(_credential(), {}) + finally: + await client.aclose() + + +@pytest.mark.parametrize( + "receipt", + [ + None, + {"method": "tempo", "reference": "0xabc", "timestamp": "not-a-date"}, + {"method": "tempo", "reference": "0xabc", "timestamp": "2026-07-22T00:00:00"}, + { + "method": "tempo", + "reference": "0xabc", + "timestamp": "2026-07-22T00:00:00Z", + "externalId": 123, + }, + ], +) +async def test_rejects_invalid_receipts(receipt: Any) -> None: + async def handler(request: httpx.Request) -> httpx.Response: + result = {"success": True} + if request.url.path.endswith("/broadcast"): + result["receipt"] = receipt + return httpx.Response(200, json=result) + + relay, client = _relay(handler) + try: + with pytest.raises(VerificationFailedError): + await relay.configure(ChargeIntent()).verify(_credential(), {}) + finally: + await client.aclose() + + +async def test_rejects_malformed_challenge_request() -> None: + relay = Relay(API_KEY) + with pytest.raises(VerificationFailedError): + await relay.configure(ChargeIntent()).verify( + make_credential(payload={}, request="not-base64"), + {}, + ) + + +async def test_context_manager_closes_owned_client() -> None: + relay = Relay(API_KEY) + async with relay: + client = relay._http_client + assert client is not None + + assert client.is_closed + assert relay._http_client is None + + +async def test_wrapped_intent_context_manager_closes_owned_client() -> None: + relay = Relay(API_KEY) + intent: Any = relay.configure(ChargeIntent()) + + async with intent: + client = relay._http_client + assert client is not None + + assert client.is_closed + assert relay._http_client is None + + +async def test_forwards_optional_challenge_fields() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json={"success": True}) + + credential = _credential() + credential = replace( + credential, + challenge=replace( + credential.challenge, + digest="sha-256=:digest:", + opaque="eyJrIjoidiJ9", + ), + ) + relay, client = _relay(handler) + try: + await relay.configure(ChargeIntent()).validate(credential, {}) + finally: + await client.aclose() + + challenge = json.loads(requests[0].content)["challenge"] + assert challenge["digest"] == "sha-256=:digest:" + assert challenge["opaque"] == "eyJrIjoidiJ9" + + +def test_relay_validates_configuration() -> None: + with pytest.raises(ValueError, match="api_key is required"): + Relay("") + with pytest.raises(ValueError, match="api_base_url is required"): + Relay(API_KEY, "") + + class SessionIntent: + name = "session" + + with pytest.raises(ValueError, match="charge intent"): + Relay(API_KEY).configure(SessionIntent()) # type: ignore[arg-type] + + +def test_tempo_factory_configures_charge_intent() -> None: + relay = Relay(API_KEY) + original = ChargeIntent() + method = tempo(intents={"charge": original}, relay=relay) + + assert method.intents["charge"].name == "charge" + assert method.intents["charge"] is not original + + +def test_tempo_factory_requires_charge_intent_for_relay() -> None: + with pytest.raises(ValueError, match="relay requires a charge intent"): + tempo(intents={}, relay=Relay(API_KEY))