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
13 changes: 2 additions & 11 deletions agentscore_commerce/payment/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,7 @@
build_payment_headers,
)
from agentscore_commerce.payment.idempotency import build_idempotency_key
from agentscore_commerce.payment.mppx_server import (
MppxRails,
StripeRail,
TempoChargeRail,
TempoSessionRail,
create_mppx_server,
)
from agentscore_commerce.payment.mppx_server import MppxRailSpec, create_mppx_server
from agentscore_commerce.payment.networks import NetworkFamily, network_family, networks
from agentscore_commerce.payment.rail_spec import (
RecipientLike,
Expand Down Expand Up @@ -89,7 +83,7 @@
"X402_SUPPORTED_BASE_NETWORKS",
"ClassifiedX402Error",
"CustomScheme",
"MppxRails",
"MppxRailSpec",
"NetworkFamily",
"PaymentHeadersRail",
"PaymentHeadersResult",
Expand All @@ -101,11 +95,8 @@
"RecipientLike",
"SignerNetwork",
"SolanaMppRailSpec",
"StripeRail",
"StripeRailSpec",
"TempoChargeRail",
"TempoRailSpec",
"TempoSessionRail",
"TempoSessionRailSpec",
"VerifyX402RequestFailure",
"VerifyX402RequestResult",
Expand Down
217 changes: 89 additions & 128 deletions agentscore_commerce/payment/mppx_server.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,31 @@
"""One-call MPP server setup wrapping the official `pympp` Python package.

Wires Tempo charge, Tempo session (channel-based for variable-cost /
streaming), and Stripe SPT methods from symbolic rail config — replaces
the boilerplate of constructing each method by hand.
streaming), and Stripe SPT methods from rail specs — replaces the boilerplate
of constructing each method by hand.

Usage::

from agentscore_commerce.payment import create_mppx_server, MppxRails, TempoChargeRail
from agentscore_commerce.payment import (
create_mppx_server,
TempoRailSpec,
StripeRailSpec,
)

mpp = await create_mppx_server(
rails=MppxRails(
tempo=TempoChargeRail(recipient=os.environ["TEMPO_RECIPIENT"]),
stripe=StripeRail(
secret_key=os.environ["MPP_SECRET_KEY"],
rails={
"tempo": TempoRailSpec(recipient=os.environ["TEMPO_RECIPIENT"]),
"stripe": StripeRailSpec(
profile_id=os.environ["STRIPE_PROFILE_ID"],
secret_key=os.environ["STRIPE_SECRET_KEY"],
),
),
secret_key=os.environ["MPP_SECRET_KEY"],
},
)

Keys are rail names (``"tempo"``, ``"tempo_session"``, ``"stripe"``); values are
the canonical ``*RailSpec`` instances every other helper also consumes.

`pympp` is an OPTIONAL peer dependency — install only if you accept MPP rails::

pip install 'pympp[server,tempo,stripe]>=0.6,<1'
Expand All @@ -27,86 +34,68 @@
from __future__ import annotations

import importlib
from dataclasses import dataclass
from typing import Any

from agentscore_commerce.payment.rail_spec import (
RecipientLike,
StripeRailSpec,
TempoRailSpec,
TempoSessionRailSpec,
resolve_recipient,
)
from agentscore_commerce.payment.usdc import USDC

MppxRailSpec = TempoRailSpec | TempoSessionRailSpec | StripeRailSpec

@dataclass
class TempoChargeRail:
"""One-shot Tempo USDC charge (intent: ``charge``)."""

recipient: str
"""Tempo wallet address that receives settled funds."""

currency: str | None = None
"""Token contract address. Defaults to USDC on Tempo (selected by ``testnet`` flag)."""

testnet: bool = False
"""Use Tempo testnet (Moderato) instead of mainnet."""


@dataclass
class TempoSessionRail:
"""Tempo session (intent: ``session``) — pay-as-you-go channel.

Used for repeated calls or SSE-streamed responses. Vendor brings their own
``ChannelStore`` and ``escrow_contract`` address.
"""

recipient: str
escrow_contract: str
"""On-chain escrow contract address that holds channel deposits and pays out
cumulative vouchers on settlement. Vendor-deployed."""

store: Any
"""ChannelStore implementation tracking open channels + cumulative voucher state.
Pass an instance of pympp's ``ChannelStore`` interface (in-memory default for
dev or a Postgres/Redis-backed store for production)."""

currency: str | None = None
testnet: bool = False
chains: Any | None = None
"""Optional supported chains; defaults to pympp defaults if omitted."""


@dataclass
class StripeRail:
"""Stripe SPT (Shared Payment Token) rail config.

