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
93 changes: 40 additions & 53 deletions agentscore_commerce/challenge/accepted_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,84 +2,71 @@

from typing import Any

_DEFAULT_TEMPO = {
"network": "tempo-mainnet",
"chain_id": 4217,
"token": "0x20C000000000000000000000b9537d11c60E8b50",
"symbol": "USDC.e",
"decimals": 6,
}
_DEFAULT_X402_BASE = {
"network": "eip155:8453",
"chain_id": 8453,
"token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"symbol": "USDC",
"decimals": 6,
}
_DEFAULT_SOLANA_MPP = {
"network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
"token": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"symbol": "USDC",
"decimals": 6,
}
_DEFAULT_STRIPE_RAILS = ["card", "link", "shared_payment_token"]
from agentscore_commerce.payment.rail_spec import (
SolanaMppRailSpec,
StripeRailSpec,
TempoRailSpec,
X402BaseRailSpec,
resolve_recipient,
)


def build_accepted_methods(
async def build_accepted_methods(
*,
tempo: dict[str, Any] | None = None,
x402_base: dict[str, Any] | None = None,
solana_mpp: dict[str, Any] | None = None,
stripe: dict[str, Any] | None = None,
tempo: TempoRailSpec | None = None,
x402_base: X402BaseRailSpec | None = None,
solana_mpp: SolanaMppRailSpec | None = None,
stripe: StripeRailSpec | None = None,
) -> list[dict[str, Any]]:
"""Build the accepted_methods[] array. Each rail entry conditionally included if vendor passed it.
"""Build the accepted_methods[] array.

Each rail value is a plain dict. Required key: ``recipient`` (or ``profile_id`` for stripe).
Optional keys override the rail's protocol defaults: ``network``, ``chain_id``, ``token``,
``symbol``, ``decimals`` (for chain rails) or ``rails`` (for stripe).
Each rail entry is conditionally included when the vendor passed a `*RailSpec`
for that rail. Each spec's `recipient` is resolved via `resolve_recipient` so
per-order factories (e.g. Stripe-multichain mints fresh deposits per
PaymentIntent) flow through identically to static-treasury strings.
"""
out: list[dict[str, Any]] = []
if tempo:
if tempo is not None:
out.append(
{
"method": "tempo/charge",
"network": tempo.get("network", _DEFAULT_TEMPO["network"]),
"chain_id": tempo.get("chain_id", _DEFAULT_TEMPO["chain_id"]),
"token": tempo.get("token", _DEFAULT_TEMPO["token"]),
"symbol": tempo.get("symbol", _DEFAULT_TEMPO["symbol"]),
"decimals": tempo.get("decimals", _DEFAULT_TEMPO["decimals"]),
"pay_to": tempo["recipient"],
"network": tempo.network,
"chain_id": tempo.chain_id,
"token": tempo.token,
"symbol": tempo.symbol,
"decimals": tempo.decimals,
"pay_to": await resolve_recipient(tempo.recipient),
}
)
if x402_base:
if x402_base is not None:
out.append(
{
"method": "x402/exact",
"network": x402_base.get("network", _DEFAULT_X402_BASE["network"]),
"chain_id": x402_base.get("chain_id", _DEFAULT_X402_BASE["chain_id"]),
"token": x402_base.get("token", _DEFAULT_X402_BASE["token"]),
"symbol": x402_base.get("symbol", _DEFAULT_X402_BASE["symbol"]),
"decimals": x402_base.get("decimals", _DEFAULT_X402_BASE["decimals"]),
"pay_to": x402_base["recipient"],
"network": x402_base.network,
"chain_id": x402_base.chain_id,
"token": x402_base.token,
"symbol": x402_base.symbol,
"decimals": x402_base.decimals,
"pay_to": await resolve_recipient(x402_base.recipient),
}
)
if solana_mpp:
if solana_mpp is not None:
out.append(
{
"method": "x402/exact",
"network": solana_mpp.get("network", _DEFAULT_SOLANA_MPP["network"]),
"token": solana_mpp.get("token", _DEFAULT_SOLANA_MPP["token"]),
"symbol": solana_mpp.get("symbol", _DEFAULT_SOLANA_MPP["symbol"]),
"decimals": solana_mpp.get("decimals", _DEFAULT_SOLANA_MPP["decimals"]),
"pay_to": solana_mpp["recipient"],
"network": solana_mpp.network,
"token": solana_mpp.token,
"symbol": solana_mpp.symbol,
"decimals": solana_mpp.decimals,
"pay_to": await resolve_recipient(solana_mpp.recipient),
}
)
if stripe:
if stripe is not None:
out.append(
{
"method": "stripe/charge",
"rails": stripe.get("rails", _DEFAULT_STRIPE_RAILS),
"profile_id": stripe.get("profile_id"),
"rails": list(stripe.rails),
"profile_id": stripe.profile_id,
}
)
return out
52 changes: 33 additions & 19 deletions agentscore_commerce/challenge/how_to_pay.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
import math
from typing import Any

_DEFAULT_TEMPO = {"network_name": "tempo-mainnet", "chain_id": 4217, "recommend": "both"}
_DEFAULT_X402_BASE = {"network": "eip155:8453"}
_DEFAULT_SOLANA_MPP = {"network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"}
from agentscore_commerce.payment.rail_spec import (
SolanaMppRailSpec,
StripeRailSpec,
TempoRailSpec,
X402BaseRailSpec,
)

TEMPO_SETUP = [
"curl -fsSL https://tempo.xyz/install | bash",
Expand All @@ -25,33 +28,44 @@
]


def build_how_to_pay(
class HowToPayRails(dict):
"""`build_how_to_pay`'s `rails` map.

Dict keyed by rail name where each value is the matching `*RailSpec`. Subclasses
`dict` so existing callers' literal-dict construction keeps working without
import churn while still benefiting from the type alias when annotated explicitly.
"""


async def build_how_to_pay(
*,
url: str,
retry_body_json: str,
total_usd: float | str,
rails: dict[str, dict[str, Any]],
rails: dict[str, TempoRailSpec | X402BaseRailSpec | SolanaMppRailSpec | StripeRailSpec],
op_token_placeholder: str = "<your_opc_token>", # noqa: S107 — literal placeholder, not a secret
max_spend: float | str | None = None,
) -> dict[str, Any]:
"""Build the agent_instructions.how_to_pay block.

Generates per-rail setup/command/what_it_does so agents see concrete commands per rail in the 402 body.
``rails`` is a dict keyed by rail name (``"tempo"``, ``"x402_base"``, ``"solana_mpp"``, ``"stripe"``);
each value is a plain dict carrying the rail's config (``recipient`` for chain rails, ``profile_id``
+ ``product_name`` for stripe, plus the protocol-default overrides ``network`` / ``network_name`` /
``chain_id`` / ``recommend``). Pass ``rails={}`` to emit no per-rail block.
each value is the matching `*RailSpec` instance. Pass ``rails={}`` to emit no per-rail block.

