Skip to content

Commit d8ff934

Browse files
vvillait88claude
andauthored
refactor(identity/ucp): payment handlers consume *RailSpec (#42)
## Summary \`mpp_payment_handler\`, \`x402_payment_handler\`, \`stripe_spt_payment_handler\` now accept \`*RailSpec\` instances directly instead of plain dicts. Tempo, Solana MPP, and TempoSession rails all flow through \`mpp_payment_handler\` in any mix; \`x402_payment_handler\` takes \`X402BaseRailSpec\`; \`stripe_spt_payment_handler\` takes \`StripeRailSpec\` (replacing the flat \`profile_id\` kwarg). CAIP-2 → UCP-namespace network conversion is internal: | CAIP-2 / spec field | UCP-namespace | |---|---| | \`eip155:8453\` | \`base-8453\` | | \`eip155:84532\` | \`base-84532\` | | \`solana:5eykt4...\` | \`solana-mainnet-beta\` | | \`solana:EtWTRABZaY...\` | \`solana-devnet\` | | \`tempo-mainnet\` | passthrough | | \`TempoRailSpec.testnet=True\` | \`tempo-testnet\` | Mainnet + testnet for every rail is preserved. **Per-order recipient factories** are omitted from the static UCP profile output (\`if isinstance(r, str): include\`); the authoritative recipient ships in the 402 body at request time. Static string recipients are emitted verbatim. Helpers stay synchronous — the UCP profile is built at boot, no async resolution needed. \`signed_ucp_merchant\` example migrated inline. New \`tests/test_payment_handlers.py\` covers every conversion path + the omit-factory branch. ## Test plan - [x] \`uv run pytest tests/\` — 1061 passed, 95.21% coverage - [x] \`uv run ty check\` — clean - [x] \`uv run ruff check\` — clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ea8420f commit d8ff934

3 files changed

Lines changed: 265 additions & 18 deletions

File tree

agentscore_commerce/identity/ucp.py

Lines changed: 112 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@
2020
from dataclasses import dataclass, field
2121
from typing import Any, Literal
2222

23+
from agentscore_commerce.payment.networks import networks
24+
from agentscore_commerce.payment.rail_spec import (
25+
RecipientLike,
26+
SolanaMppRailSpec,
27+
StripeRailSpec,
28+
TempoRailSpec,
29+
TempoSessionRailSpec,
30+
X402BaseRailSpec,
31+
)
32+
2333
_DEFAULT_VERSION = "2026-04-08"
2434

2535
# Reverse-DNS namespacing per UCP convention. The bare ``agentscore-identity`` form
@@ -469,17 +479,89 @@ async def ucp_profile():
469479
_SCHEMA_BASE = "https://agentscore.sh/schemas/payment-handlers"
470480

471481

472-
def mpp_payment_handler(*, networks: list[dict[str, Any]]) -> dict[str, list[UCPPaymentHandlerBinding]]:
482+
# CAIP-2 → UCP-namespace network-name mapping. UCP payment_handler bindings publish
483+
# network strings in the UCP namespace ("base-8453", "solana-mainnet-beta"); RailSpecs
484+
# carry the CAIP-2 form internally ("eip155:8453", "solana:5eykt4..."). Unknown values
485+
# pass through verbatim — vendors who pin a non-standard rail can override the spec's
486+
# network field directly.
487+
_CAIP2_TO_UCP_NETWORK = {
488+
networks.base.mainnet.caip2: "base-8453",
489+
networks.base.sepolia.caip2: "base-84532",
490+
networks.solana.mainnet.caip2: "solana-mainnet-beta",
491+
networks.solana.devnet.caip2: "solana-devnet",
492+
}
493+
494+
495+
def _ucp_network_name(caip2_or_ucp: str) -> str:
496+
return _CAIP2_TO_UCP_NETWORK.get(caip2_or_ucp, caip2_or_ucp)
497+
498+
499+
def _static_recipient(r: RecipientLike) -> str | None:
500+
"""Return the recipient as a string when it's already concrete; `None` for factories.
501+
502+
Per-order factory recipients (e.g. Stripe-multichain mints fresh deposits per
503+
PaymentIntent) cannot be advertised in the static UCP profile — the authoritative
504+
recipient ships in the 402 body at request time instead.
505+
"""
506+
return r if isinstance(r, str) else None
507+
508+
509+
def _tempo_to_network_entry(spec: TempoRailSpec) -> dict[str, Any]:
510+
entry: dict[str, Any] = {
511+
"network": "tempo-testnet" if spec.testnet else spec.network,
512+
"chain_id": spec.chain_id,
513+
}
514+
static = _static_recipient(spec.recipient)
515+
if static is not None:
516+
entry["recipient"] = static
517+
return entry
518+
519+
520+
def _solana_mpp_to_network_entry(spec: SolanaMppRailSpec) -> dict[str, Any]:
521+
entry: dict[str, Any] = {"network": _ucp_network_name(spec.network)}
522+
static = _static_recipient(spec.recipient)
523+
if static is not None:
524+
entry["recipient"] = static
525+
return entry
526+
527+
528+
def _tempo_session_to_network_entry(spec: TempoSessionRailSpec) -> dict[str, Any]:
529+
entry: dict[str, Any] = {
530+
"network": "tempo-testnet" if spec.testnet else "tempo-mainnet",
531+
"escrow_contract": spec.escrow_contract,
532+
}
533+
static = _static_recipient(spec.recipient)
534+
if static is not None:
535+
entry["recipient"] = static
536+
return entry
537+
538+
539+
def _mpp_rail_to_network_entry(spec: TempoRailSpec | SolanaMppRailSpec | TempoSessionRailSpec) -> dict[str, Any]:
540+
if isinstance(spec, TempoRailSpec):
541+
return _tempo_to_network_entry(spec)
542+
if isinstance(spec, SolanaMppRailSpec):
543+
return _solana_mpp_to_network_entry(spec)
544+
if isinstance(spec, TempoSessionRailSpec):
545+
return _tempo_session_to_network_entry(spec)
546+
msg = f"mpp_payment_handler: unsupported rail spec type {type(spec).__name__}"
547+
raise TypeError(msg)
548+
549+
550+
def mpp_payment_handler(
551+
*,
552+
networks: list[TempoRailSpec | SolanaMppRailSpec | TempoSessionRailSpec],
553+
) -> dict[str, list[UCPPaymentHandlerBinding]]:
473554
"""Build the `sh.agentscore.payment.mpp` payment handler block for a UCP profile.
474555
475-
Each network entry: `{"network": <id>, "chain_id"?: <int>, "recipient"?: <addr>, ...}`.
476-
Tempo: `tempo-mainnet` / `tempo-testnet`. Solana via `solana/charge`:
477-
`mpp-solana-mainnet` / `mpp-solana-devnet`.
556+
Pass any mix of `TempoRailSpec`, `SolanaMppRailSpec`, and `TempoSessionRailSpec`.
557+
Tempo + Solana SPL both flow through the MPP handler; tempo-session covers the
558+
pay-as-you-go channel variant.
478559
479560
Spread into payment_handlers:
480561
payment_handlers={
481562
**mpp_payment_handler(networks=[
482-
{"network": "tempo-mainnet", "chain_id": 4217},
563+
TempoRailSpec(recipient="0xabc..."),
564+
SolanaMppRailSpec(recipient="solanaaddr..."),
483565
]),
484566
}
485567
"""
@@ -490,23 +572,34 @@ def mpp_payment_handler(*, networks: list[dict[str, Any]]) -> dict[str, list[UCP
490572
version=_HANDLER_VERSION,
491573
spec=f"{_SPEC_BASE}/mpp",
492574
schema=f"{_SCHEMA_BASE}/mpp.json",
493-
config={"networks": networks},
575+
config={"networks": [_mpp_rail_to_network_entry(s) for s in networks]},
494576
)
495577
]
496578
}
497579

