Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changelog/tempo-api-relay.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
46 changes: 46 additions & 0 deletions examples/charge-relay/README.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions examples/charge-relay/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = "../.." }
70 changes: 70 additions & 0 deletions examples/charge-relay/server.py
Original file line number Diff line number Diff line change
@@ -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")))
22 changes: 22 additions & 0 deletions src/mpp/_validation.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 13 additions & 2 deletions src/mpp/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand All @@ -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


Expand Down Expand Up @@ -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):
Expand Down
8 changes: 6 additions & 2 deletions src/mpp/extensions/mcp/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
Expand Down
1 change: 1 addition & 0 deletions src/mpp/methods/tempo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",),
}

Expand Down
4 changes: 4 additions & 0 deletions src/mpp/methods/tempo/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,4 +30,6 @@ tempo = _tempo
ChargeIntent = _ChargeIntent
Transfer = _Transfer
get_transfers = _get_transfers
Relay = _Relay
RelayErrorCode = _RelayErrorCode
Split = _Split
12 changes: 11 additions & 1 deletion src/mpp/methods/tempo/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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.

Expand All @@ -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.
Expand Down Expand Up @@ -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
Loading