See :mod:`agentscore_commerce.stripe_multichain` for the multichain
PaymentIntent helpers used alongside this rail.
"""
def _import_optional(module_name: str) -> Any | None:
try:
return importlib.import_module(module_name)
except ImportError:
return None

profile_id: str
secret_key: str
payment_method_types: list[str] | None = None

async def _resolve_recipient_for_method(recipient: RecipientLike) -> str:
return await resolve_recipient(recipient)

@dataclass
class MppxRails:
"""Symbolic rail config for :func:`create_mppx_server`.

Commerce wires the boilerplate (``tempo.charge()``, ``mpp_stripe.charge()``,
etc.) so vendors only declare the rails they accept.
"""
async def _tempo_method(spec: TempoRailSpec) -> Any:
tempo_module = _import_optional("mpp.methods.tempo")
tempo_factory = getattr(tempo_module, "tempo", None) if tempo_module else None
if not callable(tempo_factory):
msg = "pympp[tempo] not installed — run `pip install 'pympp[tempo]'` for Tempo MPP rails."
raise ImportError(msg)
charge_intent_cls = getattr(tempo_module, "ChargeIntent", None) if tempo_module else None
if charge_intent_cls is None:
msg = "pympp[tempo] missing ChargeIntent — upgrade pympp to 0.6+."
raise ImportError(msg)
default_currency = USDC.tempo.testnet.address if spec.testnet else USDC.tempo.mainnet.address
chain_id = 42431 if spec.testnet else (spec.chain_id or 4217)
return tempo_factory(
intents={"charge": charge_intent_cls()},
currency=spec.token or default_currency,
recipient=await _resolve_recipient_for_method(spec.recipient),
chain_id=chain_id,
)

tempo: TempoChargeRail | None = None
tempo_session: TempoSessionRail | None = None
stripe: StripeRail | None = None

async def _stripe_method(spec: StripeRailSpec) -> Any:
from agentscore_commerce.stripe_multichain.mppx_stripe import create_mppx_stripe

def _import_optional(module_name: str) -> Any | None:
try:
return importlib.import_module(module_name)
except ImportError:
return None
if not spec.profile_id or not spec.secret_key:
msg = "StripeRailSpec for create_mppx_server requires both profile_id and secret_key."
raise ValueError(msg)
return await create_mppx_stripe(
profile_id=spec.profile_id,
secret_key=spec.secret_key,
payment_method_types=spec.payment_method_types,
)


async def create_mppx_server(
*,
secret_key: str,
rails: MppxRails | None = None,
rails: dict[str, MppxRailSpec] | None = None,
method: Any = None,
realm: str | None = None,
) -> Any:
Expand All @@ -116,71 +105,46 @@ async def create_mppx_server(
``ImportError`` with a guiding install command when ``pympp`` or a per-rail
extra is missing.

Async because Stripe SPT method construction may require an HTTP setup call
to the Stripe API.
``rails`` keys are rail names (``"tempo"``, ``"tempo_session"``, ``"stripe"``);
values are the canonical ``*RailSpec`` instances every other helper also
consumes. Tempo session is reserved for future pympp ``SessionIntent``
support — passing it today raises ``ImportError``.

Note: pympp 0.6 takes a single ``method`` per ``Mpp`` instance (the prior
multi-method ``Mppx`` API was removed). If multiple rails are configured on
``rails``, the first non-None one wins; merchants supporting multiple
distinct methods (e.g. tempo charge + tempo session, or tempo + Stripe SPT)
construct a separate ``Mpp`` instance per method and route by the method
name they detect on the request. Mirrors how pympp 0.6 separates methods.
pympp 0.6 takes a single ``method`` per ``Mpp`` instance. When ``rails`` is
provided, the first resolvable rail in dict-insertion order wins; merchants
supporting multiple distinct methods construct a separate ``Mpp`` per method
and route by name at the request layer.
"""
# The pympp distribution publishes its modules under the top-level `mpp`
# package (the dist name is `pympp` but `import pympp` doesn't resolve —
# only `import mpp`).
pympp = _import_optional("mpp.server")
if pympp is None or not hasattr(pympp, "Mpp"):
msg = "pympp not installed — run `pip install 'pympp[server,tempo,stripe]>=0.6,<1'` to use create_mppx_server."
raise ImportError(msg)

