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
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Every helper is extracted from a real consumer, not speculated.
| Submodule | What it is |
|---|---|
| `agentscore_commerce.identity.{fastapi,flask,django,aiohttp,sanic,middleware}` | Trust gate middleware (KYC, age, sanctions, jurisdiction) |
| `agentscore_commerce.payment` | Networks/USDC/rails registries, paymentauth.org directive builders, `create_x402_server` (wraps official `x402[evm,svm]>=2.8` peer dep with v1+v2 dual-register + bazaar extension), `create_mppx_server` (wraps `pympp[server,tempo,stripe]>=0.6` peer dep with Tempo charge/session + Stripe SPT helpers), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header |
| `agentscore_commerce.payment` | Networks/USDC/rails registries, paymentauth.org directive builders, `create_x402_server` (wraps official `x402[evm]>=2.8` peer dep with v1+v2 dual-register + bazaar extension), `create_mppx_server` (wraps `pympp[server,tempo,stripe]>=0.6` peer dep with Tempo charge/session + Stripe SPT helpers), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header |
| `agentscore_commerce.discovery` | Discovery probe, Bazaar wrapper, `/.well-known/mpp.json`, `llms.txt` builder, `skill.md` builder (Claude-Skill-compatible agent-discovery manifest), OpenAPI snippets, `NoindexNonDiscoveryMiddleware` ASGI middleware |
| `agentscore_commerce.challenge` | 402-body builders: accepted_methods, identity_metadata, how_to_pay, agent_instructions, build_402_body, `build_validation_error` (4xx body builder) |
| `agentscore_commerce.stripe_multichain` | Multichain PaymentIntent helper, deposit-address lookup, testnet simulator, mppx Stripe wrapper |
Expand All @@ -30,15 +30,15 @@ Single Python package, hatchling-built, published to PyPI as `agentscore-commerc
| `examples/` | Runnable single-file FastAPI apps for each common scenario |
| `tests/` | pytest, one file per surface |

Peer-dep pattern: payment/x402/mppx/stripe modules import lazily at runtime — vendors install only what they use via extras (`pip install agentscore-commerce[fastapi,stripe]` etc.). Underlying packages: `x402[evm,svm]`, `pympp[server,tempo,stripe]`, `stripe`. Missing peer dep raises a guiding `ImportError` with the install command.
Peer-dep pattern: payment/x402/mppx/stripe modules import lazily at runtime — vendors install only what they use via extras (`pip install agentscore-commerce[fastapi,stripe]` etc.). Underlying packages: `x402[evm]`, `pympp[server,tempo,stripe]`, `stripe`. Missing peer dep raises a guiding `ImportError` with the install command.

## Examples

`examples/` contains full single-file FastAPI apps for the most common merchant scenarios — copy-paste templates, not frameworks:

| Example | Scenario |
|---|---|
| `api_provider.py` | Per-call API billing on multiple rails: Tempo MPP + x402 (Base + Solana); no compliance gate |
| `api_provider.py` | Per-call API billing on multiple rails: Tempo MPP + x402 Base + Solana MPP; no compliance gate |
| `identity_only.py` | Compliance gate without payment (vendor handles their own) |
| `multi_rail_merchant.py` | Full agent-commerce: identity + Tempo MPP + x402 + Stripe SPT |
| `stripe_multichain_merchant.py` | Stripe-anchored multichain (PaymentIntent → tempo/base/solana deposit addresses) |
Expand Down
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pip install agentscore-commerce[fastapi] # or [flask], [django], [aiohttp], [s
|---|---|
| `agentscore_commerce.identity.{fastapi,flask,django,aiohttp,sanic,middleware}` | Trust gate middleware: KYC, sanctions, age, jurisdiction. `AgentScoreGate(...)` (or `agentscore_gate(app, ...)` on Flask/Sanic), `get_assess_data(...)`, `capture_wallet(...)`, `verify_wallet_signer_match(...)`. |
| `agentscore_commerce.identity` (package level) | Re-exports the denial helpers: `denial_reason_status`, `denial_reason_to_body`, `build_signer_mismatch_body`, `build_contact_support_next_steps`, `verification_agent_instructions`, `is_fixable_denial`, `FIXABLE_DENIAL_REASONS`. Also re-exports the per-product policy helpers: `PolicyBlock`, `GateResult`, `EnforcementMode`, `IdentityStatus`, `build_gate_from_policy`, `run_gate_with_enforcement`, `shipping_country_allowed`, `shipping_state_allowed` — for multi-product merchants where each product carries its own compliance config (hard gate vs soft vs none, per-product shipping allowlists). |
| `agentscore_commerce.payment` | `networks`, `USDC`, `rails` registries; `payment_directive`, `build_payment_directive`, `www_authenticate_header`, `payment_required_header`, `alias_amount_fields` (v1↔v2 amount field shim — emits both `amount` and `maxAmountRequired` so v1-only x402 parsers like Coinbase awal can read v2 bodies), `settlement_override_header`, `dispatch_settlement_by_network`, `extract_payment_signer` (returns `PaymentSigner({address, network})`), `register_x402_schemes_v1_v2`; drop-in x402 helpers: `validate_x402_network_config` (boot-time guard), `verify_x402_request` (parse + validate inbound X-Payment), `process_x402_settle` (verify-then-settle with one call). |
| `agentscore_commerce.payment` | `networks`, `USDC`, `rails` registries; `payment_directive`, `build_payment_directive`, `www_authenticate_header`, `payment_required_header`, `alias_amount_fields` (v1↔v2 amount field shim — emits both `amount` and `maxAmountRequired` so v1-only x402 parsers like Coinbase awal can read v2 bodies), `settlement_override_header`, `dispatch_settlement_by_network`, `extract_payment_signer` (returns `PaymentSigner({address, network})`), `register_x402_schemes_v1_v2`; drop-in x402 helpers: `validate_x402_network_config` (boot-time guard), `verify_x402_request` (parse + validate inbound X-Payment), `process_x402_settle` (verify-then-settle with one call), `classify_x402_settle_result` (maps the tagged settle result to a recommended HTTP status / code / next_steps so merchants get a controlled envelope without coupling to facilitator-specific error text). |
| `agentscore_commerce.discovery` | `is_discovery_probe_request`, `build_discovery_probe_response` (with optional `x402_sample` for x402-aware crawlers — `awal x402 details` etc.), `sample_x402_accept_for_network` (USDC sample-accept builder for known CAIP-2 networks), `build_well_known_mpp`, `build_llms_txt` + `llms_txt_identity_section` + `llms_txt_payment_section` (compact + verbose modes), `build_skill_md` (Claude-Skill-compatible `/skill.md` agent-discovery manifest — strictly agent-facing data only, no internal posture), `agentscore_openapi_snippets`, `build_bazaar_discovery_payload`, `NoindexNonDiscoveryMiddleware` (ASGI middleware that emits `X-Robots-Tag: noindex` on every path except the agent-discovery surfaces — defaults cover `/openapi.json`, `/llms.txt`, `/skill.md`, `/.well-known/{mpp.json,agent-card.json,ucp}`, `/favicon.{png,ico}`; pure helpers `is_discovery_path` + `DEFAULT_DISCOVERY_PATHS` for non-ASGI frameworks). |
| `agentscore_commerce.challenge` | `build_402_body`, `build_accepted_methods`, `build_identity_metadata`, `build_how_to_pay`, `build_agent_instructions` (auto-emits per-rail `compatible_clients` — smoke-verified CLIs the agent should use; vendor override supported), `build_pricing_block` (cents → dollar-string with optional shipping/tax), `first_encounter_agent_memory` (cross-merchant hint, returns the canonical block or `None` based on a per-merchant first-seen flag), `OrderReceipt` (dataclass for the post-settlement 200 response shape); `respond_402` — drop-in 402 emit that preserves pympp's `WWW-Authenticate` and layers x402's `PAYMENT-REQUIRED`. `build_validation_error` — structured 4xx body builder (`{error: {code, message}, required_fields?, example_body?, next_steps?, ...extra}`) so vendors compose body shapes by name instead of inlining at every validation site. |
| `agentscore_commerce.stripe_multichain` | `create_multichain_payment_intent`, `get_deposit_address`, `simulate_crypto_deposit`; `create_pi_cache` (TTL'd PI / deposit-address cache, Redis-backed when `redis_url` set, in-memory otherwise), `simulate_deposit_if_test_mode` (gates on `sk_test_` and looks up the PI for you), `STRIPE_TEST_TX_HASH_SUCCESS` / `STRIPE_TEST_TX_HASH_FAILED` constants. Peer dep on `stripe`. |
Expand Down Expand Up @@ -247,15 +247,14 @@ from agentscore_commerce.payment import (
ProcessX402SettleInput,
ValidateX402NetworkConfigInput,
VerifyX402RequestInput,
classify_x402_settle_result,
process_x402_settle,
validate_x402_network_config,
verify_x402_request,
)

# Boot-time guard — raises if a configured network isn't supported.
validate_x402_network_config(
ValidateX402NetworkConfigInput(base_network=X402_BASE, svm_network=X402_SVM)
)
validate_x402_network_config(ValidateX402NetworkConfigInput(base_network=X402_BASE))

@app.post("/purchase")
async def purchase(request: Request):
Expand All @@ -264,8 +263,7 @@ async def purchase(request: Request):
verified = await verify_x402_request(VerifyX402RequestInput(
headers=dict(request.headers),
is_cached_address=pi_cache.has_address,
accepted_base_network=X402_BASE,
accepted_svm_network=X402_SVM,
accepted_network=X402_BASE,
))
if not verified.ok:
return JSONResponse(verified.body, status_code=verified.status)
Expand All @@ -276,8 +274,14 @@ async def purchase(request: Request):
resource_config={"scheme": "exact", "network": verified.signed_network, "price": f"${total}", "payTo": verified.signed_pay_to, "maxTimeoutSeconds": 300},
resource_meta={"url": str(request.url), "mimeType": "application/json"},
))
if not settle.success:
return JSONResponse({"error": {"code": "payment_proof_invalid", "phase": settle.phase}}, status_code=400)
classified = classify_x402_settle_result(settle)
if classified is not None:
# Log raw `settle` server-side; return controlled phase-based response to the agent.
logger.error("x402-settle failed phase=%s raw=%r", settle.phase, settle)
return JSONResponse(
{"error": {"code": classified.code, "message": classified.message}, "next_steps": classified.next_steps},
status_code=classified.status,
)

headers = {"payment-response": settle.payment_response_header} if settle.payment_response_header else {}
return JSONResponse({"ok": True}, headers=headers)
Expand Down
8 changes: 4 additions & 4 deletions agentscore_commerce/challenge/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

from agentscore_commerce.challenge.accepted_methods import (
BuildAcceptedMethodsInput,
SolanaMppConfig,
StripeConfig,
TempoConfig,
X402BaseConfig,
X402SolanaConfig,
build_accepted_methods,
)
from agentscore_commerce.challenge.agent_instructions import (
Expand All @@ -21,10 +21,10 @@
from agentscore_commerce.challenge.how_to_pay import (
BuildHowToPayInput,
HowToPayRails,
SolanaMppRailConfig,
StripeRailConfig,
TempoRailConfig,
X402BaseRailConfig,
X402SolanaRailConfig,
build_how_to_pay,
)
from agentscore_commerce.challenge.identity import (
Expand Down Expand Up @@ -64,15 +64,15 @@
"Respond402Result",
"ShippingAddress",
"SignerMatchResult",
"SolanaMppConfig",
"SolanaMppRailConfig",
"StripeConfig",
"StripeRailConfig",
"TempoConfig",
"TempoRailConfig",
"X402BaseConfig",
"X402BaseRailConfig",
"X402PaymentRequired",
"X402SolanaConfig",
"X402SolanaRailConfig",
"build_402_body",
"build_accepted_methods",
"build_agent_instructions",
Expand Down
16 changes: 8 additions & 8 deletions agentscore_commerce/challenge/accepted_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class X402BaseConfig:


@dataclass
class X402SolanaConfig:
class SolanaMppConfig:
recipient: str
network: str = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
token: str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
Expand All @@ -43,7 +43,7 @@ class StripeConfig:
class BuildAcceptedMethodsInput:
tempo: TempoConfig | None = None
x402_base: X402BaseConfig | None = None
x402_solana: X402SolanaConfig | None = None
solana_mpp: SolanaMppConfig | None = None
stripe: StripeConfig | None = None


Expand Down Expand Up @@ -74,15 +74,15 @@ def build_accepted_methods(input: BuildAcceptedMethodsInput) -> list[dict[str, A
"pay_to": input.x402_base.recipient,
}
)
if input.x402_solana:
if input.solana_mpp:
out.append(
{
"method": "x402/exact",
"network": input.x402_solana.network,
"token": input.x402_solana.token,
"symbol": input.x402_solana.symbol,
"decimals": input.x402_solana.decimals,
"pay_to": input.x402_solana.recipient,
"network": input.solana_mpp.network,
"token": input.solana_mpp.token,
"symbol": input.solana_mpp.symbol,
"decimals": input.solana_mpp.decimals,
"pay_to": input.solana_mpp.recipient,
}
)
if input.stripe:
Expand Down
34 changes: 20 additions & 14 deletions agentscore_commerce/challenge/agent_instructions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,17 @@

_TEMPO_WARNING = (
"Do NOT use `tempo wallet transfer` to pay to the address above. That moves USDC on-chain but does not "
"notify this server, leaving your order in pending_identity state. Use `tempo request` insteadit performs "
"the full MPP handshake (signs, submits Authorization: Payment, waits for server confirmation)."
"notify this server, so the order will not complete. Use `tempo request` instead; it performs the full MPP "
"handshake (signs, submits Authorization: Payment, waits for server confirmation)."
)
_X402_WARNING = (
"Do NOT send USDC manually to the x402 deposit addresses (e.g. via a bare wallet `transfer`). Use "
"`agentscore-pay pay` so the X-Payment credential is signed and submitted; otherwise the order stays in "
"pending_identity even though the deposit lands."
"`agentscore-pay pay` so the X-Payment credential is signed and submitted; otherwise the order will not "
"complete even though the deposit lands."
)
_TEMPO_TOOL = "`tempo request` for Tempo USDC (installs via `tempo add request`)"
_AGENTSCORE_PAY_TOOL = (
"`agentscore-pay` (npm: `@agent-score/pay`) — single CLI for x402 on Base + Solana, "
"also speaks tempo MPP via `--chain tempo`"
"`agentscore-pay` (npm: `@agent-score/pay`); single CLI for x402 on Base and MPP on Tempo + Solana"
)

DEFAULT_WALLET_COMPATIBILITY = (
Expand All @@ -30,7 +29,7 @@
def _default_recommended_tools(how_to_pay: dict[str, Any]) -> list[str]:
tools: list[str] = []
has_tempo = "tempo" in how_to_pay
has_x402 = "x402_base" in how_to_pay or "x402_solana" in how_to_pay
has_x402 = "x402_base" in how_to_pay or "solana_mpp" in how_to_pay
if has_tempo:
tools.append(_TEMPO_TOOL)
if has_tempo or has_x402:
Expand All @@ -42,17 +41,17 @@ def _default_warnings(how_to_pay: dict[str, Any]) -> list[str]:
w: list[str] = []
if "tempo" in how_to_pay:
w.append(_TEMPO_WARNING)
if "x402_base" in how_to_pay or "x402_solana" in how_to_pay:
if "x402_base" in how_to_pay or "solana_mpp" in how_to_pay:
w.append(_X402_WARNING)
return w


RailKey = Literal["tempo_mpp", "x402_base", "x402_solana", "stripe"]
RailKey = Literal["tempo_mpp", "x402_base", "solana_mpp", "stripe"]

_RAIL_CLIENTS: dict[str, list[str]] = {
"tempo_mpp": ["agentscore-pay", "tempo request", "x402-proxy"],
"x402_base": ["agentscore-pay", "x402-proxy", "purl (omit --network flag)"],
"x402_solana": ["agentscore-pay"],
"solana_mpp": ["agentscore-pay"],
"stripe": ["link-cli"],
}

Expand Down Expand Up @@ -84,8 +83,8 @@ def _default_compatible_clients(how_to_pay: dict[str, Any]) -> dict[str, list[st
rails.append("tempo_mpp")
if "x402_base" in how_to_pay:
rails.append("x402_base")
if "x402_solana" in how_to_pay:
rails.append("x402_solana")
if "solana_mpp" in how_to_pay:
rails.append("solana_mpp")
if "stripe" in how_to_pay:
rails.append("stripe")
return compatible_clients_by_rails(rails)
Expand All @@ -98,6 +97,9 @@ class BuildAgentInstructionsInput:
wallet_compatibility: str | None = None
timeout_seconds: int = 300
warnings: list[str] | None = None
# Appended to the default protocol-footgun warnings. Use this to keep the SDK's
# protocol warnings AND add merchant-specific notes. Ignored when ``warnings`` is set.
extra_warnings: list[str] | None = None
recommended: str | None = None
# Per-rail list of client names the merchant has verified work end-to-end.
# Vendors set this from their own smoke matrix — defaults to None, in which case
Expand All @@ -112,12 +114,16 @@ def build_agent_instructions(input: BuildAgentInstructionsInput) -> dict[str, An

Defaults adapt to the rails declared in ``how_to_pay``: only tempo-relevant warnings/tools
appear if ``how_to_pay["tempo"]`` is set, only x402-relevant ones if ``x402_base``/
``x402_solana`` are set. Vendors override ``warnings``/``recommended_tools`` for full control.
``solana_mpp`` are set. Vendors override ``warnings``/``recommended_tools`` for full control.
"""
recommended_tools = (
input.recommended_tools if input.recommended_tools is not None else _default_recommended_tools(input.how_to_pay)
)
warnings = input.warnings if input.warnings is not None else _default_warnings(input.how_to_pay)
warnings = (
input.warnings
if input.warnings is not None
else [*_default_warnings(input.how_to_pay), *(input.extra_warnings or [])]
)
compatible_clients = (
input.compatible_clients
if input.compatible_clients is not None
Expand Down
10 changes: 5 additions & 5 deletions agentscore_commerce/challenge/how_to_pay.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class X402BaseRailConfig:


@dataclass
class X402SolanaRailConfig:
class SolanaMppRailConfig:
recipient: str
network: str = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"

Expand All @@ -35,7 +35,7 @@ class StripeRailConfig:
class HowToPayRails:
tempo: TempoRailConfig | None = None
x402_base: X402BaseRailConfig | None = None
x402_solana: X402SolanaRailConfig | None = None
solana_mpp: SolanaMppRailConfig | None = None
stripe: StripeRailConfig | None = None


Expand Down Expand Up @@ -125,9 +125,9 @@ def build_how_to_pay(input: BuildHowToPayInput) -> dict[str, Any]:
),
}

if input.rails.x402_solana:
s = input.rails.x402_solana
block["x402_solana"] = {
if input.rails.solana_mpp:
s = input.rails.solana_mpp
block["solana_mpp"] = {
"setup": PAY_SETUP_SOLANA,
"prerequisite": (
f"Run `agentscore-pay balance --chain solana` and confirm USDC balance on Solana ({s.network}) "
Expand Down
7 changes: 3 additions & 4 deletions agentscore_commerce/challenge/order_receipt.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
"""Canonical order-receipt shape returned to agents on the 200 after settlement.

Merchants own their order schema, but converging on this shape across every AgentScore-gated
merchant (Martin Estate today; Commerce7 / WooCommerce / Shopify plugins tomorrow) means
agents can render and post-process orders consistently. Lift this type, fill the fields you
care about, and ignore (or extend via ``extras``) what you don't.
Merchants own their order schema, but converging on this shape across AgentScore-gated
merchants means agents can render and post-process orders consistently. Lift this type,
fill the fields you care about, and ignore (or extend via ``extras``) what you don't.

All money fields are dollar-strings. Use :func:`build_pricing_block` from
:mod:`agentscore_commerce.challenge` to compose the pricing fields from cents.
Expand Down
Loading
Loading