498580

499-
def x402_payment_handler(*, networks: list[dict[str, Any]]) -> dict[str, list[UCPPaymentHandlerBinding]]:
581+
def _x402_rail_to_network_entry(spec: X402BaseRailSpec) -> dict[str, Any]:
582+
entry: dict[str, Any] = {"network": _ucp_network_name(spec.network)}
583+
static = _static_recipient(spec.recipient)
584+
if static is not None:
585+
entry["recipient"] = static
586+
return entry
587+
588+
589+
def x402_payment_handler(
590+
*,
591+
networks: list[X402BaseRailSpec],
592+
) -> dict[str, list[UCPPaymentHandlerBinding]]:
500593
"""Build the `sh.agentscore.payment.x402` payment handler block for a UCP profile.
501594
502-
Each network entry: `{"network": <id>, "recipient"?: <addr>, ...}`.
503-
EVM: `base-8453`, `base-84532`. Solana: `solana-mainnet-beta`, `solana-devnet`.
504-
Stellar: `stellar-pubnet`, `stellar-testnet`.
595+
Today only x402 on EVM (Base mainnet / sepolia) ships through this SDK; the
596+
`X402BaseRailSpec.network` defaults to `eip155:8453` (CAIP-2) and is converted to
597+
`base-8453` for the UCP profile internally.
505598
506599
Spread into payment_handlers:
507600
payment_handlers={
508601
**x402_payment_handler(networks=[
509-
{"network": "base-8453", "recipient": "0xabc..."},
602+
X402BaseRailSpec(recipient="0xabc..."),
510603
]),
511604
}
512605
"""
@@ -517,18 +610,22 @@ def x402_payment_handler(*, networks: list[dict[str, Any]]) -> dict[str, list[UC
517610
version=_HANDLER_VERSION,
518611
spec=f"{_SPEC_BASE}/x402",
519612
schema=f"{_SCHEMA_BASE}/x402.json",
520-
config={"networks": networks},
613+
config={"networks": [_x402_rail_to_network_entry(s) for s in networks]},
521614
)
522615
]
523616
}
524617

