From 2d899cbd3d3fbd8da638a9084561d9b052b1e55b Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 13:38:16 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix(checkout):=20emit=20Payment-Receipt=20h?= =?UTF-8?q?eader=20on=20MPP=20success=20(paymentauth.org=20=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec-strict MPP clients (tempo CLI, mppx Receipt.from) expect a `Payment-Receipt` HTTP header on successful charge responses so the agent can lift tx_hash + source from headers without parsing the JSON body. We had the symmetric path for x402's PAYMENT-RESPONSE header (handleMppx → SettleOutcome.payment_response_header → response headers) but never wired the MPP equivalent — the receipt was captured into MppxComposeOutcome.raw and harvested for tx_hash / signer fields, but the serialized header value was dropped on the floor. * Add `payment_receipt_header: str | None` to `SettleOutcome` + `MppxComposeOutcome` (additive; default None preserves existing consumer code). * `make_mppx_compose_hook` calls `receipt.to_payment_receipt()` and threads the result onto the outcome (no-op for pympp versions that don't expose the helper — defensive `getattr`). * `Checkout._build_success` echoes `outcome.payment_receipt_header` as the `payment-receipt` response header, mirroring the existing `payment-response` echo for x402. * `_handle_x402` + the two `$0` zero-settle carve-outs explicitly set `payment_receipt_header=None` (x402 uses payment-response only). * Tests: receipt-header round-trips through compose_mppx → response, null/omitted outcomes don't emit an empty header, and the make_mppx_compose_hook factory serializes pympp Receipts (with a parallel test confirming older receipts without the helper produce None). Surfaced when smoke-testing live martin via `tempo request`: every successful purchase printed `Warning: missing Payment-Receipt on successful paid charge response`. Confirmed mppx (node) + pympp (python) both implement the spec; the gap was our SDK never echoing it. Bumps to 2.0.1 — additive change, no consumer migration required. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/checkout.py | 14 +++++ agentscore_commerce/checkout_hooks.py | 4 ++ pyproject.toml | 2 +- tests/test_checkout.py | 36 +++++++++++++ tests/test_seamless_helpers.py | 75 +++++++++++++++++++++++++++ uv.lock | 2 +- 6 files changed, 131 insertions(+), 2 deletions(-) diff --git a/agentscore_commerce/checkout.py b/agentscore_commerce/checkout.py index ce2bdbf..3189f2f 100644 --- a/agentscore_commerce/checkout.py +++ b/agentscore_commerce/checkout.py @@ -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 @@ -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.""" @@ -1574,6 +1582,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) @@ -1589,6 +1598,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) @@ -1705,6 +1715,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) @@ -1722,6 +1733,7 @@ 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, raw=composed.raw, ) return await self._build_success(ctx, outcome) @@ -1862,6 +1874,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, diff --git a/agentscore_commerce/checkout_hooks.py b/agentscore_commerce/checkout_hooks.py index 67d47d5..0852db0 100644 --- a/agentscore_commerce/checkout_hooks.py +++ b/agentscore_commerce/checkout_hooks.py @@ -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}, ) diff --git a/pyproject.toml b/pyproject.toml index 24512b4..f2690e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_checkout.py b/tests/test_checkout.py index 8e5e0bf..6ef22f5 100644 --- a/tests/test_checkout.py +++ b/tests/test_checkout.py @@ -300,6 +300,42 @@ 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_returns_402_on_settle_leg_rejects_credential() -> None: """When the agent sends Authorization: Payment and mppx returns 402 (credential diff --git a/tests/test_seamless_helpers.py b/tests/test_seamless_helpers.py index 4324404..959efd2 100644 --- a/tests/test_seamless_helpers.py +++ b/tests/test_seamless_helpers.py @@ -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: diff --git a/uv.lock b/uv.lock index 1c8e12a..4830832 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ [[package]] name = "agentscore-commerce" -version = "2.0.0" +version = "2.0.1" source = { editable = "." } dependencies = [ { name = "agentscore-py" }, From 03053b4a5fdc007f80f19fac0fe1528415d3cbb3 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Fri, 15 May 2026 13:58:57 -0700 Subject: [PATCH 2/2] fix(checkout): auto-extract Payment-Receipt from custom compose_mppx raw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom ``compose_mppx`` hooks (the common pattern for merchants needing per-rail config, per-order recipients, or Stripe-multichain shims) return their own ``raw`` instead of letting ``make_mppx_compose_hook`` build the outcome. The first cut of this fix only wired the auto-built hook, so every custom-hook merchant still saw clients warn ``missing Payment-Receipt`` despite the SDK 2.0.1 bump. Close the loophole: when ``compose_mppx`` omits ``payment_receipt_header`` but ``raw`` contains a pympp ``Receipt`` (directly, as the second element of a ``(credential, receipt)`` tuple, in a ``raw['receipt']`` key, or on a ``raw.receipt`` attribute), the SDK extracts via ``to_payment_receipt()`` and threads it onto the response. Explicit ``payment_receipt_header`` still wins; failing extracts (older pympp without the helper, malformed receipts) silently fall through to no header rather than emitting a malformed value. This means consumers don't need to change any code — the next ``2.0.2`` bump fixes the warning everywhere ``compose_mppx`` returns the pympp Receipt in its ``raw``, regardless of whether they use the auto-built hook or hand-rolled their own. Tests: 4 new auto-extract paths (dict shape, tuple shape, explicit override wins, throws fall through) on top of the 4 from 2.0.1. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore_commerce/checkout.py | 38 +++++++++++- tests/test_checkout.py | 100 ++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/agentscore_commerce/checkout.py b/agentscore_commerce/checkout.py index 3189f2f..df4acb3 100644 --- a/agentscore_commerce/checkout.py +++ b/agentscore_commerce/checkout.py @@ -548,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. @@ -1733,7 +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, + 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) diff --git a/tests/test_checkout.py b/tests/test_checkout.py index 6ef22f5..d881f4a 100644 --- a/tests/test_checkout.py +++ b/tests/test_checkout.py @@ -336,6 +336,106 @@ async def test_compose_mppx_omitted_payment_receipt_header_emits_no_header() -> 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