`recipient` resolution: only `accepted_methods` consumes the resolved address; `how_to_pay`
surfaces commands the agent runs, none of which include the recipient string. So this builder
does NOT resolve `RecipientLike` factories.
"""
total_num = float(total_usd) if isinstance(total_usd, str) else total_usd
max_spend_str = str(max_spend) if max_spend is not None else f"{math.ceil(total_num) + 1:.2f}"
op_token = op_token_placeholder
block: dict[str, Any] = {}

tempo = rails.get("tempo")
if tempo:
network_name = tempo.get("network_name", _DEFAULT_TEMPO["network_name"])
chain_id = tempo.get("chain_id", _DEFAULT_TEMPO["chain_id"])
recommend = tempo.get("recommend", _DEFAULT_TEMPO["recommend"])
if isinstance(tempo, TempoRailSpec):
network_name = "tempo-testnet" if tempo.testnet else tempo.network
chain_id = tempo.chain_id
recommend = tempo.recommend
tempo_command = (
f"tempo request -X POST -H 'X-Operator-Token: {op_token}' -H 'Content-Type: application/json' "
f"--json '{retry_body_json}' --max-spend {max_spend_str} {url}"
Expand Down Expand Up @@ -80,8 +94,8 @@ def build_how_to_pay(
block["tempo"] = entry

x402_base = rails.get("x402_base")
if x402_base:
network = x402_base.get("network", _DEFAULT_X402_BASE["network"])
if isinstance(x402_base, X402BaseRailSpec):
network = x402_base.network
block["x402_base"] = {
"setup": PAY_SETUP_BASE,
"prerequisite": (
Expand All @@ -100,8 +114,8 @@ def build_how_to_pay(
}

solana_mpp = rails.get("solana_mpp")
if solana_mpp:
network = solana_mpp.get("network", _DEFAULT_SOLANA_MPP["network"])
if isinstance(solana_mpp, SolanaMppRailSpec):
network = solana_mpp.network
block["solana_mpp"] = {
"setup": PAY_SETUP_SOLANA,
"prerequisite": (
Expand All @@ -120,9 +134,9 @@ def build_how_to_pay(
}

stripe = rails.get("stripe")
if stripe:
profile_id = stripe.get("profile_id")
product_name = stripe.get("product_name") or "this purchase"
if isinstance(stripe, StripeRailSpec):
profile_id = stripe.profile_id
product_name = stripe.product_name or "this purchase"
amount_cents = round(total_num * 100)
link_cli_blocked = amount_cents > 50000
spt_context = (
Expand Down
15 changes: 7 additions & 8 deletions agentscore_commerce/payment/rail_spec.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Canonical `*RailSpec` types — one shape per rail, consumed by every helper.

Pre-304 a merchant accepting Tempo + Base + Solana + Stripe restated the same
recipient four times in four different shapes (`build_accepted_methods`,
`build_how_to_pay`, `mpp_payment_handler`, `create_mppx_server` each had its own
per-rail config). This module unifies those into one `*RailSpec` per rail; every
helper accepts the same instance.
A merchant accepting Tempo + Base + Solana + Stripe declares one `*RailSpec`
per rail and passes it to every helper (`build_accepted_methods`,
`build_how_to_pay`, `mpp_payment_handler`, `create_mppx_server`, ...). One
canonical shape per rail means the recipient address, network identifier, and
token defaults are declared once and reused everywhere.

`RecipientLike` is polymorphic over `str | Callable[[], Awaitable[str]]` so
per-order recipients (Stripe-multichain mints fresh deposit addresses per
Expand All @@ -29,9 +29,8 @@ async def resolve_recipient(r: RecipientLike) -> str:
"""Resolve a `RecipientLike` to a concrete address string.