525618

526-
def stripe_spt_payment_handler(*, profile_id: str) -> dict[str, list[UCPPaymentHandlerBinding]]:
619+
def stripe_spt_payment_handler(*, spec: StripeRailSpec) -> dict[str, list[UCPPaymentHandlerBinding]]:
527620
"""Build the `sh.agentscore.payment.stripe_spt` payment handler block for a UCP profile.
528621
622+
`spec.profile_id` is the merchant-side network identifier the agent's SPT is scoped
623+
to; advertised verbatim in the UCP profile so trust-mode verifiers know which Stripe
624+
network they're scoped against.
625+
529626
Spread into payment_handlers:
530627
payment_handlers={
531-
**stripe_spt_payment_handler(profile_id="profile_5xKvNqM9BaH"),
628+
**stripe_spt_payment_handler(spec=StripeRailSpec(profile_id="profile_5xKvNqM9BaH")),
532629
}
533630
"""
534631
return {
@@ -538,7 +635,7 @@ def stripe_spt_payment_handler(*, profile_id: str) -> dict[str, list[UCPPaymentH
538635
version=_HANDLER_VERSION,
539636
spec=f"{_SPEC_BASE}/stripe_spt",
540637
schema=f"{_SCHEMA_BASE}/stripe_spt.json",
541-
config={"rail": "stripe-spt", "profile_id": profile_id},
638+
config={"rail": "stripe-spt", "profile_id": spec.profile_id},
542639
)
543640
]
544641
}

examples/signed_ucp_merchant.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
sign_ucp_profile,
4747
verify_ucp_profile,
4848
)
49+
from agentscore_commerce.payment import TempoRailSpec
4950

