diff --git a/CLAUDE.md b/CLAUDE.md index e44fc67..c747512 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | @@ -30,7 +30,7 @@ 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 @@ -38,7 +38,7 @@ Peer-dep pattern: payment/x402/mppx/stripe modules import lazily at runtime — | 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) | diff --git a/README.md b/README.md index 5f5a705..c22a48a 100644 --- a/README.md +++ b/README.md @@ -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`. | @@ -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): @@ -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) @@ -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) diff --git a/agentscore_commerce/challenge/__init__.py b/agentscore_commerce/challenge/__init__.py index df73157..311a213 100644 --- a/agentscore_commerce/challenge/__init__.py +++ b/agentscore_commerce/challenge/__init__.py @@ -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 ( @@ -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 ( @@ -64,6 +64,8 @@ "Respond402Result", "ShippingAddress", "SignerMatchResult", + "SolanaMppConfig", + "SolanaMppRailConfig", "StripeConfig", "StripeRailConfig", "TempoConfig", @@ -71,8 +73,6 @@ "X402BaseConfig", "X402BaseRailConfig", "X402PaymentRequired", - "X402SolanaConfig", - "X402SolanaRailConfig", "build_402_body", "build_accepted_methods", "build_agent_instructions", diff --git a/agentscore_commerce/challenge/accepted_methods.py b/agentscore_commerce/challenge/accepted_methods.py index 23b749f..7d28c5b 100644 --- a/agentscore_commerce/challenge/accepted_methods.py +++ b/agentscore_commerce/challenge/accepted_methods.py @@ -25,7 +25,7 @@ class X402BaseConfig: @dataclass -class X402SolanaConfig: +class SolanaMppConfig: recipient: str network: str = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" token: str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" @@ -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 @@ -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: diff --git a/agentscore_commerce/challenge/agent_instructions.py b/agentscore_commerce/challenge/agent_instructions.py index eb312bc..865cb85 100644 --- a/agentscore_commerce/challenge/agent_instructions.py +++ b/agentscore_commerce/challenge/agent_instructions.py @@ -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` instead — it 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 = ( @@ -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: @@ -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"], } @@ -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) @@ -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 @@ -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 diff --git a/agentscore_commerce/challenge/how_to_pay.py b/agentscore_commerce/challenge/how_to_pay.py index efb3b85..1ff13fc 100644 --- a/agentscore_commerce/challenge/how_to_pay.py +++ b/agentscore_commerce/challenge/how_to_pay.py @@ -20,7 +20,7 @@ class X402BaseRailConfig: @dataclass -class X402SolanaRailConfig: +class SolanaMppRailConfig: recipient: str network: str = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" @@ -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 @@ -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}) " diff --git a/agentscore_commerce/challenge/order_receipt.py b/agentscore_commerce/challenge/order_receipt.py index feba251..3f5b0e0 100644 --- a/agentscore_commerce/challenge/order_receipt.py +++ b/agentscore_commerce/challenge/order_receipt.py @@ -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. diff --git a/agentscore_commerce/discovery/__init__.py b/agentscore_commerce/discovery/__init__.py index b526cf2..05abeee 100644 --- a/agentscore_commerce/discovery/__init__.py +++ b/agentscore_commerce/discovery/__init__.py @@ -12,10 +12,17 @@ ) from agentscore_commerce.discovery.openapi import ( BuildAgentScoreOpenApiSnippetsInput, + XPaymentInfoDynamicPrice, + XPaymentInfoFixedPrice, + XPaymentInfoInput, + XPaymentInfoMpp, agentscore_denial_schemas, agentscore_openapi_snippets, agentscore_payment_required_schema, agentscore_security_schemes, + siwx_security_scheme, + x_guidance_extension, + x_payment_info_extension, ) from agentscore_commerce.discovery.probe import ( DiscoveryProbeOptions, @@ -48,6 +55,11 @@ WellKnownMppInput, build_well_known_mpp, ) +from agentscore_commerce.discovery.well_known_x402 import ( + BuildWellKnownX402Input, + WellKnownX402Resource, + build_well_known_x402, +) __all__ = [ "DEFAULT_DISCOVERY_PATHS", @@ -56,6 +68,7 @@ "BuildAgentScoreOpenApiSnippetsInput", "BuildLlmsTxtInput", "BuildSkillMdInput", + "BuildWellKnownX402Input", "DiscoveryProbeOptions", "DiscoveryProbeResponse", "DjangoNoindexMiddleware", @@ -70,7 +83,12 @@ "SkillMdLink", "SkillMdShippingPolicy", "WellKnownMppInput", + "WellKnownX402Resource", "X402SampleProbe", + "XPaymentInfoDynamicPrice", + "XPaymentInfoFixedPrice", + "XPaymentInfoInput", + "XPaymentInfoMpp", "agentscore_denial_schemas", "agentscore_openapi_snippets", "agentscore_payment_required_schema", @@ -80,6 +98,7 @@ "build_llms_txt", "build_skill_md", "build_well_known_mpp", + "build_well_known_x402", "compatible_clients_by_rails", "install_flask_noindex", "is_discovery_path", @@ -87,4 +106,7 @@ "llms_txt_identity_section", "llms_txt_payment_section", "sample_x402_accept_for_network", + "siwx_security_scheme", + "x_guidance_extension", + "x_payment_info_extension", ] diff --git a/agentscore_commerce/discovery/llms_txt.py b/agentscore_commerce/discovery/llms_txt.py index 1c58b1c..106e296 100644 --- a/agentscore_commerce/discovery/llms_txt.py +++ b/agentscore_commerce/discovery/llms_txt.py @@ -100,7 +100,7 @@ def _llms_txt_payment_section_compact(input: LlmsTxtPaymentSectionInput) -> str: f"- **x402 USDC on Base** (EIP-3009) — `agentscore-pay pay POST {input.app_url} --chain base " "-H \"X-Operator-Token: opc_...\" -d '{...}'`" ) - if _has_rail_family(rails, "x402-solana-"): + if _has_rail_family(rails, "mpp-solana-"): lines.append( f"- **x402 USDC on Solana** (SPL Token) — `agentscore-pay pay POST {input.app_url} --chain solana " "-H \"X-Operator-Token: opc_...\" -d '{...}'`" @@ -124,10 +124,10 @@ def _llms_txt_payment_section_verbose(input: LlmsTxtPaymentSectionInput) -> str: rails = list(input.rails) has_tempo = _has_rail_family(rails, "tempo-") has_base = _has_rail_family(rails, "x402-base-") - has_solana = _has_rail_family(rails, "x402-solana-") + has_solana = _has_rail_family(rails, "mpp-solana-") has_stripe = "stripe-spt" in rails base_network_name = "Base Sepolia" if _is_testnet_rail(rails, "x402-base-") else "Base" - solana_network_name = "Solana devnet" if _is_testnet_rail(rails, "x402-solana-") else "Solana" + solana_network_name = "Solana devnet" if _is_testnet_rail(rails, "mpp-solana-") else "Solana" lines: list[str] = ["## Payment", ""] lines.append( @@ -232,8 +232,8 @@ def _llms_txt_payment_section_verbose(input: LlmsTxtPaymentSectionInput) -> str: lines.append("") lines.append( - "IMPORTANT: Do NOT use `tempo wallet transfer` or send USDC manually to the x402 deposit addresses — " - "those bypass the payment handshake and your order will stay in pending_identity." + "IMPORTANT: Do NOT use `tempo wallet transfer` or send USDC manually to the x402 deposit addresses; " + "those bypass the payment handshake and the order will not complete." ) if has_base or has_solana: lines.append( diff --git a/agentscore_commerce/discovery/openapi.py b/agentscore_commerce/discovery/openapi.py index 4b7235f..dcb7ba5 100644 --- a/agentscore_commerce/discovery/openapi.py +++ b/agentscore_commerce/discovery/openapi.py @@ -1,11 +1,16 @@ """OpenAPI snippets for AgentScore-related concepts (security schemes, denial schemas, 402 schema).""" from dataclasses import dataclass -from typing import Any +from typing import Any, Literal def agentscore_security_schemes() -> dict[str, Any]: - """Standard AgentScore identity security schemes for `components.securitySchemes`.""" + """Standard AgentScore identity security schemes for `components.securitySchemes`. + + Includes ``siwx`` (Sign-In With X) per the x402scan discovery spec so identity-gated + operations can declare ``security: [{ "siwx": [] }]`` and stay classified as + identity-only, not paid. + """ return { "OperatorToken": { "type": "apiKey", @@ -25,9 +30,89 @@ def agentscore_security_schemes() -> dict[str, Any]: "(Tempo MPP, x402 EIP-3009, x402 SPL Token). The wallet you claim MUST sign the payment." ), }, + "siwx": siwx_security_scheme(), } +def siwx_security_scheme() -> dict[str, Any]: + """Sign-In With X security scheme entry, per the x402scan discovery spec. + + Reference it on identity-gated (but free) operations as + ``security: [{ "siwx": [] }]``. Do NOT also attach ``x-payment-info`` to those + routes; x402scan will misclassify them as paid. + """ + return { + "type": "http", + "scheme": "bearer", + "bearerFormat": "SIWX", + "description": ( + "Sign-In With X wallet authentication. Agent signs a challenge with their wallet (any supported chain) " + "and presents the proof in the Authorization header. Used for identity-gated free endpoints; " + "payment-required endpoints declare x-payment-info instead." + ), + } + + +@dataclass +class XPaymentInfoFixedPrice: + currency: str + amount: str + mode: Literal["fixed"] = "fixed" + + +@dataclass +class XPaymentInfoDynamicPrice: + currency: str + min: str + max: str + mode: Literal["dynamic"] = "dynamic" + + +XPaymentInfoPrice = XPaymentInfoFixedPrice | XPaymentInfoDynamicPrice + + +@dataclass +class XPaymentInfoMpp: + method: str + intent: str + currency: str + + +@dataclass +class XPaymentInfoInput: + """Per-operation `x-payment-info` extension input, per the x402scan discovery spec. + + ``protocols`` is a list of single-key dicts. Use ``{"x402": {}}`` for x402, + ``{"mpp": {"method": ..., "intent": ..., "currency": ...}}`` for MPP. Order is + preserved. + """ + + price: XPaymentInfoPrice + protocols: list[dict[str, Any]] + + +def x_payment_info_extension(input: XPaymentInfoInput) -> dict[str, Any]: + """Wrap a price + protocols block under ``x-payment-info``. + + For spreading into an OpenAPI operation object. + """ + price = input.price + if isinstance(price, XPaymentInfoFixedPrice): + price_dict: dict[str, Any] = {"mode": "fixed", "currency": price.currency, "amount": price.amount} + else: + price_dict = {"mode": "dynamic", "currency": price.currency, "min": price.min, "max": price.max} + return {"x-payment-info": {"price": price_dict, "protocols": input.protocols}} + + +def x_guidance_extension(text: str) -> dict[str, str]: + """Wrap a prose blurb under ``x-guidance`` for spreading into an OpenAPI ``info`` block. + + Per the x402scan discovery spec, ``info.x-guidance`` should explain to an agent + how to use the API at a high level. Discovery crawlers surface this on listings. + """ + return {"x-guidance": text} + + def agentscore_denial_schemas() -> dict[str, Any]: """Standard AgentScore denial response schemas for `components.schemas`.""" return { diff --git a/agentscore_commerce/discovery/robots_tag.py b/agentscore_commerce/discovery/robots_tag.py index 8f32169..96b1857 100644 --- a/agentscore_commerce/discovery/robots_tag.py +++ b/agentscore_commerce/discovery/robots_tag.py @@ -24,6 +24,7 @@ "/skill.md", "/SKILL.md", "/.well-known/mpp.json", + "/.well-known/x402", "/.well-known/agent-card.json", "/.well-known/ucp", "/favicon.png", diff --git a/agentscore_commerce/discovery/skill_md.py b/agentscore_commerce/discovery/skill_md.py index 7004c69..c241759 100644 --- a/agentscore_commerce/discovery/skill_md.py +++ b/agentscore_commerce/discovery/skill_md.py @@ -99,7 +99,7 @@ class BuildSkillMdInput: """Merchant homepage (or domain root). Emitted as ``metadata.homepage`` per spec (top-level non-spec fields go under metadata).""" merchant_name: str - """Human display name (e.g. 'Martin Estate Winery').""" + """Human display name (e.g. 'Example Merchant').""" accepted_rails: list[RailKey] """Rails the merchant accepts. Drives the Payment + Compatible Clients sections. Order is preserved in render.""" @@ -158,7 +158,7 @@ class BuildSkillMdInput: _RAIL_LABELS: dict[str, str] = { "tempo_mpp": "MPP on Tempo", "x402_base": "x402 on Base", - "x402_solana": "x402 on Solana", + "solana_mpp": "MPP on Solana", "stripe": "Stripe Shared Payment Token", } @@ -168,7 +168,7 @@ class BuildSkillMdInput: "MPP credential goes in `Authorization: Payment`." ), "x402_base": "USDC (EIP-3009). Use `agentscore-pay`; X-Payment header carries the signed credential.", - "x402_solana": "USDC (SPL). Use `agentscore-pay`; X-Payment header carries the signed credential.", + "solana_mpp": "USDC (SPL). Use `agentscore-pay`; X-Payment header carries the signed credential.", "stripe": ( "Card via Link wallet. Use `@stripe/link-cli` — `agentscore-pay` emits the " "handoff hint when this rail is picked." diff --git a/agentscore_commerce/discovery/well_known_x402.py b/agentscore_commerce/discovery/well_known_x402.py new file mode 100644 index 0000000..e787262 --- /dev/null +++ b/agentscore_commerce/discovery/well_known_x402.py @@ -0,0 +1,45 @@ +"""``build_well_known_x402``: emits the x402scan v1 ``/.well-known/x402`` discovery shape. + +x402scan accepts three discovery strategies (OpenAPI > ``/.well-known/x402`` > endpoint +probe). Most AgentScore merchants already publish a richer ``/.well-known/mpp.json``, +but x402scan's strict parser only reads the v1 shape, so we emit both. The two coexist +on different paths. + +Spec (verbatim, x402scan):: + + { + "version": 1, + "resources": ["POST /api/route", ...] + } + +Resource entries are ``"METHOD /path"`` strings, not objects. Runtime 402 behavior is +authoritative over this static metadata. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass +class WellKnownX402Resource: + """Entry in the ``resources`` list.""" + + #: HTTP method, uppercase: ``GET | POST | PUT | PATCH | DELETE``. + method: str + #: Path with leading slash: ``/purchase``. + path: str + + +@dataclass +class BuildWellKnownX402Input: + #: Invocable, payment-required routes. Each entry becomes ``"METHOD /path"``. + resources: list[WellKnownX402Resource] + + +def build_well_known_x402(input: BuildWellKnownX402Input) -> dict[str, Any]: + return { + "version": 1, + "resources": [f"{r.method.upper()} {r.path}" for r in input.resources], + } diff --git a/agentscore_commerce/identity/a2a.py b/agentscore_commerce/identity/a2a.py index 1881d5e..dbdeecf 100644 --- a/agentscore_commerce/identity/a2a.py +++ b/agentscore_commerce/identity/a2a.py @@ -129,12 +129,12 @@ def build_a2a_agent_card( result = client.check(identity) card = build_a2a_agent_card( - name="Martin Estate Wine Concierge", - description="Buy regulated wines from Martin Estate via agent payments.", - url="https://agents.martinestate.com", + name="Example Merchant Concierge", + description="Buy regulated goods via agent payments.", + url="https://agents.example.com", capabilities=A2AAgentCardCapabilities( endpoints=[{"name": "purchase", "path": "/purchase", "method": "POST"}], - skills=["wine-purchase", "regulated-commerce"], + skills=["product-purchase", "regulated-commerce"], ), data=result, ) diff --git a/agentscore_commerce/identity/signer.py b/agentscore_commerce/identity/signer.py index a07353e..08e46aa 100644 --- a/agentscore_commerce/identity/signer.py +++ b/agentscore_commerce/identity/signer.py @@ -1,18 +1,16 @@ """Payment-signer extraction. -Pure-x402 extractor for Python. Two payload shapes are handled directly: +Pure-x402 extractor for Python. Handles **x402 EIP-3009** (EVM, e.g. Base/Sepolia): +``payload.authorization.from`` recovered from the base64-encoded JSON body. No external +deps. -- **x402 EIP-3009** (EVM, e.g. Base/Sepolia) — `payload.authorization.from` recovered - from the base64-encoded JSON body. No external deps. -- **x402 SVM** (Solana) — payload carries a base64-encoded Solana transaction; the - signer is the SPL Token TransferChecked source-account owner. Decoding that - transaction requires a Solana SDK (`solana-py` / `solders`) which isn't a hard - dep of this package — merchants who need Solana signer recovery should extract - the payer themselves and pass it to ``verify_wallet_signer_match`` via the - ``signer=`` argument. We return ``None`` here so the caller knows we couldn't - recover it. +Solana payments in the AgentScore stack go through MPP `solana/charge` +(``Authorization: Payment``), not x402, so they don't arrive at this helper. If a +non-AgentScore merchant does receive a legacy x402 SVM payload, this function returns +``None``; the caller should pass the recovered signer to ``verify_wallet_signer_match`` +via the ``signer=`` argument instead. -Tempo MPP signer extraction is also caller-supplied — there's no pip-installable +Tempo MPP signer extraction is also caller-supplied; there's no pip-installable equivalent of the node ``mppx`` library today. """ diff --git a/agentscore_commerce/identity/types.py b/agentscore_commerce/identity/types.py index 2832100..6b3174e 100644 --- a/agentscore_commerce/identity/types.py +++ b/agentscore_commerce/identity/types.py @@ -129,9 +129,8 @@ class VerifyWalletSignerResult: agent_instructions: str | None = None -# Canonical production AgentScore API — agent memory pointers are always hardcoded to this -# value regardless of how a given merchant configured their gate. Prevents a malicious merchant -# from emitting memory pointing agents at their own phishing endpoints. +# Canonical production AgentScore API; agent memory pointers are always hardcoded to this +# value regardless of how a given merchant configured their gate. _CANONICAL_AGENTSCORE_API = "https://api.agentscore.sh" diff --git a/agentscore_commerce/identity/ucp.py b/agentscore_commerce/identity/ucp.py index 916659e..52d08b6 100644 --- a/agentscore_commerce/identity/ucp.py +++ b/agentscore_commerce/identity/ucp.py @@ -177,14 +177,14 @@ def build_ucp_profile( async def ucp_profile(): result = await client.acheck(identity) return build_ucp_profile( - name="Martin Estate", - services=[UCPService(type="rest", url="https://agents.martinestate.com")], + name="Example Merchant", + services=[UCPService(type="rest", url="https://agents.example.com")], payment_handlers=[ UCPPaymentHandler(name="tempo", config={"recipient": TEMPO_ADDR}), UCPPaymentHandler(name="stripe", config={"profile_id": STRIPE_PROFILE_ID}), ], signing_keys=[ - UCPSigningKey(kid="me-2026-04", kty="EC", alg="ES256", crv="P-256", + UCPSigningKey(kid="merchant-2026-04", kty="EC", alg="ES256", crv="P-256", extras={"x": "...", "y": "..."}), ], data=result, diff --git a/agentscore_commerce/payment/__init__.py b/agentscore_commerce/payment/__init__.py index 783de7b..844f8e3 100644 --- a/agentscore_commerce/payment/__init__.py +++ b/agentscore_commerce/payment/__init__.py @@ -55,15 +55,16 @@ create_x402_server, ) from agentscore_commerce.payment.x402_settle import ( + ClassifiedX402Error, ProcessX402SettleFailure, ProcessX402SettleInput, ProcessX402SettleResult, ProcessX402SettleSuccess, + classify_x402_settle_result, process_x402_settle, ) from agentscore_commerce.payment.x402_validation import ( X402_SUPPORTED_BASE_NETWORKS, - X402_SUPPORTED_SVM_NETWORKS, ValidateX402NetworkConfigInput, VerifyX402RequestFailure, VerifyX402RequestInput, @@ -77,9 +78,9 @@ "SETTLEMENT_OVERRIDES_HEADER", "USDC", "X402_SUPPORTED_BASE_NETWORKS", - "X402_SUPPORTED_SVM_NETWORKS", "BuildPaymentDirectiveInput", "BuildPaymentHeadersInput", + "ClassifiedX402Error", "CreateX402ServerOptions", "CustomScheme", "MppxRails", @@ -113,6 +114,7 @@ "build_payment_directive", "build_payment_headers", "build_payment_request_blob", + "classify_x402_settle_result", "create_mppx_server", "create_x402_server", "dispatch_settlement_by_network", diff --git a/agentscore_commerce/payment/rails.py b/agentscore_commerce/payment/rails.py index d994770..35229e2 100644 --- a/agentscore_commerce/payment/rails.py +++ b/agentscore_commerce/payment/rails.py @@ -70,15 +70,15 @@ class RailDefinition: decimals=USDC.base.sepolia.decimals, asset=USDC.base.sepolia.address, ), - "x402-solana-mainnet": RailDefinition( - method="x402", + "mpp-solana-mainnet": RailDefinition( + method="solana", network=networks.solana.mainnet.caip2, currency=USDC.solana.mainnet.mint, decimals=USDC.solana.mainnet.decimals, asset=USDC.solana.mainnet.mint, ), - "x402-solana-devnet": RailDefinition( - method="x402", + "mpp-solana-devnet": RailDefinition( + method="solana", network=networks.solana.devnet.caip2, currency=USDC.solana.devnet.mint, decimals=USDC.solana.devnet.decimals, diff --git a/agentscore_commerce/payment/signer.py b/agentscore_commerce/payment/signer.py index 519717f..2e77967 100644 --- a/agentscore_commerce/payment/signer.py +++ b/agentscore_commerce/payment/signer.py @@ -1,9 +1,9 @@ """Network-aware signer extraction from x402 (EVM EIP-3009) credentials. Returns `{address, network}` so vendors can pass the network into `capture_wallet(...)` -without inferring it themselves. For Tempo MPP and Solana SPL Token signers, callers must -extract the signer themselves (no pip-installable equivalent of `mppx` / `@x402/svm` today) -and pass it directly to `verify_wallet_signer_match` via the `signer=` argument. +without inferring it themselves. For Tempo MPP and Solana signers, callers must extract +the signer themselves and pass it directly to `verify_wallet_signer_match` via the +`signer=` argument. """ from __future__ import annotations @@ -37,9 +37,8 @@ class PaymentSigner: def extract_payment_signer(x402_payment_header: str | None) -> PaymentSigner | None: """Decode an x402 header and return `{address, network}` or None. - Returns the EVM `from` address with `network='evm'` when the payload is EIP-3009 shape. - Returns None for Solana payloads (caller extracts SPL Token payer separately) or any - malformed/missing header. + Returns the EVM `from` address with `network='evm'` when the payload is EIP-3009 + shape. Returns None for any malformed/missing header. """ if not x402_payment_header: return None @@ -51,12 +50,6 @@ def extract_payment_signer(x402_payment_header: str | None) -> PaymentSigner | N if not isinstance(parsed, dict): return None - accepted = parsed.get("accepted") if isinstance(parsed.get("accepted"), dict) else {} - network = accepted.get("network") if isinstance(accepted, dict) else None - if isinstance(network, str) and network.startswith("solana:"): - # Caller must extract SPL Token payer themselves. - return None - payload = parsed.get("payload") if not isinstance(payload, dict): return None diff --git a/agentscore_commerce/payment/x402_server.py b/agentscore_commerce/payment/x402_server.py index 122b886..86b83b7 100644 --- a/agentscore_commerce/payment/x402_server.py +++ b/agentscore_commerce/payment/x402_server.py @@ -9,13 +9,13 @@ server = await create_x402_server( facilitator="coinbase", - rails=["x402-base-mainnet", "x402-solana-mainnet"], + rails=["x402-base-mainnet"], bazaar=True, ) `x402` is an OPTIONAL peer dependency — install only the schemes you use:: - pip install 'x402[evm,svm,fastapi]>=2.8,<3' # plus 'coinbase-x402' for the Coinbase facilitator + pip install 'x402[evm,fastapi]>=2.8,<3' # plus 'coinbase-x402' for the Coinbase facilitator """ from __future__ import annotations @@ -32,8 +32,6 @@ X402SymbolicRail = Literal[ "x402-base-mainnet", "x402-base-sepolia", - "x402-solana-mainnet", - "x402-solana-devnet", "x402-base-mainnet-upto", "x402-base-sepolia-upto", ] @@ -59,8 +57,7 @@ class CreateX402ServerOptions: rails: list[X402SymbolicRail] = field(default_factory=list) """Symbolic rail names to register schemes for. Each gets v1+v2 dual-register - applied. Requires the corresponding peer dep installed (``x402[evm]`` for base, - ``x402[svm]`` for solana).""" + applied. Requires ``x402[evm]`` peer dep installed.""" schemes: list[CustomScheme] = field(default_factory=list) """Advanced: register custom (network, scheme) pairs in addition to ``rails``.""" @@ -98,20 +95,14 @@ async def create_x402_server( rails_list = list(rails or []) schemes_list = list(schemes or []) - # Eager validation — surface bad rail combinations before paying for peer-dep resolution. - for rail in rails_list: - if rail.startswith("x402-solana") and rail.endswith("-upto"): - msg = f'Rail "{rail}" not supported — the Solana x402 scheme does not ship an upto variant yet (EVM-only).' - raise ValueError(msg) - # x402 2.9 layout: top-level `x402` package (with `x402` re-exports of # `x402ResourceServer`, `x402Facilitator`); schemes under - # `x402.mechanisms.{evm,svm}.{exact,upto}.server`. The 2.8-era v1+v2 dual + # `x402.mechanisms.evm.{exact,upto}.server`. The 2.8-era v1+v2 dual # register helper is obsolete — `register()` is v2 only and the resource # server handles v1 fallback internally via the facilitator. x402_top = _import_optional("x402") if x402_top is None or not hasattr(x402_top, "x402ResourceServer"): - msg = "x402 not installed — run `pip install 'x402[evm,svm,fastapi]>=2.9,<3'` to use create_x402_server." + msg = "x402 not installed — run `pip install 'x402[evm,fastapi]>=2.9,<3'` to use create_x402_server." raise ImportError(msg) facilitator_instance: Any @@ -132,7 +123,6 @@ async def create_x402_server( # Lazy-load scheme modules so vendors only need the peer deps for rails they use. evm_exact_module: Any | None = None evm_upto_module: Any | None = None - svm_module: Any | None = None for rail in rails_list: is_upto = rail.endswith("-upto") @@ -155,15 +145,6 @@ async def create_x402_server( msg = "x402[evm] not installed — run `pip install 'x402[evm]'` for x402 base rails." raise ImportError(msg) server.register(network, scheme_cls()) - elif rail.startswith("x402-solana"): - if svm_module is None: - svm_module = _import_optional("x402.mechanisms.svm.exact.server") - scheme_cls = getattr(svm_module, "ExactSvmScheme", None) if svm_module else None - if scheme_cls is None: - msg = "x402[svm] not installed — run `pip install 'x402[svm]'` for x402 solana rails." - raise ImportError(msg) - network = networks.solana.mainnet.caip2 if rail == "x402-solana-mainnet" else networks.solana.devnet.caip2 - server.register(network, scheme_cls()) for custom in schemes_list: server.register(custom.network, custom.scheme) diff --git a/agentscore_commerce/payment/x402_settle.py b/agentscore_commerce/payment/x402_settle.py index 11408d7..dc400fb 100644 --- a/agentscore_commerce/payment/x402_settle.py +++ b/agentscore_commerce/payment/x402_settle.py @@ -1,17 +1,18 @@ -"""``process_x402_settle`` — single-call x402 verify+settle for merchants. +"""``process_x402_settle``: single-call x402 verify+settle for merchants. Wraps the four x402-server steps every x402-accepting merchant repeats: -1. ``build_payment_requirements(resource_config)`` — builds the requirement entries the +1. ``build_payment_requirements(resource_config)``: builds the requirement entries the facilitator validates against -2. ``enrich_extensions(extension, transport_context)`` — folds in Bazaar (or other) +2. ``enrich_extensions(extension, transport_context)``: folds in Bazaar (or other) extensions for the verify step -3. ``process_payment_request(payload, resource_config, resource_meta, extensions)`` — +3. ``process_payment_request(payload, resource_config, resource_meta, extensions)``: runs verify against the facilitator -4. ``settle_payment(payload, matched_requirement)`` — settles on-chain +4. ``settle_payment(payload, matched_requirement)``: settles on-chain Returns a tagged result so the caller can map errors to merchant-shaped responses -without owning the orchestration boilerplate. +without owning the orchestration boilerplate. Use :func:`classify_x402_settle_result` +to map the tagged result to a recommended HTTP response. """ from __future__ import annotations @@ -57,25 +58,140 @@ class ProcessX402SettleSuccess: @dataclass class ProcessX402SettleFailure: - """Failure outcome from :func:`process_x402_settle`.""" - - phase: Literal["no_requirements", "verify_failed", "settle_failed"] + """Failure outcome from :func:`process_x402_settle`. + + Phases: + + - ``no_requirements``: ``build_payment_requirements`` returned an empty array; + merchant-side misconfiguration. Log ``reason`` server-side; map to a controlled + 500 to the consumer via :func:`classify_x402_settle_result`. + - ``verify_failed``: facilitator's verify step ran and returned ``{success: False}``. + Log ``verify_result`` server-side; map to a controlled 400 with + ``payment_proof_invalid`` to the consumer. + - ``settle_failed``: verify succeeded but ``settle_payment`` raised. Log raw + ``error`` server-side; map to a controlled 503 with + ``payment_provider_unavailable``. + - ``facilitator_error``: facilitator raised during one of the verify-stage calls + (build requirements, extension enrich, or process_payment_request). Most common + cause: facilitator client rejects the configured network. Log raw ``error`` + server-side; map to a controlled 503 so the agent can pick a different rail. + ``step`` indicates which verify-stage call raised. + """ + + phase: Literal["no_requirements", "verify_failed", "settle_failed", "facilitator_error"] success: Literal[False] = False reason: str | None = None verify_result: Any = None error: Any = None matched_requirement: Any = None + #: Populated only when ``phase == "facilitator_error"``. Indicates which verify-stage + #: call raised: ``"build_requirements"`` / ``"enrich_extensions"`` / ``"process_payment_request"``. + step: Literal["build_requirements", "enrich_extensions", "process_payment_request"] | None = None extra: dict[str, Any] = field(default_factory=dict) ProcessX402SettleResult = ProcessX402SettleSuccess | ProcessX402SettleFailure +@dataclass +class ClassifiedX402Error: + """Merchant-shaped response for a non-success :class:`ProcessX402SettleResult`. + + ``status`` / ``code`` / ``message`` are safe to send back to the consumer. + ``next_steps`` is the agent-instructions block describing what the agent should do + next. Raw facilitator errors stay server-side: do NOT serialize the original + ``error`` / ``verify_result`` / ``reason`` to the consumer; log them yourself. + """ + + status: Literal[400, 500, 503] + code: Literal["payment_proof_invalid", "payment_provider_unavailable", "payment_internal_error"] + message: str + next_steps: dict[str, Any] + + +def classify_x402_settle_result(result: ProcessX402SettleResult) -> ClassifiedX402Error | None: + """Map a :class:`ProcessX402SettleResult` to the recommended merchant response. + + Returns ``None`` for success. For each error phase, returns a controlled + status / code / message / next_steps tuple. Replaces error-message string + matching with phase-based dispatch so merchants stop coupling to + facilitator-specific error text. + + Phase mapping: + + - ``verify_failed``: 400 ``payment_proof_invalid`` / ``regenerate_payment_credential`` + - ``facilitator_error``: 503 ``payment_provider_unavailable`` / ``try_different_rail`` + - ``settle_failed``: 503 ``payment_provider_unavailable`` / ``retry_or_swap_method`` + - ``no_requirements``: 500 ``payment_internal_error`` / ``contact_support`` + + Always log the raw ``result`` server-side before responding; the returned + object is intentionally facilitator-agnostic and never carries raw error detail. + """ + if isinstance(result, ProcessX402SettleSuccess): + return None + if result.phase == "no_requirements": + return ClassifiedX402Error( + status=500, + code="payment_internal_error", + message="Failed to build x402 payment requirements for this configuration", + next_steps={ + "action": "contact_support", + "user_message": ( + "The merchant could not produce a payment challenge for this request. " + "Try again later or contact support." + ), + }, + ) + if result.phase == "verify_failed": + return ClassifiedX402Error( + status=400, + code="payment_proof_invalid", + message="Payment credential failed verification; regenerate from a fresh 402 challenge", + next_steps={ + "action": "regenerate_payment_credential", + "user_message": ( + "The payment credential was rejected at verify time. " + "Discard it, fetch a fresh 402 challenge, and re-sign." + ), + }, + ) + if result.phase == "facilitator_error": + return ClassifiedX402Error( + status=503, + code="payment_provider_unavailable", + message="Payment provider could not process this network configuration", + next_steps={ + "action": "try_different_rail", + "user_message": ( + "This rail is currently unavailable. Pick a different rail from the 402 challenge and retry." + ), + }, + ) + if result.phase == "settle_failed": + return ClassifiedX402Error( + status=503, + code="payment_provider_unavailable", + message="Payment credential verified but on-chain settlement failed", + next_steps={ + "action": "retry_or_swap_method", + "retry_after_seconds": 10, + "user_message": ( + "Transient settlement error. Retry in a few seconds, " + "or pick a different rail from the 402 challenge." + ), + }, + ) + return None + + async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402SettleResult: """Run the x402 verify→settle flow and return a tagged outcome.""" server = input.x402_server - built_requirements = await server.build_payment_requirements(input.resource_config) + try: + built_requirements = await server.build_payment_requirements(input.resource_config) + except Exception as err: + return ProcessX402SettleFailure(phase="facilitator_error", step="build_requirements", error=err) if not built_requirements: return ProcessX402SettleFailure( phase="no_requirements", @@ -92,11 +208,19 @@ async def process_x402_settle(input: ProcessX402SettleInput) -> ProcessX402Settl "routePattern": path, } - enriched_ext = server.enrich_extensions(input.extension, transport_context) if input.extension is not None else None + try: + enriched_ext = ( + server.enrich_extensions(input.extension, transport_context) if input.extension is not None else None + ) + except Exception as err: + return ProcessX402SettleFailure(phase="facilitator_error", step="enrich_extensions", error=err) - verify_result = await server.process_payment_request( - input.payload, input.resource_config, input.resource_meta, enriched_ext - ) + try: + verify_result = await server.process_payment_request( + input.payload, input.resource_config, input.resource_meta, enriched_ext + ) + except Exception as err: + return ProcessX402SettleFailure(phase="facilitator_error", step="process_payment_request", error=err) if not getattr( verify_result, "success", verify_result.get("success") if isinstance(verify_result, dict) else False diff --git a/agentscore_commerce/payment/x402_validation.py b/agentscore_commerce/payment/x402_validation.py index 98b52de..2e60c55 100644 --- a/agentscore_commerce/payment/x402_validation.py +++ b/agentscore_commerce/payment/x402_validation.py @@ -2,17 +2,15 @@ Two layers of validation every x402-accepting merchant repeats: -- **Boot-time**: validate the configured ``X402_BASE_NETWORK`` + ``X402_SVM_NETWORK`` - env vars are in the supported set, and aren't pointing at the same network family. - Failing loud at boot is much better than per-request "unsupported network" errors - after a misconfigured deploy. +- **Boot-time**: validate the configured ``X402_BASE_NETWORK`` env var is in the + supported set. Failing loud at boot is much better than per-request "unsupported + network" errors after a misconfigured deploy. - **Per-request**: when an x402 X-Payment header arrives, parse the base64 payload, extract the signed network + payTo, validate against the merchant's accepted - networks, validate the payTo address shape per network family, and check that the - payTo was minted by THIS merchant (cache hit). Each step has its own denial code - and ``next_steps`` shape — getting the message right by hand across 4 conditions - is fiddly. + network, validate the payTo address shape, and check that the payTo was minted by + THIS merchant (cache hit). Each step has its own denial code and ``next_steps`` + shape — getting the message right by hand across 4 conditions is fiddly. """ from __future__ import annotations @@ -23,7 +21,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal -from agentscore_commerce.payment.networks import network_family, networks +from agentscore_commerce.payment.networks import networks if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -31,20 +29,16 @@ #: CAIP-2 networks the commerce SDK supports for x402 Base (EVM USDC). X402_SUPPORTED_BASE_NETWORKS: frozenset[str] = frozenset({networks.base.mainnet.caip2, networks.base.sepolia.caip2}) -#: CAIP-2 networks the commerce SDK supports for x402 Solana (SPL Token USDC). -X402_SUPPORTED_SVM_NETWORKS: frozenset[str] = frozenset({networks.solana.mainnet.caip2, networks.solana.devnet.caip2}) - @dataclass class ValidateX402NetworkConfigInput: """Input for :func:`validate_x402_network_config`.""" base_network: str - svm_network: str def validate_x402_network_config(input: ValidateX402NetworkConfigInput) -> None: - """Boot-time guard: raise if either network isn't supported, or if both share a family. + """Boot-time guard: raise if the base network isn't supported. Raises ``ValueError`` with a message that names the unsupported value AND lists the valid options — agents tracking down a misconfigured deploy don't need to grep for @@ -55,19 +49,9 @@ def validate_x402_network_config(input: ValidateX402NetworkConfigInput) -> None: f"X402_BASE_NETWORK={input.base_network} is not supported. " f"Use one of: {', '.join(sorted(X402_SUPPORTED_BASE_NETWORKS))}" ) - if input.svm_network not in X402_SUPPORTED_SVM_NETWORKS: - raise ValueError( - f"X402_SVM_NETWORK={input.svm_network} is not supported. " - f"Use one of: {', '.join(sorted(X402_SUPPORTED_SVM_NETWORKS))}" - ) - if input.base_network == input.svm_network: - raise ValueError( - f"X402_BASE_NETWORK and X402_SVM_NETWORK must be different (both set to {input.base_network})." - ) _EVM_ADDRESS_RE = re.compile(r"^0x[0-9a-fA-F]{40}$") -_SOLANA_ADDRESS_RE = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$") @dataclass @@ -79,9 +63,8 @@ class VerifyX402RequestInput: #: Async lookup that returns ``True`` when the address was minted by this merchant #: (typically ``pi_cache.has_address``). is_cached_address: Callable[[str], Awaitable[bool]] - #: The merchant's accepted CAIP-2 networks per family. - accepted_base_network: str - accepted_svm_network: str + #: The merchant's accepted Base CAIP-2 network. + accepted_network: str @dataclass @@ -91,7 +74,6 @@ class VerifyX402RequestSuccess: payload: dict[str, Any] signed_network: str signed_pay_to: str - is_solana: bool ok: Literal[True] = True @@ -117,10 +99,9 @@ def _header_lookup(headers: dict[str, str], *names: str) -> str | None: _REGENERATE_WARNING = ( - "If you're trying to pay with Tempo USDC, use `tempo request` (sends Authorization: Payment), " - "not a manual X-Payment header. Do NOT use `tempo wallet transfer` — that sends USDC on-chain " - "but will not complete the MPP handshake. For x402 on Base/Solana, use `agentscore-pay pay` so " - "the X-Payment credential is signed and submitted; bare wallet transfers do not complete the handshake." + "Use `agentscore-pay pay --chain base` (or `tempo request` for Tempo USDC) so the credential " + "is signed and submitted via the protocol handshake. Do NOT use `tempo wallet transfer` — " + "that sends USDC on-chain but does not complete the handshake." ) @@ -175,27 +156,35 @@ async def verify_x402_request(input: VerifyX402RequestInput) -> VerifyX402Reques signed_network = accepted.get("network") signed_pay_to = accepted.get("payTo") - if not signed_network or signed_network not in ( - input.accepted_base_network, - input.accepted_svm_network, - ): + if not signed_network or signed_network != input.accepted_network: + if signed_network and signed_network.lower().startswith("solana:"): + return VerifyX402RequestFailure( + body=_regenerate_body( + ( + f"x402 on {signed_network} is not accepted; " + f"Solana payments must use the `solana/charge` rail advertised in the 402 challenge. " + f"This server accepts x402 on {input.accepted_network} only." + ), + ( + "Solana payments are not accepted over x402 at this merchant. " + "Pick the `solana/charge` rail from the 402 challenge and re-sign." + ), + ), + ) return VerifyX402RequestFailure( body=_regenerate_body( ( f"Unsupported x402 network {signed_network or ''}; " - f"this server accepts {input.accepted_base_network} (Base) " - f"and {input.accepted_svm_network} (Solana)" + f"this server accepts {input.accepted_network}." ), ( - "The credential signed for an unsupported network. Pick one of the " - "accepted networks from the 402 challenge and re-sign." + "The credential signed for an unsupported network. Pick the accepted " + "network from the 402 challenge and re-sign." ), ), ) - is_solana = network_family(signed_network) == "solana" - re_match = _SOLANA_ADDRESS_RE if is_solana else _EVM_ADDRESS_RE - if not signed_pay_to or not isinstance(signed_pay_to, str) or not re_match.match(signed_pay_to): + if not signed_pay_to or not isinstance(signed_pay_to, str) or not _EVM_ADDRESS_RE.match(signed_pay_to): return VerifyX402RequestFailure( body=_regenerate_body( f"Payment payload missing or malformed accepted.payTo address for network {signed_network}", @@ -221,5 +210,4 @@ async def verify_x402_request(input: VerifyX402RequestInput) -> VerifyX402Reques payload=payload, signed_network=signed_network, signed_pay_to=signed_pay_to, - is_solana=is_solana, ) diff --git a/agentscore_commerce/stripe_multichain/pi_cache.py b/agentscore_commerce/stripe_multichain/pi_cache.py index 0fe8114..251ca9b 100644 --- a/agentscore_commerce/stripe_multichain/pi_cache.py +++ b/agentscore_commerce/stripe_multichain/pi_cache.py @@ -4,8 +4,8 @@ 1. **Is this on-chain ``pay_to`` address one we minted?** — when an MPP credential arrives with a ``recipient``, verify it matches a recently-minted Stripe deposit - address. Prevents agents from sending payment to an attacker-controlled address - and replaying the credential against the merchant's endpoint. + address. Validates the credential's deposit address against the addresses the + merchant has actually minted. 2. **Which PaymentIntent owns this deposit address?** — when settling, the ``simulate_crypto_deposit`` test_helpers call needs the PaymentIntent id for the diff --git a/examples/README.md b/examples/README.md index 0878f5f..b42ee37 100644 --- a/examples/README.md +++ b/examples/README.md @@ -45,7 +45,7 @@ AgentScore Commerce handles the agent commerce protocol layer; everything else i ## Differences from node-commerce examples -Python doesn't have peer-dep equivalents for `@x402/core`, `@x402/evm`, `@x402/svm`, or `mppx` — those are TypeScript-only ecosystems today. Three implications: +Python doesn't have peer-dep equivalents for `@x402/core`, `@x402/evm`, `@solana/mpp`, or `mppx` — those are TypeScript-only ecosystems today. Three implications: 1. **No `create_x402_server` / `create_mppx_server` factories.** The commerce package exposes `register_x402_schemes_v1_v2` for the x402 v1+v2 dispatch helper, but happy-path setup (registering the facilitator, schemes, etc.) is something Python merchants do via direct HTTP calls to their facilitator of choice. 2. **`extract_payment_signer` returns EVM only.** Solana SPL Token payer recovery requires a Solana SDK (`solders` / `solana-py`) which isn't bundled. Pass the recovered Solana payer via `signer=...` to `verify_wallet_signer_match` directly. diff --git a/examples/api_provider.py b/examples/api_provider.py index a1bd21c..8939254 100644 --- a/examples/api_provider.py +++ b/examples/api_provider.py @@ -1,31 +1,31 @@ -"""Example: API provider with per-call billing — multi-rail (Tempo MPP + x402). +"""Example: API provider with per-call billing; multi-rail (Tempo MPP + x402 base + Solana MPP). Scenario: you sell access to an HTTP API (search, scraping, RPC, etc.). Each call costs a fixed price; agents pick whichever rail their wallet supports. No identity gate, no -compliance — purely pay-or-fail. Think Exa, QuickNode, anyone in the x402 Bazaar. +compliance: purely pay-or-fail. Think Exa, QuickNode, anyone in the x402 Bazaar. Rails advertised: - - **Tempo MPP** (`tempo/charge` intent) - - **x402 USDC on Base** (EIP-3009) - - **x402 USDC on Solana** (SPL Token) + - **Tempo MPP** (`tempo/charge` intent, carried in `Authorization: Payment`) + - **x402 USDC on Base** (EIP-3009, carried in `x-payment` / `payment-signature`) + - **Solana MPP** (`solana/charge` intent, carried in `Authorization: Payment`) -The 402 lists all rails neutrally — the agent picks based on what their wallet supports. +The 402 lists all rails neutrally; the agent picks based on what their wallet supports. -Python doesn't have `mppx` / `@x402/core` peer deps — verification + settlement run against -the facilitator HTTP API. Commerce helpers build the protocol-correct 402 body + headers; -your route does the post-payment settle against the facilitator. +Python merchants on Solana implement MPP `solana/charge` server-side themselves; +there is no `@solana/mpp` Python equivalent today. This example only advertises the +Solana rail in the 402 directives; settle the credential via your facilitator API. Peer deps: pip install agentscore-commerce[fastapi] Env vars: - TEMPO_RECIPIENT — your Tempo wallet for receiving USDC.e - X402_BASE_RECIPIENT — your Base wallet for receiving USDC - X402_SOLANA_RECIPIENT — your Solana wallet for receiving USDC - X402_BASE_NETWORK — CAIP-2 (default eip155:8453 = Base mainnet; - override to eip155:84532 for Sepolia testnet) - X402_SVM_NETWORK — CAIP-2 (default solana mainnet; override to - solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 for devnet) + TEMPO_RECIPIENT your Tempo wallet for receiving USDC.e + X402_BASE_RECIPIENT your Base wallet for receiving USDC + SOLANA_RECIPIENT your Solana wallet for receiving USDC + X402_BASE_NETWORK CAIP-2 (default eip155:8453 = Base mainnet; + override to eip155:84532 for Sepolia testnet) + SOLANA_NETWORK_CAIP2 CAIP-2 (default solana mainnet; override to + solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 for devnet) Run: uvicorn examples.api_provider:app --port 3000 """ @@ -57,11 +57,10 @@ # Read network selection from env so the same example serves mainnet + testnet. X402_BASE_NETWORK = os.environ.get("X402_BASE_NETWORK", networks.base.mainnet.caip2) -X402_SVM_NETWORK = os.environ.get("X402_SVM_NETWORK", networks.solana.mainnet.caip2) +SOLANA_NETWORK_CAIP2 = os.environ.get("SOLANA_NETWORK_CAIP2", networks.solana.mainnet.caip2) _BASE_USDC = ( USDC.base.sepolia.address if networks.base.sepolia.caip2 == X402_BASE_NETWORK else USDC.base.mainnet.address ) -_SVM_USDC = USDC.solana.devnet.mint if networks.solana.devnet.caip2 == X402_SVM_NETWORK else USDC.solana.mainnet.mint _TEMPO_RAIL = "tempo-testnet" if networks.base.sepolia.caip2 == X402_BASE_NETWORK else "tempo-mainnet" app = FastAPI() @@ -93,21 +92,21 @@ async def search(request: Request): # can find it on an empty-body POST. Commerce synthesizes USDC # sample accepts from the registry per CAIP-2 network passed. x402_sample=X402SampleProbe( - networks=[X402_BASE_NETWORK, X402_SVM_NETWORK], + networks=[X402_BASE_NETWORK, SOLANA_NETWORK_CAIP2], resource_url=f"{REALM}/search", ), ) ) return JSONResponse(json.loads(probe.body), status_code=probe.status, headers=probe.headers) - # No payment? Return a 402 with directives for all accepted rails (Tempo + x402). + # No payment? Return a 402 with directives for all accepted rails. if not (auth and auth.startswith("Payment ")) and not x402_header: challenge_id = f"chg_{os.urandom(8).hex()}" x402_base_rail = ( "x402-base-sepolia" if networks.base.sepolia.caip2 == X402_BASE_NETWORK else "x402-base-mainnet" ) - x402_svm_rail = ( - "x402-solana-devnet" if networks.solana.devnet.caip2 == X402_SVM_NETWORK else "x402-solana-mainnet" + solana_mpp_rail = ( + "mpp-solana-devnet" if networks.solana.devnet.caip2 == SOLANA_NETWORK_CAIP2 else "mpp-solana-mainnet" ) directives = [ payment_directive( @@ -117,10 +116,9 @@ async def search(request: Request): PaymentDirectiveInput(rail=x402_base_rail, id=f"{challenge_id}_base", realm=REALM, request="") ), payment_directive( - PaymentDirectiveInput(rail=x402_svm_rail, id=f"{challenge_id}_solana", realm=REALM, request="") + PaymentDirectiveInput(rail=solana_mpp_rail, id=f"{challenge_id}_solana", realm=REALM, request="") ), ] - x402_solana_recipient = os.environ["X402_SOLANA_RECIPIENT"] accepts = [ { "scheme": "exact", @@ -129,22 +127,10 @@ async def search(request: Request): "asset": _BASE_USDC, "payTo": os.environ["X402_BASE_RECIPIENT"], "maxTimeoutSeconds": 300, - # EIP-712 domain — required by every x402 EVM client to sign + # EIP-712 domain required by every x402 EVM client to sign # EIP-3009 TransferWithAuthorization. "extra": {"name": "USDC", "version": "2"}, }, - { - "scheme": "exact", - "network": X402_SVM_NETWORK, - "amount": str(int(PRICE_USDC * 1_000_000)), - "asset": _SVM_USDC, - "payTo": x402_solana_recipient, - "maxTimeoutSeconds": 300, - # SVM transactions require feePayer in extra. Default to the - # recipient (round-trip safe for dev). Production merchants - # typically point at the Coinbase facilitator's payer address. - "extra": {"feePayer": x402_solana_recipient}, - }, ] return JSONResponse( {"payment_required": True, "x402Version": 2, "accepts": accepts}, @@ -157,9 +143,9 @@ async def search(request: Request): }, ) - # Payment present — branch on which header arrived: - # Authorization: Payment ... → MPP (tempo) — validate via your facilitator's MPP API - # payment-signature / x-payment → x402 (base or solana) — validate via x402 facilitator + # Payment present; branch on which header arrived: + # Authorization: Payment ... → MPP (tempo or solana); validate via your facilitator's MPP API + # payment-signature / x-payment → x402 base; validate via x402 facilitator # Both shapes settle through the configured facilitator HTTP API, then run your operation. body_json = json.loads(body_text) diff --git a/examples/multi_rail_merchant.py b/examples/multi_rail_merchant.py index 25d3b2c..9158d97 100644 --- a/examples/multi_rail_merchant.py +++ b/examples/multi_rail_merchant.py @@ -1,15 +1,17 @@ -"""Example: full agent-commerce merchant (Martin-Estate-style stripped down). +"""Example: full regulated-commerce merchant. Scenario: you sell a regulated good. Identity gate (KYC + age + jurisdiction + sanctions), plus 402 payment challenge advertising multiple rails so agents can pay with whatever they -have — Tempo USDC (MPP), x402 USDC on Base + Solana, Stripe SPT. +have: Tempo USDC (MPP `tempo/charge`), x402 USDC on Base, Solana USDC (MPP `solana/charge`), +Stripe SPT. The flow on each /purchase POST: 1. Identity gate (AgentScoreGate): KYC + age + jurisdiction + sanctions - 2. If ``X-Payment`` header present (x402 client paying) → ``verify_x402_request`` → + 2. If ``X-Payment`` header present (x402 client paying base) → ``verify_x402_request`` → ``process_x402_settle`` → return 200 with ``payment-response`` header 3. Else mint a Stripe multichain PI (deposit addresses for tempo/base/solana) and run pympp's compose() to validate any ``Authorization: Payment`` header + (covers tempo/charge AND solana/charge directives) 4. If pympp returns 402 → ``respond_402`` (preserves pympp's WWW-Auth + adds x402's PAYMENT-REQUIRED) with the rich body 5. If pympp returns 200 → also fire ``simulate_deposit_if_test_mode`` for testnet @@ -25,7 +27,7 @@ STRIPE_PROFILE_ID — your Stripe Connect profile id (for SPT) TEMPO_USDC_ADDRESS — USDC token address on Tempo (mainnet or testnet) X402_BASE_NETWORK — CAIP-2 - X402_SVM_NETWORK — CAIP-2 + SOLANA_NETWORK_CAIP2 — CAIP-2 REDIS_URL — optional; in-memory PI cache otherwise Run: uvicorn examples.multi_rail_merchant:app --port 3000 @@ -44,14 +46,14 @@ BuildValidationErrorInput, HowToPayRails, Respond402Input, + SolanaMppConfig, + SolanaMppRailConfig, StripeConfig, StripeRailConfig, TempoConfig, TempoRailConfig, X402BaseConfig, X402BaseRailConfig, - X402SolanaConfig, - X402SolanaRailConfig, build_accepted_methods, build_agent_instructions, build_how_to_pay, @@ -81,13 +83,11 @@ APP_URL = os.environ["APP_URL"] X402_BASE_NETWORK = os.environ.get("X402_BASE_NETWORK", networks.base.mainnet.caip2) -X402_SVM_NETWORK = os.environ.get("X402_SVM_NETWORK", networks.solana.mainnet.caip2) +SOLANA_NETWORK_CAIP2 = os.environ.get("SOLANA_NETWORK_CAIP2", networks.solana.mainnet.caip2) # Boot-time guard: validate the configured x402 networks are in the supported set. # Raises on misconfigured deploys before the first request. -validate_x402_network_config( - ValidateX402NetworkConfigInput(base_network=X402_BASE_NETWORK, svm_network=X402_SVM_NETWORK) -) +validate_x402_network_config(ValidateX402NetworkConfigInput(base_network=X402_BASE_NETWORK)) # Singleton Stripe PI / deposit-address cache. Backed by Redis when REDIS_URL is set # (multi-instance deployments need this so a deposit lands on whichever instance @@ -151,8 +151,7 @@ async def purchase(request: Request, assess: dict = Depends(get_assess_data)): VerifyX402RequestInput( headers=dict(request.headers), is_cached_address=pi_cache.has_address, - accepted_base_network=X402_BASE_NETWORK, - accepted_svm_network=X402_SVM_NETWORK, + accepted_network=X402_BASE_NETWORK, ) ) if not verified.ok: @@ -189,12 +188,13 @@ async def purchase(request: Request, assess: dict = Depends(get_assess_data)): status_code=400, ) - # Fire Stripe testnet sim — no-ops on live keys. + # Fire Stripe testnet sim; no-ops on live keys. x402 settle only ever + # lands on base in 1.4+ (Solana moved to MPP `solana/charge`). await simulate_deposit_if_test_mode( SimulateDepositIfTestModeInput( get_payment_intent_id=pi_cache.get_payment_intent_id, deposit_address=verified.signed_pay_to, - network="solana" if verified.is_solana else "base", + network="base", stripe_secret_key=os.environ["STRIPE_SECRET_KEY"], ) ) @@ -217,7 +217,7 @@ async def purchase(request: Request, assess: dict = Depends(get_assess_data)): BuildAcceptedMethodsInput( tempo=TempoConfig(recipient=deposit_addresses["tempo"]), x402_base=X402BaseConfig(recipient=deposit_addresses["base"]), - x402_solana=X402SolanaConfig(recipient=deposit_addresses["solana"]), + solana_mpp=SolanaMppConfig(recipient=deposit_addresses["solana"]), stripe=StripeConfig(profile_id=os.environ["STRIPE_PROFILE_ID"]), ) ) @@ -229,7 +229,7 @@ async def purchase(request: Request, assess: dict = Depends(get_assess_data)): rails=HowToPayRails( tempo=TempoRailConfig(recipient=deposit_addresses["tempo"]), x402_base=X402BaseRailConfig(recipient=deposit_addresses["base"]), - x402_solana=X402SolanaRailConfig(recipient=deposit_addresses["solana"]), + solana_mpp=SolanaMppRailConfig(recipient=deposit_addresses["solana"]), stripe=StripeRailConfig(profile_id=os.environ["STRIPE_PROFILE_ID"]), ), ) @@ -270,11 +270,11 @@ async def purchase(request: Request, assess: dict = Depends(get_assess_data)): }, { "scheme": "exact", - "network": X402_SVM_NETWORK, + "network": SOLANA_NETWORK_CAIP2, "amount": str(round(float(total_usd) * 1_000_000)), "asset": ( USDC.solana.devnet.mint - if networks.solana.devnet.caip2 == X402_SVM_NETWORK + if networks.solana.devnet.caip2 == SOLANA_NETWORK_CAIP2 else USDC.solana.mainnet.mint ), "payTo": deposit_addresses["solana"], diff --git a/pyproject.toml b/pyproject.toml index 3bdaa37..cac872d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-commerce" -version = "1.2.1" +version = "1.3.0" description = "Agent commerce SDK for Python — identity middleware (FastAPI, Flask, Django, AIOHTTP, Sanic, ASGI) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agent commerce." readme = "README.md" license = "MIT" @@ -39,7 +39,7 @@ stripe = ["stripe>=11.0.0"] # Note: the Coinbase x402 facilitator is reached through the main `x402` # package (HTTPFacilitatorClient pointed at the Coinbase URL), not a separate # package — no `coinbase-x402` extra needed. -x402 = ["x402[evm,svm,fastapi]>=2.8,<3"] +x402 = ["x402[evm,fastapi]>=2.8,<3"] mppx = ["pympp[server,tempo,stripe]>=0.6,<1"] [project.urls] diff --git a/tests/test_a2a.py b/tests/test_a2a.py index 833d4df..73398f3 100644 --- a/tests/test_a2a.py +++ b/tests/test_a2a.py @@ -26,14 +26,14 @@ def _full_result() -> AssessResult: def test_card_with_identity_when_data_provided(): card = build_a2a_agent_card( - name="Martin Estate", - url="https://agents.martinestate.com", + name="Example Merchant", + url="https://agents.example.com", data=_full_result(), ) assert card.protocol_version == "1.0" assert card.card_version == 1 - assert card.name == "Martin Estate" - assert card.url == "https://agents.martinestate.com" + assert card.name == "Example Merchant" + assert card.url == "https://agents.example.com" assert card.identity is not None assert card.identity.operator_id == "op_abc" assert card.identity.kyc_level == "enhanced" diff --git a/tests/test_challenge.py b/tests/test_challenge.py index efbb46a..b9197f6 100644 --- a/tests/test_challenge.py +++ b/tests/test_challenge.py @@ -7,6 +7,7 @@ IdentityMetadataInput, PricingBlock, SignerMatchResult, + SolanaMppConfig, StripeConfig, StripeRailConfig, TempoConfig, @@ -14,7 +15,6 @@ X402BaseConfig, X402BaseRailConfig, X402PaymentRequired, - X402SolanaConfig, build_402_body, build_accepted_methods, build_agent_instructions, @@ -41,7 +41,7 @@ def test_build_accepted_methods_full_set(): BuildAcceptedMethodsInput( tempo=TempoConfig(recipient="0xT"), x402_base=X402BaseConfig(recipient="0xB"), - x402_solana=X402SolanaConfig(recipient="solanaaddr"), + solana_mpp=SolanaMppConfig(recipient="solanaaddr"), stripe=StripeConfig(profile_id="acct_x"), ) ) @@ -131,6 +131,31 @@ def test_build_agent_instructions_warnings_match_rails(): assert stripe_only["recommended_tools"] == [] +def test_build_agent_instructions_appends_extra_warnings(): + """extra_warnings is appended to the rail-derived defaults.""" + out = build_agent_instructions( + BuildAgentInstructionsInput( + how_to_pay={"tempo": {}, "x402_base": {}}, + extra_warnings=["Solana unavailable for this order; use base or tempo."], + ) + ) + assert len(out["warnings"]) == 3 + assert "tempo wallet transfer" in out["warnings"][0] + assert "Solana unavailable" in out["warnings"][2] + + +def test_build_agent_instructions_extra_warnings_ignored_when_warnings_set(): + """Explicit warnings override defaults AND extra_warnings.""" + out = build_agent_instructions( + BuildAgentInstructionsInput( + how_to_pay={"tempo": {}}, + warnings=["custom only"], + extra_warnings=["ignored"], + ) + ) + assert out["warnings"] == ["custom only"] + + def test_build_402_body_assembles_full_response(): body = build_402_body( Build402BodyInput( diff --git a/tests/test_coverage_fillers.py b/tests/test_coverage_fillers.py index ee8f518..c18e8d7 100644 --- a/tests/test_coverage_fillers.py +++ b/tests/test_coverage_fillers.py @@ -6,10 +6,10 @@ BuildAgentInstructionsInput, BuildHowToPayInput, HowToPayRails, + SolanaMppConfig, + SolanaMppRailConfig, StripeRailConfig, TempoRailConfig, - X402SolanaConfig, - X402SolanaRailConfig, build_402_body, build_accepted_methods, build_agent_instructions, @@ -105,7 +105,7 @@ def test_llms_txt_identity_section_includes_compliance_note(): def test_llms_txt_payment_section_includes_all_rails(): section = llms_txt_payment_section( LlmsTxtPaymentSectionInput( - rails=["tempo-mainnet", "x402-base-mainnet", "x402-solana-mainnet", "stripe-spt"], + rails=["tempo-mainnet", "x402-base-mainnet", "mpp-solana-mainnet", "stripe-spt"], app_url="https://ex.com/buy", ) ) @@ -116,7 +116,7 @@ def test_llms_txt_payment_section_includes_all_rails(): def test_build_accepted_methods_includes_solana_only(): - out = build_accepted_methods(BuildAcceptedMethodsInput(x402_solana=X402SolanaConfig(recipient="solanaaddr"))) + out = build_accepted_methods(BuildAcceptedMethodsInput(solana_mpp=SolanaMppConfig(recipient="solanaaddr"))) assert out[0]["network"].startswith("solana:") assert out[0]["pay_to"] == "solanaaddr" @@ -127,11 +127,11 @@ def test_build_how_to_pay_solana_only(): url="https://ex.com", retry_body_json="{}", total_usd=5.0, - rails=HowToPayRails(x402_solana=X402SolanaRailConfig(recipient="solanaaddr")), + rails=HowToPayRails(solana_mpp=SolanaMppRailConfig(recipient="solanaaddr")), ) ) - assert "x402_solana" in out - assert "agentscore-pay pay POST" in out["x402_solana"]["command"] + assert "solana_mpp" in out + assert "agentscore-pay pay POST" in out["solana_mpp"]["command"] def test_build_how_to_pay_tempo_recommend_pay(): diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 101fa48..af9aaf3 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -4,18 +4,28 @@ from agentscore_commerce.discovery import ( BuildAgentScoreOpenApiSnippetsInput, + BuildWellKnownX402Input, DiscoveryProbeOptions, LlmsTxtIdentitySectionInput, LlmsTxtPaymentSectionInput, LlmsTxtSection, PaymentMethodConfig, WellKnownMppInput, + WellKnownX402Resource, + XPaymentInfoDynamicPrice, + XPaymentInfoFixedPrice, + XPaymentInfoInput, agentscore_openapi_snippets, + agentscore_security_schemes, build_discovery_probe_response, build_llms_txt, build_well_known_mpp, + build_well_known_x402, is_discovery_probe_request, llms_txt_payment_section, + siwx_security_scheme, + x_guidance_extension, + x_payment_info_extension, ) @@ -149,7 +159,7 @@ def test_emits_stripe_section(self): def test_solana_only_no_base(self): section = llms_txt_payment_section( - LlmsTxtPaymentSectionInput(rails=["x402-solana-mainnet"], app_url="https://x", verbose=True) + LlmsTxtPaymentSectionInput(rails=["mpp-solana-mainnet"], app_url="https://x", verbose=True) ) assert "### How to pay with x402 (Solana)" in section assert "--chain solana" in section @@ -323,3 +333,69 @@ async def test_is_probe_with_real_body_rejected() -> None: from agentscore_commerce.discovery.probe import is_discovery_probe_request assert await is_discovery_probe_request("POST", None, '{"product": "x"}') is False + + +def test_build_well_known_x402_emits_v1_shape(): + doc = build_well_known_x402( + BuildWellKnownX402Input( + resources=[ + WellKnownX402Resource(method="POST", path="/purchase"), + WellKnownX402Resource(method="GET", path="/catalog"), + ] + ) + ) + assert doc == {"version": 1, "resources": ["POST /purchase", "GET /catalog"]} + + +def test_build_well_known_x402_uppercases_methods(): + doc = build_well_known_x402(BuildWellKnownX402Input(resources=[WellKnownX402Resource(method="post", path="/x")])) + assert doc["resources"] == ["POST /x"] + + +def test_build_well_known_x402_empty_resources(): + assert build_well_known_x402(BuildWellKnownX402Input(resources=[])) == {"version": 1, "resources": []} + + +def test_siwx_security_scheme_is_http_bearer_siwx(): + scheme = siwx_security_scheme() + assert scheme["type"] == "http" + assert scheme["scheme"] == "bearer" + assert scheme["bearerFormat"] == "SIWX" + + +def test_agentscore_security_schemes_includes_siwx(): + schemes = agentscore_security_schemes() + assert "siwx" in schemes + assert schemes["siwx"]["bearerFormat"] == "SIWX" + + +def test_x_payment_info_extension_fixed_price(): + ext = x_payment_info_extension( + XPaymentInfoInput( + price=XPaymentInfoFixedPrice(currency="USD", amount="0.10"), + protocols=[{"x402": {}}], + ) + ) + assert ext["x-payment-info"]["price"] == {"mode": "fixed", "currency": "USD", "amount": "0.10"} + assert ext["x-payment-info"]["protocols"] == [{"x402": {}}] + + +def test_x_payment_info_extension_dynamic_price_with_mpp(): + ext = x_payment_info_extension( + XPaymentInfoInput( + price=XPaymentInfoDynamicPrice(currency="USD", min="0.01", max="5.00"), + protocols=[ + {"x402": {}}, + {"mpp": {"method": "tempo/charge", "intent": "pay", "currency": "USD"}}, + ], + ) + ) + assert ext["x-payment-info"]["price"]["mode"] == "dynamic" + assert ext["x-payment-info"]["price"]["min"] == "0.01" + assert len(ext["x-payment-info"]["protocols"]) == 2 + + +def test_x_guidance_extension_wraps_text(): + assert x_guidance_extension("Use POST /purchase with operator token") == { + "x-guidance": "Use POST /purchase with operator token" + } diff --git a/tests/test_lifted_helpers.py b/tests/test_lifted_helpers.py index e511cbe..c93246a 100644 --- a/tests/test_lifted_helpers.py +++ b/tests/test_lifted_helpers.py @@ -17,7 +17,6 @@ ) from agentscore_commerce.payment import ( X402_SUPPORTED_BASE_NETWORKS, - X402_SUPPORTED_SVM_NETWORKS, PaymentRequiredHeaderInput, ProcessX402SettleFailure, ProcessX402SettleInput, @@ -26,6 +25,7 @@ VerifyX402RequestFailure, VerifyX402RequestInput, VerifyX402RequestSuccess, + classify_x402_settle_result, networks, process_x402_settle, validate_x402_network_config, @@ -179,34 +179,18 @@ def test_respond_402_layers_payment_required_when_x402_set(): # ───────────────────────────────────────────────────────────────────────────── -def test_validate_x402_accepts_supported_combo(): - validate_x402_network_config( - ValidateX402NetworkConfigInput( - base_network=networks.base.sepolia.caip2, - svm_network=networks.solana.devnet.caip2, - ) - ) +def test_validate_x402_accepts_supported_base(): + validate_x402_network_config(ValidateX402NetworkConfigInput(base_network=networks.base.sepolia.caip2)) def test_validate_x402_rejects_unknown_base(): with pytest.raises(ValueError, match="X402_BASE_NETWORK=eip155:9999"): - validate_x402_network_config( - ValidateX402NetworkConfigInput(base_network="eip155:9999", svm_network=networks.solana.devnet.caip2) - ) - - -def test_validate_x402_rejects_unknown_svm(): - with pytest.raises(ValueError, match="X402_SVM_NETWORK=solana:bogus"): - validate_x402_network_config( - ValidateX402NetworkConfigInput(base_network=networks.base.sepolia.caip2, svm_network="solana:bogus") - ) + validate_x402_network_config(ValidateX402NetworkConfigInput(base_network="eip155:9999")) def test_x402_supported_networks_constants(): assert networks.base.mainnet.caip2 in X402_SUPPORTED_BASE_NETWORKS assert networks.base.sepolia.caip2 in X402_SUPPORTED_BASE_NETWORKS - assert networks.solana.mainnet.caip2 in X402_SUPPORTED_SVM_NETWORKS - assert networks.solana.devnet.caip2 in X402_SUPPORTED_SVM_NETWORKS # ───────────────────────────────────────────────────────────────────────────── @@ -232,8 +216,7 @@ async def test_verify_x402_missing_header(): VerifyX402RequestInput( headers={}, is_cached_address=_always_true, - accepted_base_network=networks.base.sepolia.caip2, - accepted_svm_network=networks.solana.devnet.caip2, + accepted_network=networks.base.sepolia.caip2, ) ) assert isinstance(res, VerifyX402RequestFailure) @@ -246,8 +229,7 @@ async def test_verify_x402_bad_base64(): VerifyX402RequestInput( headers={"X-Payment": "not-base64-json"}, is_cached_address=_always_true, - accepted_base_network=networks.base.sepolia.caip2, - accepted_svm_network=networks.solana.devnet.caip2, + accepted_network=networks.base.sepolia.caip2, ) ) assert isinstance(res, VerifyX402RequestFailure) @@ -261,8 +243,7 @@ async def test_verify_x402_unsupported_network(): VerifyX402RequestInput( headers={"x-payment": _x_payment(payload)}, is_cached_address=_always_true, - accepted_base_network=networks.base.sepolia.caip2, - accepted_svm_network=networks.solana.devnet.caip2, + accepted_network=networks.base.sepolia.caip2, ) ) assert isinstance(res, VerifyX402RequestFailure) @@ -276,8 +257,7 @@ async def test_verify_x402_malformed_evm_pay_to(): VerifyX402RequestInput( headers={"x-payment": _x_payment(payload)}, is_cached_address=_always_true, - accepted_base_network=networks.base.sepolia.caip2, - accepted_svm_network=networks.solana.devnet.caip2, + accepted_network=networks.base.sepolia.caip2, ) ) assert isinstance(res, VerifyX402RequestFailure) @@ -291,8 +271,7 @@ async def test_verify_x402_pay_to_not_in_cache(): VerifyX402RequestInput( headers={"x-payment": _x_payment(payload)}, is_cached_address=_always_false, - accepted_base_network=networks.base.sepolia.caip2, - accepted_svm_network=networks.solana.devnet.caip2, + accepted_network=networks.base.sepolia.caip2, ) ) assert isinstance(res, VerifyX402RequestFailure) @@ -306,8 +285,7 @@ async def test_verify_x402_failures_carry_regenerate_next_steps(): VerifyX402RequestInput( headers={}, is_cached_address=_always_true, - accepted_base_network=networks.base.sepolia.caip2, - accepted_svm_network=networks.solana.devnet.caip2, + accepted_network=networks.base.sepolia.caip2, ) ) assert isinstance(res, VerifyX402RequestFailure) @@ -324,32 +302,35 @@ async def test_verify_x402_success_evm(): VerifyX402RequestInput( headers={"x-payment": _x_payment(payload)}, is_cached_address=_always_true, - accepted_base_network=networks.base.sepolia.caip2, - accepted_svm_network=networks.solana.devnet.caip2, + accepted_network=networks.base.sepolia.caip2, ) ) assert isinstance(res, VerifyX402RequestSuccess) assert res.signed_pay_to == pay_to assert res.signed_network == networks.base.sepolia.caip2 - assert res.is_solana is False @pytest.mark.asyncio -async def test_verify_x402_success_solana(): - # Real-shape Solana base58 (System Program address) - pay_to = "11111111111111111111111111111111" - payload = {"accepted": {"network": networks.solana.devnet.caip2, "payTo": pay_to}} +async def test_verify_x402_rejects_solana_credential(): + """Solana credentials over x402 are not supported (Solana goes through MPP). + + The error message + next_steps point the client at MPP `solana/charge` so an + agent on a stale x402 SVM client can recover with a single re-sign. + """ + payload = {"accepted": {"network": networks.solana.mainnet.caip2, "payTo": "11111111111111111111111111111111"}} res = await verify_x402_request( VerifyX402RequestInput( headers={"x-payment": _x_payment(payload)}, is_cached_address=_always_true, - accepted_base_network=networks.base.sepolia.caip2, - accepted_svm_network=networks.solana.devnet.caip2, + accepted_network=networks.base.sepolia.caip2, ) ) - assert isinstance(res, VerifyX402RequestSuccess) - assert res.signed_pay_to == pay_to - assert res.is_solana is True + assert isinstance(res, VerifyX402RequestFailure) + msg = res.body["error"]["message"] + assert "Solana" in msg + assert "`solana/charge`" in msg + # Recovery guidance points at the rail in the 402 challenge, not at any CLI by name. + assert "solana/charge" in res.body["next_steps"]["user_message"] # ───────────────────────────────────────────────────────────────────────────── @@ -360,21 +341,29 @@ async def test_verify_x402_success_solana(): class _FakeServer: def __init__( self, - requirements: list, - verify_result: dict, + requirements: list | Exception, + verify_result: dict | Exception, settle_result: object | Exception | None = None, + enrich_result: object | Exception = "passthrough", ) -> None: self.requirements = requirements self.verify_result = verify_result self.settle_result = settle_result + self.enrich_result = enrich_result async def build_payment_requirements(self, _cfg: object) -> list: + if isinstance(self.requirements, Exception): + raise self.requirements return self.requirements def enrich_extensions(self, ext: object, _ctx: object) -> object: - return ext + if isinstance(self.enrich_result, Exception): + raise self.enrich_result + return ext if self.enrich_result == "passthrough" else self.enrich_result async def process_payment_request(self, _payload: object, _cfg: object, _meta: object, _ext: object) -> dict: + if isinstance(self.verify_result, Exception): + raise self.verify_result return self.verify_result async def settle_payment(self, _payload: object, _req: object) -> object: @@ -460,6 +449,161 @@ async def test_process_x402_settle_success_returns_payment_response_header(): assert decoded["tx_hash"] == "0xabc" +# ───────────────────────────────────────────────────────────────────────────── +# process_x402_settle: facilitator_error wrap +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_process_x402_settle_wraps_build_requirements_throws_as_facilitator_error(): + server = _FakeServer( + requirements=RuntimeError("facilitator: network not supported"), + verify_result={"success": True}, + ) + res = await process_x402_settle( + ProcessX402SettleInput( + x402_server=server, + payload={}, + resource_config={}, + resource_meta=_RESOURCE_META, + ) + ) + assert isinstance(res, ProcessX402SettleFailure) + assert res.phase == "facilitator_error" + assert res.step == "build_requirements" + assert isinstance(res.error, RuntimeError) + + +@pytest.mark.asyncio +async def test_process_x402_settle_wraps_enrich_extensions_throws_as_facilitator_error(): + server = _FakeServer( + requirements=[{"id": "req1"}], + verify_result={"success": True}, + enrich_result=RuntimeError("extension barfed"), + ) + res = await process_x402_settle( + ProcessX402SettleInput( + x402_server=server, + payload={}, + resource_config={}, + resource_meta=_RESOURCE_META, + extension={"name": "bazaar"}, + ) + ) + assert isinstance(res, ProcessX402SettleFailure) + assert res.phase == "facilitator_error" + assert res.step == "enrich_extensions" + + +@pytest.mark.asyncio +async def test_process_x402_settle_wraps_process_payment_request_throws_as_facilitator_error(): + server = _FakeServer( + requirements=[{"id": "req1"}], + verify_result=RuntimeError("CDP facilitator: solana:devnet not supported"), + ) + res = await process_x402_settle( + ProcessX402SettleInput( + x402_server=server, + payload={}, + resource_config={}, + resource_meta=_RESOURCE_META, + ) + ) + assert isinstance(res, ProcessX402SettleFailure) + assert res.phase == "facilitator_error" + assert res.step == "process_payment_request" + assert isinstance(res.error, RuntimeError) + + +@pytest.mark.asyncio +async def test_process_x402_settle_does_not_swallow_settle_failed_as_facilitator_error(): + server = _FakeServer( + requirements=[{"id": "req1"}], + verify_result={"success": True}, + settle_result=RuntimeError("on-chain rejection"), + ) + res = await process_x402_settle( + ProcessX402SettleInput( + x402_server=server, + payload={}, + resource_config={}, + resource_meta=_RESOURCE_META, + ) + ) + assert isinstance(res, ProcessX402SettleFailure) + assert res.phase == "settle_failed" + assert res.step is None + + +# ───────────────────────────────────────────────────────────────────────────── +# classify_x402_settle_result +# ───────────────────────────────────────────────────────────────────────────── + + +def test_classify_returns_none_on_success(): + res = ProcessX402SettleSuccess( + matched_requirement={"id": "req1"}, + settle_result={"tx": "0xabc"}, + payment_response_header="abc", + verify_result={"success": True}, + ) + assert classify_x402_settle_result(res) is None + + +def test_classify_no_requirements_to_500_payment_internal_error(): + classified = classify_x402_settle_result(ProcessX402SettleFailure(phase="no_requirements", reason="empty")) + assert classified is not None + assert classified.status == 500 + assert classified.code == "payment_internal_error" + assert classified.next_steps["action"] == "contact_support" + + +def test_classify_verify_failed_to_400_payment_proof_invalid(): + classified = classify_x402_settle_result( + ProcessX402SettleFailure(phase="verify_failed", verify_result={"success": False, "reason": "expired"}) + ) + assert classified is not None + assert classified.status == 400 + assert classified.code == "payment_proof_invalid" + assert classified.next_steps["action"] == "regenerate_payment_credential" + + +def test_classify_facilitator_error_to_503_payment_provider_unavailable(): + classified = classify_x402_settle_result( + ProcessX402SettleFailure( + phase="facilitator_error", step="process_payment_request", error=RuntimeError("CDP rejects solana:devnet") + ) + ) + assert classified is not None + assert classified.status == 503 + assert classified.code == "payment_provider_unavailable" + assert classified.next_steps["action"] == "try_different_rail" + + +def test_classify_settle_failed_to_503_with_retry_after(): + classified = classify_x402_settle_result( + ProcessX402SettleFailure( + phase="settle_failed", error=RuntimeError("on-chain rejection"), matched_requirement={} + ) + ) + assert classified is not None + assert classified.status == 503 + assert classified.code == "payment_provider_unavailable" + assert classified.next_steps["action"] == "retry_or_swap_method" + assert classified.next_steps["retry_after_seconds"] == 10 + + +def test_classify_does_not_leak_raw_error_detail(): + sensitive = RuntimeError("CDP-INTERNAL-TRACE-ID-12345 secret-key-in-stack") + classified = classify_x402_settle_result( + ProcessX402SettleFailure(phase="facilitator_error", step="process_payment_request", error=sensitive) + ) + assert classified is not None + assert "CDP-INTERNAL-TRACE-ID-12345" not in classified.message + assert "secret-key-in-stack" not in classified.message + assert "CDP-INTERNAL-TRACE-ID-12345" not in classified.next_steps["user_message"] + + # Required for asyncio fixtures @pytest.fixture def _ensure_loop() -> None: diff --git a/tests/test_payment_headers.py b/tests/test_payment_headers.py index 0b30890..7603c42 100644 --- a/tests/test_payment_headers.py +++ b/tests/test_payment_headers.py @@ -48,12 +48,12 @@ def test_unique_challenge_ids_per_rail(): _input( [ PaymentHeadersRail(rail="tempo-mainnet", amount_usd=1, recipient="0xa"), - PaymentHeadersRail(rail="x402-solana-mainnet", amount_usd=1, recipient="0xb"), + PaymentHeadersRail(rail="mpp-solana-mainnet", amount_usd=1, recipient="0xb"), ], ), ) assert 'id="ord_1-tempo-mainnet"' in result["www_authenticate"] - assert 'id="ord_1-x402-solana-mainnet"' in result["www_authenticate"] + assert 'id="ord_1-mpp-solana-mainnet"' in result["www_authenticate"] def test_emits_payment_required_header_when_x402_provided(): diff --git a/tests/test_payment_servers.py b/tests/test_payment_servers.py index 25c6791..b06c3f1 100644 --- a/tests/test_payment_servers.py +++ b/tests/test_payment_servers.py @@ -50,26 +50,6 @@ async def test_create_x402_server_registers_base_sepolia_scheme() -> None: assert "exact" in server._schemes["eip155:84532"] -@pytest.mark.skipif(not _X402_INSTALLED, reason="x402 peer dep not installed") -@pytest.mark.asyncio -async def test_create_x402_server_registers_solana_devnet_scheme() -> None: - server = await create_x402_server( - facilitator="http", - rails=["x402-solana-devnet"], - initialize=False, - ) - assert "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" in server._schemes - assert "exact" in server._schemes["solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"] - - -@pytest.mark.skipif(not _X402_INSTALLED, reason="x402 peer dep not installed") -@pytest.mark.asyncio -async def test_create_x402_server_solana_upto_rejected_eagerly() -> None: - """Solana doesn't ship an upto variant — surface that before peer-dep load.""" - with pytest.raises(ValueError, match="upto"): - await create_x402_server(rails=["x402-solana-mainnet-upto"], initialize=False) - - @pytest.mark.skipif(not _MPPX_INSTALLED or not _TEMPO_INSTALLED, reason="pympp[tempo] not installed") @pytest.mark.asyncio async def test_create_mppx_server_tempo_returns_mpp_instance() -> None: diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 2f50df5..33ff366 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -72,7 +72,7 @@ def test_forwards_context_and_product_name(self): CreateSessionOnMissing( api_key="ask_test", context="purchase_flow", - product_name="Martin Estate", + product_name="Example Merchant", ), user_agent="agentscore-commerce/1.0", ) @@ -80,7 +80,7 @@ def test_forwards_context_and_product_name(self): body = json.loads(route.calls[0].request.content) assert body["context"] == "purchase_flow" - assert body["product_name"] == "Martin Estate" + assert body["product_name"] == "Example Merchant" @respx.mock def test_omits_context_and_product_name_when_not_provided(self): diff --git a/tests/test_skill_md.py b/tests/test_skill_md.py index ebcddae..1447987 100644 --- a/tests/test_skill_md.py +++ b/tests/test_skill_md.py @@ -16,16 +16,16 @@ def _base() -> BuildSkillMdInput: return BuildSkillMdInput( - name="martin-estate-wine-commerce", - description="Buy wine from Martin Estate via an AI agent", - homepage="https://martin-estate.com", - merchant_name="Martin Estate", - accepted_rails=["tempo_mpp", "x402_base", "x402_solana", "stripe"], + name="example-merchant-commerce", + description="Buy from Example Merchant via an AI agent", + homepage="https://example.com", + merchant_name="Example Merchant", + accepted_rails=["tempo_mpp", "x402_base", "solana_mpp", "stripe"], endpoints=[ SkillMdEndpoint(method="GET", path="/api/v1/wines", auth_required=False, description="Wine catalog"), SkillMdEndpoint(method="POST", path="/api/v1/orders", auth_required=True, description="Place order"), ], - triggers=["User wants to buy wine from Martin Estate"], + triggers=["User wants to buy from Example Merchant"], ) @@ -33,11 +33,11 @@ class TestFrontmatter: def test_emits_yaml_block_with_required_fields(self) -> None: out = build_skill_md(_base()) assert out.startswith("---\n") - assert "name: martin-estate-wine-commerce" in out - assert 'description: "Buy wine from Martin Estate via an AI agent"' in out + assert "name: example-merchant-commerce" in out + assert 'description: "Buy from Example Merchant via an AI agent"' in out assert "metadata:" in out assert ' version: "1"' in out - assert ' homepage: "https://martin-estate.com"' in out + assert ' homepage: "https://example.com"' in out def test_version_emitted_as_quoted_string(self) -> None: cfg = _base() @@ -96,7 +96,7 @@ def test_metadata_extras_with_protected_keys(self) -> None: assert ' author: "agentscore"' in out assert ' vendor_id: "me-001"' in out assert ' version: "1"' in out - assert ' homepage: "https://martin-estate.com"' in out + assert ' homepage: "https://example.com"' in out assert "IGNORED" not in out @@ -159,49 +159,49 @@ def test_rejects_compatibility_over_500_chars(self) -> None: class TestTitleBlock: def test_renders_merchant_name_as_h1(self) -> None: out = build_skill_md(_base()) - assert "\n# Martin Estate\n" in out + assert "\n# Example Merchant\n" in out def test_renders_title_tagline_intro_with_blank_lines(self) -> None: cfg = _base() cfg.tagline = "A classic is forever" cfg.intro = "Napa Valley winery, family-run." out = build_skill_md(cfg) - assert "# Martin Estate\n\n_A classic is forever_\n\nNapa Valley winery, family-run." in out + assert "# Example Merchant\n\n_A classic is forever_\n\nNapa Valley winery, family-run." in out def test_renders_tagline_only(self) -> None: cfg = _base() cfg.tagline = "A classic is forever" out = build_skill_md(cfg) - assert "# Martin Estate\n\n_A classic is forever_" in out + assert "# Example Merchant\n\n_A classic is forever_" in out def test_renders_intro_only(self) -> None: cfg = _base() cfg.intro = "Napa Valley winery." out = build_skill_md(cfg) - assert "# Martin Estate\n\nNapa Valley winery." in out + assert "# Example Merchant\n\nNapa Valley winery." in out class TestImportantFiles: def test_emits_self_reference(self) -> None: out = build_skill_md(_base()) assert "## Important Files" in out - assert "| **SKILL.md** (this file) | `https://martin-estate.com/skill.md` |" in out + assert "| **SKILL.md** (this file) | `https://example.com/skill.md` |" in out def test_appends_caller_files(self) -> None: cfg = _base() cfg.files = [ - SkillMdLink(label="llms.txt", url="https://martin-estate.com/llms.txt"), - SkillMdLink(label="OpenAPI", url="https://martin-estate.com/openapi.json"), + SkillMdLink(label="llms.txt", url="https://example.com/llms.txt"), + SkillMdLink(label="OpenAPI", url="https://example.com/openapi.json"), ] out = build_skill_md(cfg) - assert "| llms.txt | `https://martin-estate.com/llms.txt` |" in out - assert "| OpenAPI | `https://martin-estate.com/openapi.json` |" in out + assert "| llms.txt | `https://example.com/llms.txt` |" in out + assert "| OpenAPI | `https://example.com/openapi.json` |" in out def test_strips_trailing_slash_from_homepage(self) -> None: cfg = _base() - cfg.homepage = "https://martin-estate.com/" + cfg.homepage = "https://example.com/" out = build_skill_md(cfg) - assert "`https://martin-estate.com/skill.md`" in out + assert "`https://example.com/skill.md`" in out assert "//skill.md" not in out def test_escapes_pipes_in_files(self) -> None: @@ -227,13 +227,13 @@ def test_renders_one_row_per_rail(self) -> None: assert "agentscore-pay, tempo request, x402-proxy" in out assert "**x402 on Base**" in out assert "agentscore-pay, x402-proxy, purl (omit --network flag)" in out - assert "**x402 on Solana**" in out + assert "**MPP on Solana**" in out assert "**Stripe Shared Payment Token**" in out assert "link-cli" in out def test_omits_unaccepted_rails(self) -> None: cfg = _base() - cfg.accepted_rails = ["tempo_mpp", "x402_base", "x402_solana"] + cfg.accepted_rails = ["tempo_mpp", "x402_base", "solana_mpp"] out = build_skill_md(cfg) assert "**MPP on Tempo**" in out assert "**Stripe Shared Payment Token**" not in out @@ -364,10 +364,10 @@ def test_escapes_pipes_in_endpoints(self) -> None: class TestTriggersSection: def test_emits_each_trigger(self) -> None: cfg = _base() - cfg.triggers = ["Buy wine from Martin Estate", "Check order status"] + cfg.triggers = ["Buy from Example Merchant", "Check order status"] out = build_skill_md(cfg) assert "## Triggers" in out - assert "- Buy wine from Martin Estate" in out + assert "- Buy from Example Merchant" in out assert "- Check order status" in out def test_omits_when_empty(self) -> None: @@ -390,12 +390,12 @@ def test_emits_numbered_onboarding(self) -> None: def test_emits_support_links(self) -> None: cfg = _base() cfg.support_links = [ - SkillMdLink(label="Homepage", url="https://martin-estate.com"), + SkillMdLink(label="Homepage", url="https://example.com"), SkillMdLink(label="Pay CLI", url="https://github.com/agentscore/pay"), ] out = build_skill_md(cfg) assert "## Support" in out - assert "- **Homepage**: https://martin-estate.com" in out + assert "- **Homepage**: https://example.com" in out assert "- **Pay CLI**: https://github.com/agentscore/pay" in out @@ -427,7 +427,7 @@ def test_no_triple_newline_runs(self) -> None: [ ("tempo_mpp", "MPP on Tempo"), ("x402_base", "x402 on Base"), - ("x402_solana", "x402 on Solana"), + ("solana_mpp", "MPP on Solana"), ("stripe", "Stripe Shared Payment Token"), ], ) diff --git a/tests/test_ucp.py b/tests/test_ucp.py index 7aeadc9..493e198 100644 --- a/tests/test_ucp.py +++ b/tests/test_ucp.py @@ -83,7 +83,7 @@ def test_preserves_caller_capabilities_and_appends_agentscore(): def test_passes_through_name_payment_handlers_extras(): profile = build_ucp_profile( **_base_kwargs(), - name="Martin Estate", + name="Example Merchant", payment_handlers=[ UCPPaymentHandler(name="tempo", config={"recipient": "0xtempo"}), UCPPaymentHandler(name="stripe", config={"profile_id": "prof_x"}), @@ -91,7 +91,7 @@ def test_passes_through_name_payment_handlers_extras(): extras={"custom_field": "custom_value"}, ) d = profile.to_dict() - assert d["name"] == "Martin Estate" + assert d["name"] == "Example Merchant" assert len(d["payment_handlers"]) == 2 assert d["custom_field"] == "custom_value" diff --git a/uv.lock b/uv.lock index 78f4bd0..db82644 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ [[package]] name = "agentscore-commerce" -version = "1.2.1" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "agentscore-py" }, @@ -44,7 +44,7 @@ stripe = [ { name = "stripe" }, ] x402 = [ - { name = "x402", extra = ["evm", "fastapi", "svm"] }, + { name = "x402", extra = ["evm", "fastapi"] }, ] [package.dev-dependencies] @@ -82,7 +82,7 @@ requires-dist = [ { name = "sanic", marker = "extra == 'sanic'", specifier = ">=23.0.0" }, { name = "starlette", marker = "extra == 'starlette'", specifier = ">=0.27.0" }, { name = "stripe", marker = "extra == 'stripe'", specifier = ">=11.0.0" }, - { name = "x402", extras = ["evm", "svm", "fastapi"], marker = "extra == 'x402'", specifier = ">=2.8,<3" }, + { name = "x402", extras = ["evm", "fastapi"], marker = "extra == 'x402'", specifier = ">=2.8,<3" }, ] provides-extras = ["starlette", "fastapi", "flask", "django", "aiohttp", "sanic", "stripe", "x402", "mppx"] @@ -582,28 +582,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "construct" -version = "2.10.70" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/77/8c84b98eca70d245a2a956452f21d57930d22ab88cbeed9290ca630cf03f/construct-2.10.70.tar.gz", hash = "sha256:4d2472f9684731e58cc9c56c463be63baa1447d674e0d66aeb5627b22f512c29", size = 86337, upload-time = "2023-11-29T08:44:49.545Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/fb/08b3f4bf05da99aba8ffea52a558758def16e8516bc75ca94ff73587e7d3/construct-2.10.70-py3-none-any.whl", hash = "sha256:c80be81ef595a1a821ec69dc16099550ed22197615f4320b57cc9ce2a672cb30", size = 63020, upload-time = "2023-11-29T08:44:46.876Z" }, -] - -[[package]] -name = "construct-typing" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "construct" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/ae/659fe4866d89ef5a3a65cddbdd7b35882f4feb72db383821965f2fcea934/construct_typing-0.7.0.tar.gz", hash = "sha256:71d110dedff39bd3b603c734077032a7065bc597a49db1f5b03a211d05dbac23", size = 45104, upload-time = "2025-10-27T19:30:29.614Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/0c/2db6f7e1ae9795e436c6a0dc0bc38b12b8c8a228cb63203e24190b755b3b/construct_typing-0.7.0-py3-none-any.whl", hash = "sha256:c92383c6e8e5d07ba25811c8d5163820458d821e73bb1006541f43f89788646c", size = 24350, upload-time = "2025-10-27T19:30:27.505Z" }, -] - [[package]] name = "coverage" version = "7.13.5" @@ -1448,15 +1426,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "jsonalias" -version = "0.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/45/ee7e17002cb7f3264f755ff6a1a72c55d1830e07808d643167d2a2277c4f/jsonalias-0.1.1.tar.gz", hash = "sha256:64f04d935397d579fc94509e1fcb6212f2d081235d9d6395bd10baedf760a769", size = 1095, upload-time = "2022-10-28T22:57:56.224Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/ed/05aebce69f78c104feff2ffcdd5a6f9d668a208aba3a8bf56e3750809fd8/jsonalias-0.1.1-py3-none-any.whl", hash = "sha256:a56d2888e6397812c606156504e861e8ec00e188005af149f003c787db3d3f18", size = 1312, upload-time = "2022-10-28T22:57:54.763Z" }, -] - [[package]] name = "lefthook" version = "2.1.6" @@ -2552,15 +2521,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.58.0" +version = "2.59.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/26/b3/fb8291170d0e844173164709fc0fa0c221ed75a5da740c8746f2a83b4eb1/sentry_sdk-2.58.0.tar.gz", hash = "sha256:c1144d947352d54e5b7daa63596d9f848adf684989c06c4f5a659f0c85a18f6f", size = 438764, upload-time = "2026-04-13T17:23:26.265Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/e0/9bf5e5fc7442b10880f3ec0eff0ef4208b84a099606f343ec4f5445227fb/sentry_sdk-2.59.0.tar.gz", hash = "sha256:cd265808ef8bf3f3edf69b527c0a0b2b6b1322762679e55b8987db2e9584aec1", size = 447331, upload-time = "2026-05-04T12:19:06.538Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/eb/d875669993b762556ae8b2efd86219943b4c0864d22204d622a9aee3052b/sentry_sdk-2.58.0-py2.py3-none-any.whl", hash = "sha256:688d1c704ddecf382ea3326f21a67453d4caa95592d722b7c780a36a9d23109e", size = 460919, upload-time = "2026-04-13T17:23:24.675Z" }, + { url = "https://files.pythonhosted.org/packages/bf/00/b8cc413748fb6383d1582e7cda51314f99743351c462a92dc690d5b5853b/sentry_sdk-2.59.0-py2.py3-none-any.whl", hash = "sha256:abcf65ee9a9d9cdebf9ad369782408ecca9c1c792686ef06ba34f5ab233527fe", size = 468432, upload-time = "2026-05-04T12:19:04.741Z" }, ] [[package]] @@ -2590,42 +2559,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] -[[package]] -name = "solana" -version = "0.36.11" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "construct-typing" }, - { name = "httpx" }, - { name = "solders" }, - { name = "typing-extensions" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/66/b8cd6e4d95bfe46798942ace31935e7799005a4e2180869dc7bac6b75be9/solana-0.36.11.tar.gz", hash = "sha256:2fdcf483674f4b88fe6510524bf3234a5837d19fe1815aa5a285f2739d28b3a3", size = 54516, upload-time = "2026-01-03T02:11:52.243Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/8d/807eebf0560759ad90464060e0d1d87ff5409beb6ed56104c553a83a976a/solana-0.36.11-py3-none-any.whl", hash = "sha256:1d659decc67a40ee1e9b5ded373a076b87cf3b4bd0645e120d16d9348c2025ba", size = 64786, upload-time = "2026-01-03T02:11:50.811Z" }, -] - -[[package]] -name = "solders" -version = "0.27.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jsonalias" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/25/80a81bb3dc4c70329dd0016edbdfbf2e8d8300a98ab9cd1a6ea0266bda7c/solders-0.27.1.tar.gz", hash = "sha256:7d8a24ad2f193afcdc02d6f3975917a7358b0f0ab7f4b3695b135ff2008222c8", size = 180923, upload-time = "2025-11-15T07:50:52.32Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/6b/0c0ee4766705824261779d00229fb95308d6b28422613e0e2af577f60ee3/solders-0.27.1-cp38-abi3-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:4dcd8e766bab24afbe9e0ae363d86f9810457e04b00c8a9149f69ca939ed587c", size = 24883435, upload-time = "2025-11-15T07:50:34.42Z" }, - { url = "https://files.pythonhosted.org/packages/33/1c/be04a1b26e18c409dd006d214198dc03f0b657c1cb34f4c83b763f8348f0/solders-0.27.1-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5d87b145cc0129095f9cff8c7f28d2e910bc5b5a4cf257c263b08a4b95f111dd", size = 6480729, upload-time = "2025-11-15T07:50:37.323Z" }, - { url = "https://files.pythonhosted.org/packages/48/03/98dc73c266b11ed5c13b3933510a1aa115becf97f45bec1a22da9d03ffa9/solders-0.27.1-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6082bbe46b7b1b2b005d046011f89fcae75fc5ea4f1a0ef5c2e9dfb5fe7930ce", size = 12744782, upload-time = "2025-11-15T07:50:39.283Z" }, - { url = "https://files.pythonhosted.org/packages/a0/39/35384d8fb80d05937bd9e8af7237cfe3f0d017c8aba357209d90d428f3a0/solders-0.27.1-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ccb821c2e4af43d976f312086f248a67352b3986e5f4c87af41cfeac6d8b5683", size = 6601257, upload-time = "2025-11-15T07:50:41.738Z" }, - { url = "https://files.pythonhosted.org/packages/8c/65/8989e521142473bf1130613476a4449e106bb97ed6cc86097f6f519b1234/solders-0.27.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:663a10566ae81f67c4515d4db5fbf51b735204741728c1a5cde11c4e019a51df", size = 7277802, upload-time = "2025-11-15T07:50:43.789Z" }, - { url = "https://files.pythonhosted.org/packages/f2/41/87ecf12cec0e7aa9c67b0cf1b8079fb28aa0af91e97328a3bd0c5e3001ba/solders-0.27.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:d14f05a77dbbf7966fb26f255c81302e6127550bdb66c2fdc99f522043fdf376", size = 7082541, upload-time = "2025-11-15T07:50:45.847Z" }, - { url = "https://files.pythonhosted.org/packages/33/b9/35e6f59b41bb205b26c7318fcdca43f3d59464fd3ddc13d36f36427f64d4/solders-0.27.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f778eeab411acec0a765a01c7b772f8eca8a8543d98276bd83cb826960da211b", size = 6845568, upload-time = "2025-11-15T07:50:47.698Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f3/14ed12d8d5047ababaca3271f82ebbf500ff74b6358f283962232103a12d/solders-0.27.1-cp38-abi3-win_amd64.whl", hash = "sha256:f3b787c29570a46d219c7a67543d8b0fadc73abda346653aa20e8eccd839e78b", size = 5295092, upload-time = "2025-11-15T07:50:50.517Z" }, -] - [[package]] name = "sortedcontainers" version = "2.4.0" @@ -2803,14 +2736,14 @@ wheels = [ [[package]] name = "types-requests" -version = "2.33.0.20260408" +version = "2.33.0.20260503" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/6a/749dc53a54a3f35842c1f8197b3ca6b54af6d7458a1bfc75f6629b6da666/types_requests-2.33.0.20260408.tar.gz", hash = "sha256:95b9a86376807a216b2fb412b47617b202091c3ea7c078f47cc358d5528ccb7b", size = 23882, upload-time = "2026-04-08T04:34:49.33Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/b8/57e94268c0d82ac3eaa2fc35aa8ca7bbc2542f726b67dcf90b0b00a3b14d/types_requests-2.33.0.20260503.tar.gz", hash = "sha256:9721b2d9dbee7131f2fb39f20f0ebb1999c18cef4b512c9a7932f3722de7c5f4", size = 23931, upload-time = "2026-05-03T05:20:08.882Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/b8/78fd6c037de4788c040fdd323b3369804400351b7827473920f6c1d03c10/types_requests-2.33.0.20260408-py3-none-any.whl", hash = "sha256:81f31d5ea4acb39f03be7bc8bed569ba6d5a9c5d97e89f45ac43d819b68ca50f", size = 20739, upload-time = "2026-04-08T04:34:48.325Z" }, + { url = "https://files.pythonhosted.org/packages/c3/82/959113a6351f3ca046cd0a8cd2cee071d7ea47473560557a01eeae9a6fe2/types_requests-2.33.0.20260503-py3-none-any.whl", hash = "sha256:02aaa7e3577a13471715bb1bddb693cc985ea514f754b503bf033e6a09a3e528", size = 20736, upload-time = "2026-05-03T05:20:07.858Z" }, ] [[package]] @@ -3172,10 +3105,6 @@ fastapi = [ { name = "fastapi", extra = ["standard"] }, { name = "starlette" }, ] -svm = [ - { name = "solana" }, - { name = "solders" }, -] [[package]] name = "yarl"