Accepts a string (returned verbatim), a sync callable (called once), or an
async callable (awaited once). The orchestrator (TEC-305) calls this once
per session and caches the resolved value; helpers within a session never
re-invoke the factory.
async callable (awaited once). Helpers call this on every invocation;
callers that want once-per-session resolution should cache externally.
"""
if isinstance(r, str):
return r
Expand Down
30 changes: 18 additions & 12 deletions examples/multi_rail_merchant.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
from agentscore_commerce.identity.fastapi import AgentScoreGate, get_agentscore_data
from agentscore_commerce.payment import (
USDC,
SolanaMppRailSpec,
StripeRailSpec,
TempoRailSpec,
X402BaseRailSpec,
build_x402_accepts_for_402,
networks,
process_x402_settle,
Expand Down Expand Up @@ -186,22 +190,24 @@ async def purchase(request: Request, assess: dict = Depends(get_agentscore_data)
# WWW-Auth + adds x402's PAYMENT-REQUIRED):
pympx_challenge_headers = {"www-authenticate": 'Payment id="..."'} # from pympp.compose
deposit_addresses = {"tempo": "0x...", "base": "0x...", "solana": "..."} # from create_multichain_payment_intent
accepted = build_accepted_methods(
tempo={"recipient": deposit_addresses["tempo"]},
x402_base={"recipient": deposit_addresses["base"]},
solana_mpp={"recipient": deposit_addresses["solana"]},
stripe={"profile_id": os.environ["STRIPE_PROFILE_ID"]},
# Declare every rail once — every helper consumes the same RailSpec instances.
rails = {
"tempo": TempoRailSpec(recipient=deposit_addresses["tempo"]),
"x402_base": X402BaseRailSpec(recipient=deposit_addresses["base"]),
"solana_mpp": SolanaMppRailSpec(recipient=deposit_addresses["solana"]),
"stripe": StripeRailSpec(profile_id=os.environ["STRIPE_PROFILE_ID"]),
}
accepted = await build_accepted_methods(
tempo=rails["tempo"],
x402_base=rails["x402_base"],
solana_mpp=rails["solana_mpp"],
stripe=rails["stripe"],
)
how_to_pay = build_how_to_pay(
how_to_pay = await build_how_to_pay(
url=APP_URL,
retry_body_json=str(body),
total_usd=total_usd,
rails={
"tempo": {"recipient": deposit_addresses["tempo"]},
"x402_base": {"recipient": deposit_addresses["base"]},
"solana_mpp": {"recipient": deposit_addresses["solana"]},
"stripe": {"profile_id": os.environ["STRIPE_PROFILE_ID"]},
},
rails=rails,
)

result = respond_402(
Expand Down
Loading