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
16 changes: 16 additions & 0 deletions agentscore_commerce/payment/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@
create_mppx_server,
)
from agentscore_commerce.payment.networks import NetworkFamily, network_family, networks
from agentscore_commerce.payment.rail_spec import (
RecipientLike,
SolanaMppRailSpec,
StripeRailSpec,
TempoRailSpec,
TempoSessionRailSpec,
X402BaseRailSpec,
resolve_recipient,
)
from agentscore_commerce.payment.rails import RailDefinition, lookup_rail, rails
from agentscore_commerce.payment.settlement_override import (
SETTLEMENT_OVERRIDES_HEADER,
Expand Down Expand Up @@ -89,14 +98,20 @@
"ProcessX402SettleResult",
"ProcessX402SettleSuccess",
"RailDefinition",
"RecipientLike",
"SignerNetwork",
"SolanaMppRailSpec",
"StripeRail",
"StripeRailSpec",
"TempoChargeRail",
"TempoRailSpec",
"TempoSessionRail",
"TempoSessionRailSpec",
"VerifyX402RequestFailure",
"VerifyX402RequestResult",
"VerifyX402RequestSuccess",
"X402AcceptsBlock",
"X402BaseRailSpec",
"X402FacilitatorChoice",
"X402SymbolicRail",
"ZeroSettleRail",
Expand Down Expand Up @@ -126,6 +141,7 @@
"rails",
"read_x402_payment_header",
"register_x402_schemes_v1_v2",
"resolve_recipient",
"settle_result_to_json_bytes",
"settlement_override_header",
"usd_to_atomic",
Expand Down
139 changes: 139 additions & 0 deletions agentscore_commerce/payment/rail_spec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""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.

`RecipientLike` is polymorphic over `str | Callable[[], Awaitable[str]]` so
per-order recipients (Stripe-multichain mints fresh deposit addresses per
PaymentIntent) flow through identically to static-treasury recipients. The
factory is called once per helper invocation; callers cache externally.
"""

from __future__ import annotations

import inspect
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any, Literal, cast

from agentscore_commerce.payment.networks import networks
from agentscore_commerce.payment.usdc import USDC

RecipientLike = str | Callable[[], Awaitable[str]] | Callable[[], str]


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.
"""
if isinstance(r, str):
return r
result = r()
if inspect.isawaitable(result):
return cast("str", await result)
return cast("str", result)


@dataclass
class TempoRailSpec:
"""Canonical config for the Tempo MPP rail."""

recipient: RecipientLike
network: str = "tempo-mainnet"
chain_id: int = 4217
token: str = USDC.tempo.mainnet.address
symbol: str = "USDC.e"
decimals: int = 6
testnet: bool = False
recommend: Literal["tempo", "agentscore-pay", "both"] = "both"


@dataclass
class X402BaseRailSpec:
"""Canonical config for the x402 EVM (Base) rail."""

recipient: RecipientLike
network: str = "eip155:8453" # CAIP-2 canonical
chain_id: int = 8453
token: str = USDC.base.mainnet.address
symbol: str = "USDC"
decimals: int = 6
mode: Literal["exact", "upto"] = "exact"


@dataclass
class SolanaMppRailSpec:
"""Canonical config for the Solana MPP rail.

`signer` is an optional fee-payer signer for server-side fee sponsorship —
typed as `Any` to avoid hard-importing `@solana/kit`-equivalent types here.
"""

recipient: RecipientLike
network: str = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
token: str = USDC.solana.mainnet.mint
symbol: str = "USDC"
decimals: int = 6
rpc_url: str | None = None
signer: Any | None = None
token_program: str | None = None


@dataclass
class StripeRailSpec:
"""Canonical config for the Stripe SPT rail.

`recipient` is intentionally absent — Stripe rails use `profile_id` as the
merchant-side network identifier the agent's SPT is scoped to; the
transaction recipient is the merchant's Stripe account, not an on-chain
address.
"""

profile_id: str | None = None
rails: list[str] = field(default_factory=lambda: ["card", "link", "shared_payment_token"])
payment_method_types: list[str] | None = None
product_name: str | None = None
secret_key: str | None = None


@dataclass
class TempoSessionRailSpec:
"""Canonical config for the Tempo session MPP rail (pay-as-you-go channels).

`escrow_contract` is the merchant-deployed on-chain escrow that holds
channel deposits + pays out cumulative vouchers on settlement.
`store` is a `ChannelStore` instance (in-memory default for dev; Postgres /
Redis-backed in production) — typed as `Any` to avoid hard-importing
`mppx`'s store interface here.
"""

recipient: RecipientLike
escrow_contract: str
store: Any
currency: str = USDC.tempo.mainnet.address
testnet: bool = False
chains: Any | None = None


__all__ = [
"RecipientLike",
"SolanaMppRailSpec",
"StripeRailSpec",
"TempoRailSpec",
"TempoSessionRailSpec",
"X402BaseRailSpec",
"resolve_recipient",
]


# Reference the networks module to keep an explicit dependency edge — the
# CAIP-2 default values above are sourced from `networks.tempo.mainnet.caip2`
# and `networks.base.mainnet.caip2` semantics. Asserted via tests.
_ = networks
137 changes: 137 additions & 0 deletions tests/test_rail_spec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Tests for the canonical *RailSpec types + RecipientLike resolution."""

from __future__ import annotations

import pytest

from agentscore_commerce.payment import (
SolanaMppRailSpec,
StripeRailSpec,
TempoRailSpec,
TempoSessionRailSpec,
X402BaseRailSpec,
resolve_recipient,
)


def test_tempo_rail_spec_defaults() -> None:
"""Mainnet defaults match the USDC + networks registries."""
spec = TempoRailSpec(recipient="0xfeedface")
assert spec.recipient == "0xfeedface"
assert spec.network == "tempo-mainnet"
assert spec.chain_id == 4217
assert spec.symbol == "USDC.e"
assert spec.decimals == 6
assert spec.testnet is False
assert spec.recommend == "both"


def test_x402_base_rail_spec_defaults() -> None:
"""Defaults pin Base mainnet (CAIP-2 `eip155:8453`) + USDC."""
spec = X402BaseRailSpec(recipient="0xfeedface")
assert spec.network == "eip155:8453"
assert spec.chain_id == 8453
assert spec.symbol == "USDC"
assert spec.decimals == 6
assert spec.mode == "exact"


def test_x402_base_rail_spec_upto_mode() -> None:
"""`mode='upto'` is the Permit2 + Settlement-Overrides variant."""
spec = X402BaseRailSpec(recipient="0xfeedface", mode="upto")
assert spec.mode == "upto"


def test_solana_mpp_rail_spec_defaults() -> None:
spec = SolanaMppRailSpec(recipient="GEQg2TM4VL315Bd4LLkGrhBjdNfoatKjCJYHBDPM3D74")
assert spec.network.startswith("solana:")
assert spec.symbol == "USDC"
assert spec.decimals == 6
assert spec.rpc_url is None
assert spec.signer is None
assert spec.token_program is None


def test_solana_mpp_rail_spec_with_fee_payer_signer() -> None:
"""Fee-payer signer roundtrips through the spec — opaque object."""
sentinel_signer = object()
spec = SolanaMppRailSpec(recipient="GEQg2TM4VL315Bd4LLkGrhBjdNfoatKjCJYHBDPM3D74", signer=sentinel_signer)
assert spec.signer is sentinel_signer


def test_stripe_rail_spec_defaults() -> None:
"""Stripe has no on-chain recipient; profile_id replaces it."""
spec = StripeRailSpec(profile_id="profile_abc")
assert spec.profile_id == "profile_abc"
assert spec.rails == ["card", "link", "shared_payment_token"]
assert spec.payment_method_types is None
assert spec.product_name is None
assert spec.secret_key is None


def test_stripe_rail_spec_default_factory_isolation() -> None:
"""Each instance gets its own rails list (no shared mutable default)."""
a = StripeRailSpec()
b = StripeRailSpec()
a.rails.append("custom")
assert b.rails == ["card", "link", "shared_payment_token"]


def test_tempo_session_rail_spec_defaults() -> None:
"""Session rail requires escrow + store; defaults mirror tempo."""
spec = TempoSessionRailSpec(
recipient="0xfeedface",
escrow_contract="0xescrow",
store=object(),
)
assert spec.escrow_contract == "0xescrow"
assert spec.testnet is False
assert spec.chains is None


@pytest.mark.asyncio
async def test_resolve_recipient_string_returns_verbatim() -> None:
assert await resolve_recipient("0xfeedface") == "0xfeedface"


@pytest.mark.asyncio
async def test_resolve_recipient_sync_callable() -> None:
"""Sync factory: called once, return value used directly."""
calls = 0

def factory() -> str:
nonlocal calls
calls += 1
return "0xdynamic"

assert await resolve_recipient(factory) == "0xdynamic"
assert calls == 1


@pytest.mark.asyncio
async def test_resolve_recipient_async_callable() -> None:
"""Async factory: awaited once."""
calls = 0

async def factory() -> str:
nonlocal calls
calls += 1
return "0xdynamic"

assert await resolve_recipient(factory) == "0xdynamic"
assert calls == 1


@pytest.mark.asyncio
async def test_resolve_recipient_called_once_per_resolution() -> None:
"""Each `resolve_recipient` call invokes the factory once — caching is caller-side."""
calls = 0

async def factory() -> str:
nonlocal calls
calls += 1
return f"0xattempt-{calls}"

assert await resolve_recipient(factory) == "0xattempt-1"
assert await resolve_recipient(factory) == "0xattempt-2"
assert calls == 2