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
8 changes: 2 additions & 6 deletions agentscore_commerce/discovery/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
from typing import Any, Literal, Protocol

from agentscore_commerce.payment.directive import (
PaymentDirectiveInput,
PaymentRequestInput,
build_payment_request_blob,
payment_directive,
)
Expand Down Expand Up @@ -122,12 +120,10 @@ def build_discovery_probe_response(opts: DiscoveryProbeOptions) -> DiscoveryProb
probe_id = f"probe_{int(datetime.now(UTC).timestamp() * 1000)}"
expires = (datetime.now(UTC) + timedelta(seconds=opts.ttl_seconds)).isoformat().replace("+00:00", "Z")
request = build_payment_request_blob(
PaymentRequestInput(rail=opts.sample_rail, amount_usd=opts.sample_amount_usd, recipient=opts.sample_recipient)
rail=opts.sample_rail, amount_usd=opts.sample_amount_usd, recipient=opts.sample_recipient
)
directive = payment_directive(
PaymentDirectiveInput(
rail=opts.sample_rail, id=probe_id, realm=opts.realm, intent=opts.intent, expires=expires, request=request
)
rail=opts.sample_rail, id=probe_id, realm=opts.realm, intent=opts.intent, expires=expires, request=request
)
body_obj: dict[str, Any] = {
"error": {
Expand Down
10 changes: 0 additions & 10 deletions agentscore_commerce/payment/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,12 @@

from agentscore_commerce.payment.amounts import usd_to_atomic
from agentscore_commerce.payment.directive import (
BuildPaymentDirectiveInput,
PaymentDirectiveInput,
PaymentRequestInput,
build_payment_directive,
build_payment_request_blob,
payment_directive,
)
from agentscore_commerce.payment.dispatch import detect_rail_from_headers, dispatch_settlement_by_network
from agentscore_commerce.payment.headers import (
BuildPaymentHeadersInput,
PaymentHeadersRail,
PaymentHeadersResult,
X402AcceptsBlock,
Expand All @@ -29,7 +25,6 @@
from agentscore_commerce.payment.rails import RailDefinition, lookup_rail, rails
from agentscore_commerce.payment.settlement_override import (
SETTLEMENT_OVERRIDES_HEADER,
SettlementOverrides,
settlement_override_header,
)
from agentscore_commerce.payment.signer import (
Expand Down Expand Up @@ -85,24 +80,19 @@
"SETTLEMENT_OVERRIDES_HEADER",
"USDC",
"X402_SUPPORTED_BASE_NETWORKS",
"BuildPaymentDirectiveInput",
"BuildPaymentHeadersInput",
"ClassifiedX402Error",
"CreateX402ServerOptions",
"CustomScheme",
"MppxRails",
"NetworkFamily",
"PaymentDirectiveInput",
"PaymentHeadersRail",
"PaymentHeadersResult",
"PaymentRequestInput",
"PaymentRequiredHeaderInput",
"PaymentSigner",
"ProcessX402SettleFailure",
"ProcessX402SettleResult",
"ProcessX402SettleSuccess",
"RailDefinition",
"SettlementOverrides",
"SignerNetwork",
"StripeRail",
"TempoChargeRail",
Expand Down
148 changes: 69 additions & 79 deletions agentscore_commerce/payment/directive.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,106 +6,96 @@

import base64
import json
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta

from agentscore_commerce.payment.rails import lookup_rail


@dataclass
class PaymentRequestInput:
amount_usd: float | str
rail: str | None = None
currency: str | None = None
decimals: int | None = None
recipient: str | None = None
chain_id: int | None = None
network_id: str | None = None # Stripe profile_id; camelCase per link-cli mpp decode validator


def build_payment_request_blob(input: PaymentRequestInput) -> str:
def build_payment_request_blob(
*,
amount_usd: float | str,
rail: str | None = None,
currency: str | None = None,
decimals: int | None = None,
recipient: str | None = None,
chain_id: int | None = None,
# Stripe profile_id; camelCase per link-cli mpp decode validator.
network_id: str | None = None,
) -> str:
"""Build the base64url-encoded `request` blob for an MPP Payment directive."""
rail_def = lookup_rail(input.rail) if input.rail else None
decimals = input.decimals if input.decimals is not None else (rail_def.decimals if rail_def else 6)
currency = input.currency or (rail_def.currency if rail_def else "usd")
chain_id = input.chain_id if input.chain_id is not None else (rail_def.chain_id if rail_def else None)

amount_num = float(input.amount_usd) if isinstance(input.amount_usd, str) else input.amount_usd
amount_raw = str(round(amount_num * 10**decimals))
blob: dict[str, object] = {"amount": amount_raw, "currency": currency, "decimals": decimals}
if input.recipient:
blob["recipient"] = input.recipient
rail_def = lookup_rail(rail) if rail else None
resolved_decimals = decimals if decimals is not None else (rail_def.decimals if rail_def else 6)
resolved_currency = currency or (rail_def.currency if rail_def else "usd")
resolved_chain_id = chain_id if chain_id is not None else (rail_def.chain_id if rail_def else None)

amount_num = float(amount_usd) if isinstance(amount_usd, str) else amount_usd
amount_raw = str(round(amount_num * 10**resolved_decimals))
blob: dict[str, object] = {"amount": amount_raw, "currency": resolved_currency, "decimals": resolved_decimals}
if recipient:
blob["recipient"] = recipient
method_details: dict[str, object] = {}
if chain_id is not None:
method_details["chainId"] = chain_id
if input.network_id:
method_details["networkId"] = input.network_id
if resolved_chain_id is not None:
method_details["chainId"] = resolved_chain_id
if network_id:
method_details["networkId"] = network_id
if method_details:
blob["methodDetails"] = method_details

raw = json.dumps(blob, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()


@dataclass
class PaymentDirectiveInput:
id: str
realm: str
request: str
rail: str | None = None
method: str | None = None
intent: str = "charge"
expires: str | None = None


def payment_directive(input: PaymentDirectiveInput) -> str:
def payment_directive(
*,
id: str,
realm: str,
request: str,
rail: str | None = None,
method: str | None = None,
intent: str = "charge",
expires: str | None = None,
) -> str:
"""Format an MPP Payment directive string for the WWW-Authenticate header."""
rail_def = lookup_rail(input.rail) if input.rail else None
method = input.method or (rail_def.method if rail_def else "unknown")
expires = input.expires or (datetime.now(UTC) + timedelta(minutes=5)).isoformat().replace("+00:00", "Z")
rail_def = lookup_rail(rail) if rail else None
resolved_method = method or (rail_def.method if rail_def else "unknown")
resolved_expires = expires or (datetime.now(UTC) + timedelta(minutes=5)).isoformat().replace("+00:00", "Z")
return (
f'Payment id="{input.id}", realm="{input.realm}", method="{method}", '
f'intent="{input.intent}", expires="{expires}", request="{input.request}"'
f'Payment id="{id}", realm="{realm}", method="{resolved_method}", '
f'intent="{intent}", expires="{resolved_expires}", request="{request}"'
)


@dataclass
class BuildPaymentDirectiveInput:
rail: str
id: str
realm: str
amount_usd: float | str
currency: str | None = None
decimals: int | None = None
recipient: str | None = None
chain_id: int | None = None
network_id: str | None = None
method: str | None = None
intent: str = "charge"
expires: str | None = None


def build_payment_directive(input: BuildPaymentDirectiveInput) -> str:
def build_payment_directive(
*,
rail: str,
id: str,
realm: str,
amount_usd: float | str,
currency: str | None = None,
decimals: int | None = None,
recipient: str | None = None,
chain_id: int | None = None,
network_id: str | None = None,
method: str | None = None,
intent: str = "charge",
expires: str | None = None,
) -> str:
"""Convenience: build the request blob + directive in one call."""
request = build_payment_request_blob(
PaymentRequestInput(
rail=input.rail,
amount_usd=input.amount_usd,
currency=input.currency,
decimals=input.decimals,
recipient=input.recipient,
chain_id=input.chain_id,
network_id=input.network_id,
)
rail=rail,
amount_usd=amount_usd,
currency=currency,
decimals=decimals,
recipient=recipient,
chain_id=chain_id,
network_id=network_id,
)
return payment_directive(
PaymentDirectiveInput(
rail=input.rail,
id=input.id,
realm=input.realm,
method=input.method,
intent=input.intent,
expires=input.expires,
request=request,
)
rail=rail,
id=id,
realm=realm,
method=method,
intent=intent,
expires=expires,
request=request,
)
85 changes: 38 additions & 47 deletions agentscore_commerce/payment/headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,10 @@

from __future__ import annotations

from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Any, Literal, TypedDict

from agentscore_commerce.payment.directive import (
BuildPaymentDirectiveInput,
build_payment_directive,
)
from agentscore_commerce.payment.directive import build_payment_directive
from agentscore_commerce.payment.wwwauthenticate import (
PaymentRequiredHeaderInput,
payment_required_header,
Expand Down Expand Up @@ -69,23 +66,6 @@ class X402AcceptsBlock:
resource: dict[str, str] | None = None


@dataclass
class BuildPaymentHeadersInput:
"""Input shape for :func:`build_payment_headers`."""

rails: list[PaymentHeadersRail] = field(default_factory=list)
order_id: str = ""
"""Order id used as the directive challenge id (per-rail it becomes ``{order_id}-{rail}``)."""

realm: str = ""
"""Realm — the host of the merchant URL (e.g. ``agents.merchant.example``)."""

x402: X402AcceptsBlock | None = None
"""Optional x402 ``accepts`` array — included as the standard PAYMENT-REQUIRED header
so x402 clients (``x402[fastapi]``, ``agentscore-pay``) can parse the binary-friendly
format. Pass ``None`` (or omit) to skip the PAYMENT-REQUIRED header."""


class PaymentHeadersResult(TypedDict, total=False):
"""Header dict returned by :func:`build_payment_headers`."""

Expand All @@ -98,19 +78,31 @@ class PaymentHeadersResult(TypedDict, total=False):
were provided."""


def build_payment_headers(input: BuildPaymentHeadersInput) -> PaymentHeadersResult:
def build_payment_headers(
*,
rails: list[PaymentHeadersRail] | None = None,
order_id: str = "",
realm: str = "",
x402: X402AcceptsBlock | None = None,
) -> PaymentHeadersResult:
"""Compose WWW-Authenticate + PAYMENT-REQUIRED headers from a single rails declaration.

Returns a dict with snake_case keys — callers map to actual HTTP header names::

headers = build_payment_headers(BuildPaymentHeadersInput(...))
headers = build_payment_headers(...)
response.headers["www-authenticate"] = headers["www_authenticate"]
if "payment_required" in headers:
response.headers["PAYMENT-REQUIRED"] = headers["payment_required"]

``order_id`` is used as the directive challenge id (per-rail it becomes
``"{order_id}-{rail}"``). ``realm`` is the host of the merchant URL (e.g.
``agents.merchant.example``). ``x402`` is optional — pass an ``X402AcceptsBlock``
to include the standard PAYMENT-REQUIRED header so x402 clients (``x402[fastapi]``,
``agentscore-pay``) can parse the binary-friendly format. Omit to skip.

Example::

headers = build_payment_headers(BuildPaymentHeadersInput(
headers = build_payment_headers(
order_id="ord_123",
realm="agents.merchant.example",
rails=[
Expand All @@ -119,45 +111,44 @@ def build_payment_headers(input: BuildPaymentHeadersInput) -> PaymentHeadersResu
PaymentHeadersRail(rail="stripe", amount_usd=25, network_id=STRIPE_PROFILE_ID),
],
x402=X402AcceptsBlock(accepts=x402_accepts, version=2),
))
)
"""
directives = []
for rail in input.rails:
# `intent` is non-Optional on BuildPaymentDirectiveInput (defaults to "charge");
for rail_entry in rails or []:
# `intent` is non-Optional on build_payment_directive (defaults to "charge");
# only forward when the rail explicitly sets it so the default applies otherwise.
kwargs: dict[str, Any] = {
"id": f"{input.order_id}-{rail.rail}",
"realm": input.realm,
"rail": rail.rail,
"amount_usd": rail.amount_usd,
"recipient": rail.recipient,
"network_id": rail.network_id,
"chain_id": rail.chain_id,
"currency": rail.currency,
"decimals": rail.decimals,
"method": rail.method,
"expires": rail.expires,
"id": f"{order_id}-{rail_entry.rail}",
"realm": realm,
"rail": rail_entry.rail,
"amount_usd": rail_entry.amount_usd,
"recipient": rail_entry.recipient,
"network_id": rail_entry.network_id,
"chain_id": rail_entry.chain_id,
"currency": rail_entry.currency,
"decimals": rail_entry.decimals,
"method": rail_entry.method,
"expires": rail_entry.expires,
}
if rail.intent is not None:
kwargs["intent"] = rail.intent
directives.append(build_payment_directive(BuildPaymentDirectiveInput(**kwargs)))
if rail_entry.intent is not None:
kwargs["intent"] = rail_entry.intent
directives.append(build_payment_directive(**kwargs))

result: PaymentHeadersResult = {"www_authenticate": www_authenticate_header(directives)}

if input.x402 is not None:
if x402 is not None:
result["payment_required"] = payment_required_header(
PaymentRequiredHeaderInput(
x402_version=input.x402.version,
accepts=input.x402.accepts,
resource=input.x402.resource,
x402_version=x402.version,
accepts=x402.accepts,
resource=x402.resource,
),
)

return result


__all__ = [
"BuildPaymentHeadersInput",
"PaymentHeadersRail",
"PaymentHeadersResult",
"X402AcceptsBlock",
Expand Down
Loading