rails_cfg = rails or MppxRails()
resolved_method: Any = method
rails_map: dict[str, MppxRailSpec] = rails or {}

if resolved_method is None and rails_cfg.tempo is not None:
tempo_module = _import_optional("mpp.methods.tempo")
tempo_factory = getattr(tempo_module, "tempo", None) if tempo_module else None
if not callable(tempo_factory):
msg = "pympp[tempo] not installed — run `pip install 'pympp[tempo]'` for Tempo MPP rails."
raise ImportError(msg)
charge_intent_cls = getattr(tempo_module, "ChargeIntent", None) if tempo_module else None
if charge_intent_cls is None:
msg = "pympp[tempo] missing ChargeIntent — upgrade pympp to 0.6+."
raise ImportError(msg)
t = rails_cfg.tempo
default_currency = USDC.tempo.testnet.address if t.testnet else USDC.tempo.mainnet.address
chain_id = 42431 if t.testnet else 4217
resolved_method = tempo_factory(
intents={"charge": charge_intent_cls()},
currency=t.currency or default_currency,
recipient=t.recipient,
chain_id=chain_id,
)

if resolved_method is None and rails_cfg.tempo_session is not None:
# pympp 0.6 has not shipped a session intent factory under the same
# naming. Keep the surface (TempoSessionRail), but vendors must wait
# for pympp to expose ``SessionIntent`` before this branch resolves.
msg = (
"pympp[tempo] session support not available — pympp 0.6 has not "
"shipped a SessionIntent factory yet. Upgrade pympp when it does "
"or pass `method=` directly with a hand-built TempoMethod."
)
raise ImportError(msg)

if resolved_method is None and rails_cfg.stripe is not None:
from agentscore_commerce.stripe_multichain.mppx_stripe import create_mppx_stripe

resolved_method = await create_mppx_stripe(
profile_id=rails_cfg.stripe.profile_id,
secret_key=rails_cfg.stripe.secret_key,
payment_method_types=rails_cfg.stripe.payment_method_types,
)
if resolved_method is None:
for name, spec in rails_map.items():
if isinstance(spec, TempoRailSpec):
resolved_method = await _tempo_method(spec)
break
if isinstance(spec, TempoSessionRailSpec):
msg = (
"pympp[tempo] session support not available — pympp 0.6 has not "
"shipped a SessionIntent factory yet. Upgrade pympp when it does "
"or pass `method=` directly with a hand-built TempoMethod."
)
raise ImportError(msg)
if isinstance(spec, StripeRailSpec):
resolved_method = await _stripe_method(spec)
break
msg = f"create_mppx_server: unsupported rail spec for key {name!r}: {type(spec).__name__}"
raise TypeError(msg)

if resolved_method is None:
msg = (
"create_mppx_server called with no method or rails — pass at least one of "
"`method=`, `rails.tempo`, `rails.tempo_session`, or `rails.stripe`."
"create_mppx_server called with no method or rails — pass `method=` or a "
"non-empty `rails={...}` map keyed by rail name (`tempo`, `tempo_session`, `stripe`)."
)
raise ValueError(msg)

