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
50 changes: 50 additions & 0 deletions agentscore_commerce/checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,9 @@ class SettleOutcome:
"""``"evm"`` / ``"solana"`` for chain signers; ``None`` otherwise."""
payment_response_header: str | None = None
"""The ``PAYMENT-RESPONSE`` header to echo (x402 success path). ``None`` for MPP."""
payment_receipt_header: str | None = None
"""The ``Payment-Receipt`` header to echo (MPP success path, paymentauth.org §5).
``None`` for x402 and for the MPP zero-settle carve-out (no receipt minted)."""
raw: Any = None
"""The underlying settle result. Inspect for power-user fields (facilitator
diagnostics, raw receipt blobs); prefer the normalized fields above for the
Expand Down Expand Up @@ -490,6 +493,11 @@ class MppxComposeOutcome:
"""For ``status=200``: ``"evm"`` / ``"solana"`` depending on the rail."""
payment_response_header: str | None = None
"""For ``status=200``: optional PAYMENT-RESPONSE header echoed to the agent."""
payment_receipt_header: str | None = None
"""For ``status=200``: serialized ``Payment-Receipt`` header (base64url-encoded
receipt struct per pympp's ``Receipt.to_payment_receipt``). Echoed to the agent
so spec-strict MPP clients (tempo CLI, etc.) can lift tx_hash + source from
headers without parsing the JSON body."""
raw: Any = None
"""The underlying pympp compose result for ``on_settled`` introspection."""

Expand Down Expand Up @@ -540,6 +548,41 @@ async def _maybe_await(value: Any) -> Any:
return value


def _extract_mppx_receipt_header_from_raw(raw: Any) -> str | None:
"""Best-effort ``Payment-Receipt`` extraction from a custom hook's ``raw``.

Handles the three shapes hand-rolled hooks commonly return on a 200:

* The raw object itself exposes ``to_payment_receipt()`` (pympp Receipt
handed back directly).
* ``raw`` is a tuple ``(credential, receipt)`` (the pympp ``Mpp.charge``
return shape, unpacked but not re-wrapped).
* ``raw`` is a dict with ``receipt`` key, or an object with ``.receipt``
attribute (the auto-built hook's ``{"credential", "receipt"}`` dict).

Returns ``None`` when none of the shapes match, or the receipt's
``to_payment_receipt`` raises: the response then omits the header rather
than emitting a malformed value.
"""
candidates: list[Any] = [raw]
if isinstance(raw, tuple | list) and len(raw) >= 2:
candidates.append(raw[1])
if isinstance(raw, dict) and "receipt" in raw:
candidates.append(raw["receipt"])
if hasattr(raw, "receipt"):
candidates.append(raw.receipt)
for candidate in candidates:
to_header = getattr(candidate, "to_payment_receipt", None)
if callable(to_header):
try:
value = to_header()
except Exception: # noqa: S112
continue
if isinstance(value, str) and value:
return value
return None


def _resolve_identity_metadata(ctx: CheckoutContext) -> dict[str, Any] | None:
"""Compose the identity_metadata block from request + assess state.

Expand Down Expand Up @@ -1574,6 +1617,7 @@ async def _handle_zero_settle(self, ctx: CheckoutContext) -> CheckoutResult:
signer_address=carve.signer_address,
signer_network="evm" if carve.signer_address else None,
payment_response_header=None,
payment_receipt_header=None,
raw=verified,
)
return await self._build_success(ctx, outcome)
Expand All @@ -1589,6 +1633,7 @@ async def _handle_zero_settle(self, ctx: CheckoutContext) -> CheckoutResult:
signer_address=carve.signer_address,
signer_network="evm" if carve.signer_address else None,
payment_response_header=None,
payment_receipt_header=None,
raw=None,
)
return await self._build_success(ctx, outcome)
Expand Down Expand Up @@ -1705,6 +1750,7 @@ async def _handle_x402(self, ctx: CheckoutContext) -> CheckoutResult:
signer_address=x402_signer.address if x402_signer else None,
signer_network=x402_signer.network if x402_signer else None,
payment_response_header=settle.payment_response_header,
payment_receipt_header=None,
raw=settle,
)
return await self._build_success(ctx, outcome)
Expand All @@ -1722,6 +1768,8 @@ async def _handle_mppx(self, ctx: CheckoutContext) -> CheckoutResult:
signer_address=composed.signer_address,
signer_network=composed.signer_network,
payment_response_header=composed.payment_response_header,
payment_receipt_header=composed.payment_receipt_header
or _extract_mppx_receipt_header_from_raw(composed.raw),
raw=composed.raw,
)
return await self._build_success(ctx, outcome)
Expand Down Expand Up @@ -1862,6 +1910,8 @@ async def _build_success(self, ctx: CheckoutContext, outcome: SettleOutcome) ->
headers: dict[str, str] = {}
if outcome.payment_response_header:
headers["payment-response"] = outcome.payment_response_header
if outcome.payment_receipt_header:
headers["payment-receipt"] = outcome.payment_receipt_header
return CheckoutResult(
status=200,
body=body,
Expand Down
4 changes: 4 additions & 0 deletions agentscore_commerce/checkout_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,15 @@ async def hook(ctx: CheckoutContext) -> MppxComposeOutcome:
signer_address = signer.address
signer_network = signer.network

to_header = getattr(receipt, "to_payment_receipt", None)
payment_receipt_header = to_header() if callable(to_header) else None

return MppxComposeOutcome(
status=200,
tx_hash=tx_hash,
signer_address=signer_address,
signer_network=signer_network,
payment_receipt_header=payment_receipt_header,
raw={"credential": credential, "receipt": receipt},
)

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "agentscore-commerce"
version = "2.0.0"
version = "2.0.1"
description = "Agent commerce SDK for Python — identity middleware (FastAPI, Flask, Django, AIOHTTP, Sanic, ASGI) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agent commerce."
readme = "README.md"
license = "MIT"
Expand Down
136 changes: 136 additions & 0 deletions tests/test_checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,142 @@ async def test_compose_mppx_returns_200_runs_on_settled() -> None:
on_settled.assert_awaited_once()


@pytest.mark.asyncio
async def test_compose_mppx_payment_receipt_header_surfaces_on_response() -> None:
"""When ``compose_mppx`` populates ``payment_receipt_header``, Checkout echoes
it as a ``payment-receipt`` HTTP header on the success response — symmetric
to the existing ``payment_response_header`` (x402) behavior."""
receipt_header = "eyJzdGF0dXMiOiJzdWNjZXNzIn0"
compose_mppx = AsyncMock(
return_value=MppxComposeOutcome(status=200, payment_receipt_header=receipt_header),
)
checkout = Checkout(
rails={"tempo": TempoRailSpec(recipient="0xtempo")},
url="https://api.example/purchase",
compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0),
compose_mppx=compose_mppx,
)
result = await checkout.handle(_req(headers={"authorization": "Payment id=abc"}))
assert result.status == 200
assert result.headers["payment-receipt"] == receipt_header