5051
logger = logging.getLogger("signed_ucp_merchant")
5152

@@ -78,9 +79,7 @@ async def well_known_ucp() -> JSONResponse:
7879
},
7980
payment_handlers={
8081
**mpp_payment_handler(
81-
networks=[
82-
{"network": "tempo-mainnet", "chain_id": 4217, "recipient": "0xfeedface"},
83-
]
82+
networks=[TempoRailSpec(recipient="0xfeedface")],
8483
),
8584
},
8685
signing_keys=[UCPSigningKey.from_jwk(key.public_jwk)],

tests/test_payment_handlers.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""Tests for the UCP payment-handler builders consuming *RailSpec."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from agentscore_commerce.identity.ucp import (
8+
mpp_payment_handler,
9+
stripe_spt_payment_handler,
10+
x402_payment_handler,
11+
)
12+
from agentscore_commerce.payment import (
13+
SolanaMppRailSpec,
14+
StripeRailSpec,
15+
TempoRailSpec,
16+
TempoSessionRailSpec,
17+
X402BaseRailSpec,
18+
)
19+
20+
21+
def test_mpp_tempo_static_recipient() -> None:
22+
out = mpp_payment_handler(networks=[TempoRailSpec(recipient="0xfeedface")])
23+
binding = out["sh.agentscore.payment.mpp"][0]
24+
assert binding.config is not None
25+
assert binding.config["networks"] == [
26+
{"network": "tempo-mainnet", "chain_id": 4217, "recipient": "0xfeedface"},
27+
]
28+
29+
30+
def test_mpp_tempo_testnet_overrides_network_name() -> None:
31+
out = mpp_payment_handler(networks=[TempoRailSpec(recipient="0xfeedface", testnet=True)])
32+
binding = out["sh.agentscore.payment.mpp"][0]
33+
assert binding.config is not None
34+
entry = binding.config["networks"][0]
35+
assert entry["network"] == "tempo-testnet"
36+
37+
38+
def test_mpp_tempo_factory_recipient_omitted_from_static_profile() -> None:
39+
"""Per-order factory recipients are omitted from the UCP profile (only the 402 body carries them)."""
40+
41+
async def factory() -> str:
42+
return "0xdynamic"
43+
44+
out = mpp_payment_handler(networks=[TempoRailSpec(recipient=factory)])
45+
binding = out["sh.agentscore.payment.mpp"][0]
46+
assert binding.config is not None
47+
entry = binding.config["networks"][0]
48+
assert "recipient" not in entry
49+
assert entry["network"] == "tempo-mainnet"
50+
assert entry["chain_id"] == 4217
51+
52+
53+
def test_mpp_solana_caip2_to_ucp_namespace() -> None:
54+
spec = SolanaMppRailSpec(recipient="solanaaddr")
55+
out = mpp_payment_handler(networks=[spec])
56+
binding = out["sh.agentscore.payment.mpp"][0]
57+
assert binding.config is not None
58+
entry = binding.config["networks"][0]
59+
assert entry["network"] == "solana-mainnet-beta"
60+
assert entry["recipient"] == "solanaaddr"
61+
62+
63+
def test_mpp_solana_devnet_caip2_to_ucp_namespace() -> None:
64+
from agentscore_commerce.payment import networks
65+
66+
spec = SolanaMppRailSpec(recipient="solanaaddr", network=networks.solana.devnet.caip2)
67+
out = mpp_payment_handler(networks=[spec])
68+
binding = out["sh.agentscore.payment.mpp"][0]
69+
assert binding.config is not None
70+
assert binding.config["networks"][0]["network"] == "solana-devnet"
71+
72+
73+
def test_mpp_mixed_tempo_solana_session() -> None:
74+
"""One call can mix Tempo, Solana MPP, and Tempo session rails."""
75+
out = mpp_payment_handler(
76+
networks=[
77+
TempoRailSpec(recipient="0xtempo"),
78+
SolanaMppRailSpec(recipient="solanaaddr"),
79+
TempoSessionRailSpec(recipient="0xsession", escrow_contract="0xescrow", store=object()),
80+
],
81+
)
82+
binding = out["sh.agentscore.payment.mpp"][0]
83+
assert binding.config is not None
84+
entries = binding.config["networks"]
85+
assert len(entries) == 3
86+
assert entries[2]["escrow_contract"] == "0xescrow"
87+
88+
89+
def test_mpp_unknown_spec_type_raises() -> None:
90+
with pytest.raises(TypeError, match="unsupported rail spec type"):
91+
mpp_payment_handler(networks=["not-a-spec"]) # type: ignore[list-item]
92+
93+
94+
def test_x402_base_mainnet_caip2_to_ucp_namespace() -> None:
95+
out = x402_payment_handler(networks=[X402BaseRailSpec(recipient="0xbase")])
96+
binding = out["sh.agentscore.payment.x402"][0]
97+
assert binding.config is not None
98+
entry = binding.config["networks"][0]
99+
assert entry["network"] == "base-8453"
100+
assert entry["recipient"] == "0xbase"
101+
102+
103+
def test_x402_base_sepolia_caip2_to_ucp_namespace() -> None:
104+
out = x402_payment_handler(
105+
networks=[X402BaseRailSpec(recipient="0xbase", network="eip155:84532")],
106+
)
107+
binding = out["sh.agentscore.payment.x402"][0]
108+
assert binding.config is not None
109+
assert binding.config["networks"][0]["network"] == "base-84532"
110+
111+
112+
def test_x402_factory_recipient_omitted() -> None:
113+
async def factory() -> str:
114+
return "0xdynamic"
115+
116+
out = x402_payment_handler(networks=[X402BaseRailSpec(recipient=factory)])
117+
binding = out["sh.agentscore.payment.x402"][0]
118+
assert binding.config is not None
119+
assert "recipient" not in binding.config["networks"][0]
120+
121+
122+
def test_x402_unknown_network_passes_through_verbatim() -> None:
123+
"""A non-standard CAIP-2 (e.g. an unsupported chain) ships through unchanged."""
124+
out = x402_payment_handler(
125+
networks=[X402BaseRailSpec(recipient="0xbase", network="custom-rail-id")],
126+
)
127+
binding = out["sh.agentscore.payment.x402"][0]
128+
assert binding.config is not None
129+
assert binding.config["networks"][0]["network"] == "custom-rail-id"
130+
131+
132+
def test_stripe_spt_handler_emits_profile_id() -> None:
133+
out = stripe_spt_payment_handler(spec=StripeRailSpec(profile_id="profile_5xKvNqM9BaH"))
134+
binding = out["sh.agentscore.payment.stripe_spt"][0]
135+
assert binding.config == {"rail": "stripe-spt", "profile_id": "profile_5xKvNqM9BaH"}
136+
137+
138+
def test_handler_metadata_versioning() -> None:
139+
"""All three handlers share the same handler-version constant and spec URL prefix."""
140+
mpp = mpp_payment_handler(networks=[TempoRailSpec(recipient="0xt")])
141+
x402 = x402_payment_handler(networks=[X402BaseRailSpec(recipient="0xb")])
142+
stripe = stripe_spt_payment_handler(spec=StripeRailSpec(profile_id="profile_x"))
143+
mpp_binding = mpp["sh.agentscore.payment.mpp"][0]
144+
x402_binding = x402["sh.agentscore.payment.x402"][0]
145+
stripe_binding = stripe["sh.agentscore.payment.stripe_spt"][0]
146+
# All same version + spec/schema base.
147+
assert mpp_binding.version == x402_binding.version == stripe_binding.version
148+
assert all(
149+
b.spec.startswith("https://agentscore.sh/specification/payment-handlers/")
150+
for b in [mpp_binding, x402_binding, stripe_binding]
151+
)

0 commit comments

Comments
 (0)