Expand All @@ -191,9 +155,6 @@ async def create_mppx_server(


__all__ = [
"MppxRails",
"StripeRail",
"TempoChargeRail",
"TempoSessionRail",
"MppxRailSpec",
"create_mppx_server",
]
6 changes: 3 additions & 3 deletions examples/variable_cost_merchant.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,9 @@ async def complete(request: Request):
async def stream(request: Request):
"""MPP tempo session path — agent opens channel, server streams SSE with mid-stream vouchers.

Production wiring: ``mpp = await create_mppx_server(secret_key=MPP_SECRET, rails=MppxRails(
tempo_session=TempoSessionRail(recipient=TEMPO_RECIPIENT, escrow_contract=TEMPO_ESCROW,
store=YourChannelStore())))`` — parse channel state from ``Authorization: Payment``,
Production wiring: ``mpp = await create_mppx_server(secret_key=MPP_SECRET, rails={
"tempo_session": TempoSessionRailSpec(recipient=TEMPO_RECIPIENT, escrow_contract=TEMPO_ESCROW,
store=YourChannelStore())})`` — parse channel state from ``Authorization: Payment``,
emit SSE chunks, request fresh voucher signatures as cumulative cost grows, close
channel on completion.
"""
Expand Down
43 changes: 40 additions & 3 deletions tests/test_payment_servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
import pytest

from agentscore_commerce.payment import (
MppxRails,
TempoChargeRail,
StripeRailSpec,
TempoRailSpec,
TempoSessionRailSpec,
create_mppx_server,
create_x402_server,
)
Expand Down Expand Up @@ -147,7 +148,7 @@ async def test_create_mppx_server_tempo_returns_mpp_instance() -> None:
"""create_mppx_server with a Tempo charge rail returns a configured Mpp."""
server = await create_mppx_server(
secret_key="X" * 32,
rails=MppxRails(tempo=TempoChargeRail(recipient="0x" + "00" * 20, testnet=True)),
rails={"tempo": TempoRailSpec(recipient="0x" + "00" * 20, testnet=True)},
)
assert type(server).__name__ == "Mpp"
# pympp 0.6 exposes intent-named methods directly (charge, pay, …) on the Mpp instance.
Expand All @@ -159,3 +160,39 @@ async def test_create_mppx_server_tempo_returns_mpp_instance() -> None:
async def test_create_mppx_server_no_method_or_rails_raises() -> None:
with pytest.raises(ValueError, match="no method or rails"):
await create_mppx_server(secret_key="X" * 32)


@pytest.mark.skipif(not _MPPX_INSTALLED, reason="pympp not installed")
@pytest.mark.asyncio
async def test_create_mppx_server_tempo_session_raises_until_pympp_supports_it() -> None:
with pytest.raises(ImportError, match="SessionIntent"):
await create_mppx_server(
secret_key="X" * 32,
rails={
"tempo_session": TempoSessionRailSpec(
recipient="0x" + "00" * 20,
escrow_contract="0x" + "11" * 20,
store=object(),
),
},
)


@pytest.mark.skipif(not _MPPX_INSTALLED, reason="pympp not installed")
@pytest.mark.asyncio
async def test_create_mppx_server_stripe_requires_secret_key() -> None:
with pytest.raises(ValueError, match="profile_id and secret_key"):
await create_mppx_server(
secret_key="X" * 32,
rails={"stripe": StripeRailSpec(profile_id="profile_x")},
)


@pytest.mark.skipif(not _MPPX_INSTALLED, reason="pympp not installed")
@pytest.mark.asyncio
async def test_create_mppx_server_unknown_rail_spec_raises() -> None:
with pytest.raises(TypeError, match="unsupported rail spec"):
await create_mppx_server(
secret_key="X" * 32,
rails={"weird": "not-a-spec"}, # type: ignore[dict-item]
)