@pytest.mark.asyncio
async def test_compose_mppx_omitted_payment_receipt_header_emits_no_header() -> None:
"""Default ``payment_receipt_header=None`` on the compose outcome must NOT
emit an empty ``payment-receipt`` header on the response."""
compose_mppx = AsyncMock(return_value=MppxComposeOutcome(status=200))
checkout = Checkout(
rails={"tempo": TempoRailSpec(recipient="0xtempo")},
url="https://api.example/purchase",
compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0),
compose_mppx=compose_mppx,
)
result = await checkout.handle(_req(headers={"authorization": "Payment id=abc"}))
assert result.status == 200
assert "payment-receipt" not in result.headers


@pytest.mark.asyncio
async def test_compose_mppx_auto_extracts_receipt_header_from_raw_dict() -> None:
"""When a custom compose_mppx returns ``raw={'credential': c, 'receipt': r}``
(the auto-built hook's shape) without explicitly setting
``payment_receipt_header``, Checkout lifts the header from ``r.to_payment_receipt()``."""

class _Receipt:
@staticmethod
def to_payment_receipt() -> str:
return "auto-from-raw-dict"

compose_mppx = AsyncMock(
return_value=MppxComposeOutcome(status=200, raw={"credential": object(), "receipt": _Receipt()}),
)
checkout = Checkout(
rails={"tempo": TempoRailSpec(recipient="0xtempo")},
url="https://api.example/purchase",
compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0),
compose_mppx=compose_mppx,
)
result = await checkout.handle(_req(headers={"authorization": "Payment id=abc"}))
assert result.status == 200
assert result.headers["payment-receipt"] == "auto-from-raw-dict"


@pytest.mark.asyncio
async def test_compose_mppx_auto_extracts_receipt_header_from_raw_tuple() -> None:
"""``raw=(credential, receipt)`` (the pympp Mpp.charge return) is also a
recognized shape — the second element's ``to_payment_receipt()`` is lifted."""

class _Receipt:
@staticmethod
def to_payment_receipt() -> str:
return "auto-from-raw-tuple"

compose_mppx = AsyncMock(
return_value=MppxComposeOutcome(status=200, raw=(object(), _Receipt())),
)
checkout = Checkout(
rails={"tempo": TempoRailSpec(recipient="0xtempo")},
url="https://api.example/purchase",
compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0),
compose_mppx=compose_mppx,
)
result = await checkout.handle(_req(headers={"authorization": "Payment id=abc"}))
assert result.status == 200
assert result.headers["payment-receipt"] == "auto-from-raw-tuple"


@pytest.mark.asyncio
async def test_compose_mppx_explicit_payment_receipt_header_wins_over_raw() -> None:
"""When the hook sets ``payment_receipt_header`` explicitly, the auto-extract
from ``raw`` is NOT consulted."""

class _Receipt:
@staticmethod
def to_payment_receipt() -> str:
return "auto-IGNORED"

compose_mppx = AsyncMock(
return_value=MppxComposeOutcome(
status=200,
payment_receipt_header="explicit-value",
raw={"receipt": _Receipt()},
),
)
checkout = Checkout(
rails={"tempo": TempoRailSpec(recipient="0xtempo")},
url="https://api.example/purchase",
compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0),
compose_mppx=compose_mppx,
)
result = await checkout.handle(_req(headers={"authorization": "Payment id=abc"}))
assert result.status == 200
assert result.headers["payment-receipt"] == "explicit-value"


@pytest.mark.asyncio
async def test_compose_mppx_receipt_to_header_that_throws_falls_through() -> None:
"""If ``to_payment_receipt()`` raises (unsupported pympp version, malformed
receipt), the SDK omits the header rather than emitting a malformed value."""

class _BadReceipt:
def to_payment_receipt(self) -> str:
raise RuntimeError("malformed receipt")

compose_mppx = AsyncMock(
return_value=MppxComposeOutcome(status=200, raw={"receipt": _BadReceipt()}),
)
checkout = Checkout(
rails={"tempo": TempoRailSpec(recipient="0xtempo")},
url="https://api.example/purchase",
compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0),
compose_mppx=compose_mppx,
)
result = await checkout.handle(_req(headers={"authorization": "Payment id=abc"}))
assert result.status == 200
assert "payment-receipt" not in result.headers


@pytest.mark.asyncio
async def test_compose_mppx_returns_402_on_settle_leg_rejects_credential() -> None:
"""When the agent sends Authorization: Payment and mppx returns 402 (credential
Expand Down
75 changes: 75 additions & 0 deletions tests/test_seamless_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,81 @@ class _Ctx:
assert out.signer_network == "evm"


@pytest.mark.asyncio
async def test_make_mppx_compose_hook_serializes_receipt_to_payment_receipt_header() -> None:
"""When the pympp ``Receipt`` exposes ``to_payment_receipt()``, the compose
hook lifts the serialized header onto ``MppxComposeOutcome.payment_receipt_header``
so Checkout can echo it as the response's ``Payment-Receipt`` header."""

class _Credential:
source = "did:pkh:eip155:8453:0xABCD000000000000000000000000000000000003"

class _Receipt:
reference = "0xtx_hash"
transaction = None

@staticmethod
def to_payment_receipt() -> str:
return "eyJzdGF0dXMiOiJzdWNjZXNzIn0"

class _Mpp:
realm = "r"

async def charge(self, *, authorization: str | None, amount: str) -> tuple:
return (_Credential(), _Receipt())

async def _getter() -> _Mpp:
return _Mpp()

hook = make_mppx_compose_hook(server_getter=_getter)

class _Pricing:
amount_usd = 0.1

class _Ctx:
request = _req({"authorization": "Payment somevalidcred"})
pricing = _Pricing()

out = await hook(_Ctx())
assert out.status == 200
assert out.payment_receipt_header == "eyJzdGF0dXMiOiJzdWNjZXNzIn0"


@pytest.mark.asyncio
async def test_make_mppx_compose_hook_omits_receipt_header_when_unavailable() -> None:
"""Older pympp Receipts without ``to_payment_receipt()`` leave the header
field None rather than raising or fabricating."""

class _Credential:
source = "did:pkh:eip155:8453:0xABCD000000000000000000000000000000000003"

class _Receipt:
reference = "0xtx_hash"
transaction = None

class _Mpp:
realm = "r"

async def charge(self, *, authorization: str | None, amount: str) -> tuple:
return (_Credential(), _Receipt())

async def _getter() -> _Mpp:
return _Mpp()

hook = make_mppx_compose_hook(server_getter=_getter)

class _Pricing:
amount_usd = 0.1

class _Ctx:
request = _req({"authorization": "Payment somevalidcred"})
pricing = _Pricing()

out = await hook(_Ctx())
assert out.status == 200
assert out.payment_receipt_header is None


@pytest.mark.asyncio
async def test_make_mppx_compose_hook_returns_402_when_charge_raises() -> None:
class _Mpp:
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.