diff --git a/CLAUDE.md b/CLAUDE.md index 2e093f6..7217a7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ Every helper is extracted from a real consumer, not speculated. | `agentscore_commerce.identity.{fastapi,flask,django,aiohttp,sanic,middleware}` | Trust gate middleware (KYC, age, sanctions on both account name and signer wallet, jurisdiction). Each adapter exports a conditional variant that wraps the gate so it fires only on settle legs (anonymous discovery flows through and gets a 402 with all rails): FastAPI / ASGI expose `ConditionalAgentScoreGate`, Django exposes `ConditionalAgentScoreMiddleware`, Flask + Sanic expose `conditional_agentscore_gate(app, ...)`, aiohttp exposes `conditional_agentscore_gate_middleware(...)`. Adapters export ONLY framework-specific surface (gate classes / fns, accessors, `capture_wallet`); shared helpers like `has_payment_header` / `denial_reason_to_body` import from their canonical home (`agentscore_commerce.payment` and `agentscore_commerce.identity` respectively). The Flask / Sanic `agentscore_gate(app, ...)` function accepts an optional `condition=` callable for inline gating (`AgentScoreGate.__init__` does not). | | `agentscore_commerce.identity.policy` | Per-product compliance helpers: `PolicyBlock`, `build_gate_from_policy`, `run_gate_with_enforcement`, `shipping_country_allowed`, `shipping_state_allowed`, `validate_shipping_against_policy` (one-call country+state validator that raises `CheckoutValidationError` with the canonical envelope on miss) | | `agentscore_commerce.payment` | Networks/USDC/rails registries, paymentauth.org directive builders, `create_x402_server` (wraps `x402[evm]>=2.9` + `cdp-sdk` for `facilitator="coinbase"`; install via the `coinbase` extra), `build_x402_accepts_for_402` (build the 402's `accepts[]` from the registered scheme; derives the right `extra.name` per network), `build_default_checkout_rails(tempo=, x402_base=, solana_mpp=, stripe=)` (canonical 4-rail `rails` dict factory: merchants pass per-rail overrides instead of redeclaring the recipient sentinel + network/chain_id/token boilerplate. When a caller flips `network` without pinning `token` / `chain_id`, the underlying dataclass derives them from the network: Base Sepolia → Sepolia USDC + chain_id 84532, Solana devnet → devnet USDC mint. Explicit overrides always win. Solana's `network` field accepts both CAIP-2 (`solana:5eykt4UsFv8…` / `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) AND the raw `@solana/mpp` form (`mainnet-beta` / `devnet` / `localnet`)), `build_mppx_compose_rails(amount_usd=, tempo_recipient=, solana_recipient=, ...)` (per-call intent factory replacing the hand-rolled `[("tempo/charge", {...}), ("solana/charge", {...}), ("stripe/charge", {...})]` list; auto-handles USD→atomic conversion for Solana; auto-drops the `stripe/charge` rail with a one-time `logging.warning` when `amount_usd < 0.50` since Stripe's fixed ~$0.30 fee makes sub-50-cent charges unprofitable — many Stripe accounts also reject PI creation below the floor with `amount_too_small`; sub-50-cent APIs pass `include_stripe=False` explicitly to silence the warning), `process_x402_settle` (verify+settle in one call), `create_mppx_server` (wraps `pympp[server,tempo,stripe]>=0.6`), `is_evm_network`/`is_solana_network` (CAIP-2 discriminators that hide the `startswith("eip155:")` / `startswith("solana:")` prefix matching), `has_payment_header` (settle-leg vs discovery-leg discriminator), `parse_did_pkh_address` (parses ``did:pkh:::`` into a `PaymentSigner`), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header | -| `agentscore_commerce.discovery` | Discovery probe (`is_discovery_probe_request`, `build_discovery_probe_response`), Bazaar wrapper, `/.well-known/mpp.json`, `llms.txt` builder, `skill.md` builder (Claude-Skill-compatible agent-discovery manifest), `build_redemption_skill_md` (delivery-neutral; printed/emailed/API-trial codes all covered via `delivery_intro`/`body_shape`/`body_rules`/`extra_recovery_rows` overrides), `build_merchant_index_json` + `standard_endpoint_descriptions(kind=)` (canonical `/` discovery body for goods or API merchants), `build_success_next_steps` (universal Passport-active success block), `build_agentscore_onboarding_steps`, OpenAPI snippets, `NoindexNonDiscoveryMiddleware` ASGI middleware. Plus the UCP/JWKS publish surface: `build_signed_ucp_response`, `build_signed_jwks_response`, `well_known_preflight_response`, `default_a2a_services`, `bootstrap_ucp_signing_key`, framework-neutral `SignedDiscoveryResponse` + per-framework wrappers `signed_response_{fastapi,flask,django,aiohttp,sanic}` | +| `agentscore_commerce.discovery` | Discovery probe (`is_discovery_probe_request`, `build_discovery_probe_response`; the x402 sample envelope always carries a v2 `resource` and threads optional `extensions`, and `Checkout` fills both from its own `url`/`resource_info`/`discovery_extensions`, because envelope validators (mppx, x402scan's shared engine) hard-require `resource` and read the Bazaar example input to build valid probe bodies), Bazaar wrapper, `/.well-known/mpp.json`, `llms.txt` builder, `skill.md` builder (Claude-Skill-compatible agent-discovery manifest), `build_redemption_skill_md` (delivery-neutral; printed/emailed/API-trial codes all covered via `delivery_intro`/`body_shape`/`body_rules`/`extra_recovery_rows` overrides), `build_merchant_index_json` + `standard_endpoint_descriptions(kind=)` (canonical `/` discovery body for goods or API merchants), `build_success_next_steps` (universal Passport-active success block), `build_agentscore_onboarding_steps`, OpenAPI snippets, `NoindexNonDiscoveryMiddleware` ASGI middleware. Plus the UCP/JWKS publish surface: `build_signed_ucp_response`, `build_signed_jwks_response`, `well_known_preflight_response`, `default_a2a_services`, `bootstrap_ucp_signing_key`, framework-neutral `SignedDiscoveryResponse` + per-framework wrappers `signed_response_{fastapi,flask,django,aiohttp,sanic}` | | `agentscore_commerce.challenge` | 402-body builders: accepted_methods, identity_metadata (auto-attached by `Checkout` when wallet header present), how_to_pay, agent_instructions, build_402_body, pricing, agent_memory, `build_validation_error` (4xx body builder), `Receipt`/`ReceiptNextSteps`/`ProductInfo`/`ShippingAddress` (canonical 200-receipt dataclasses) | | `agentscore_commerce.stripe_multichain` | Multichain PaymentIntent helper (`create_multichain_payment_intent` returns `MultichainPaymentIntentResult`; read `result.deposit_addresses[network]` directly), `create_pay_to_address_from_stripe_pi(authorization_header=, amount_cents=, stripe=, pi_cache=, networks=, static_recipients=, metadata=, order_id=, preferred_network=)` — one-call per-order payTo resolver matching `Checkout.mint_recipients`: on the settle leg, reuses the buyer's signed-against payTo from the MPP credential (after `pi_cache.has_address` check OR a `static_recipients` match — the static address is always-accepted because the merchant owns it); on the discovery leg, mints a fresh PI for the rails NOT covered by `static_recipients`, caches the merged map, registers static addresses with `pi_cache.cache_address`. `mint_multichain_recipients(...same kwargs) -> MintMultichainRecipientsResult(recipients, payment_intent_id, reused_from_credential)` — structured variant returning the full per-rail map; prefer this when the merchant's `mint_recipients` hook needs all rail addresses (typical multi-rail merchant), and to avoid the "returned-string-is-ambiguous" trap on the settle leg when `static_recipients` is configured. Use `static_recipients={"solana": ""}` for low-margin endpoints where rotating per-PI Solana addresses can't absorb MPP spec §13.6's ~$0.50 ATA rent per call — the SDK skips Stripe minting on that network and reuses the static recipient forever; pair with a one-time external USDC pre-funding of the recipient's ATA and every settle pays only the per-tx fee. Testnet simulator (`simulate_crypto_deposit`, `simulate_deposit_if_test_mode`), `simulate_deposit_for_outcome(outcome=, deposit_address=, get_payment_intent_id=, stripe_secret_key=, stripe_version=)` (dispatches the simulator based on a Checkout / compute_first_checkout settle outcome; replaces the per-merchant rail-switch + thin `simulate_deposit_if_testnet(addr, network)` wrapper), `network_for_outcome` (outcome → simulator network arg, handles both Checkout-shaped `rail_key` and compute-first-shaped `mpp_method`, accepts bare scheme names AND `/charge` forms), `create_pi_cache`, `create_mppx_stripe` | | `agentscore_commerce.api` | Re-exports `AgentScore` from `agentscore` SDK | diff --git a/README.md b/README.md index 33528f8..b9bc618 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ pip install 'agentscore-commerce[fastapi,x402,coinbase]' | `agentscore_commerce.identity.{fastapi,flask,django,aiohttp,sanic,middleware}` | Trust gate middleware: KYC, sanctions (account name + signer wallet), age, jurisdiction. `AgentScoreGate(...)` (or `agentscore_gate(app, ...)` on Flask/Sanic), `get_agentscore_data(...)`, `capture_wallet(...)`, `get_signer_verdict(...)`. The gate extracts the payment signer pre-evaluate and passes it to `/v1/assess`, so the API composes both wallet-binding (`signer_match`) and OFAC SDN wallet-address (`signer_sanctions`) verdicts on one round trip. | | `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`. The per-framework adapter modules also expose `get_gate_quota_info(request)` for surfacing X-RateLimit info from gate state. 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`, `validate_shipping_against_policy` (one-call country+state validator that raises `CheckoutValidationError` with the canonical envelope on miss), for multi-product merchants where each product carries its own compliance config: hard gate vs soft vs none, per-product shipping allowlists. Key + token helpers: `load_ucp_signing_key_from_env` (cached env-driven loader for the UCP signing key, reads `UCP_SIGNING_KEY_JWK_PRIVATE` JSON JWK, detects alg from shape, falls back to ephemeral when unset, sanitizes errors so key bytes never reach logs, concurrent-safe via `threading.Lock`; env-var names and `default_kid` / `default_alg` are overridable as kwargs); `hash_operator_token` (sha256 hex of plaintext `opc_...`, for merchants persisting `operator_token_id` to their own DB without ever storing the plaintext); `extract_owner_scope(headers) -> OwnerScope` (canonical owner-identity extractor for caller-scoped resource queries, reads `X-Wallet-Address` / `X-Operator-Token`, hashes the token so plaintext never leaves the request); `default_read_only_on_denied(reason)` (canonical `on_denied` for read-only resource gates: 401 + `Cache-Control: no-store` while still spreading `denial_reason_to_body`. Returns a `DefaultOnDeniedResult(body, status, headers)`; FastAPI / Flask / aiohttp / Sanic `on_denied` callbacks accept an optional 3-tuple `(body, status, headers)` to carry headers through; wrap with `lambda req, reason: (lambda r: (r.body, r.status, r.headers or {}))(default_read_only_on_denied(reason))`). | | `agentscore_commerce.payment` | `networks`, `USDC`, `rails` registries; `payment_directive`, `build_payment_directive`, `www_authenticate_header`, `payment_required_header`, `alias_amount_fields` (opt-in v1↔v2 amount-field shim that adds both `amount` and `maxAmountRequired` to an entry. The 402 builders do NOT apply it by default, strict x402 v2 settlement matches the agent's echoed requirement by exact comparison, so an extra field the server's rebuilt requirement lacks breaks settle; use only when you know a client is hardcoded to read `maxAmountRequired`), `settlement_override_header`, `dispatch_settlement_by_network`, `extract_payment_signer` (accepts positional `x402_payment_header` AND/OR `authorization_header=` kwarg; recovers signer from x402 EIP-3009 `payload.authorization.from` OR MPP `Authorization: Payment ` `did:pkh:eip155::` / `did:pkh:solana::` source DID), `detect_rail_from_headers` (returns `"x402"` / `"mpp"` / `None` from inbound headers), `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), `classify_orchestration_error` (same `ClassifiedX402Error` shape but for uncaught exceptions thrown elsewhere in the orchestration; returns `None` for unknown errors so merchants rethrow instead of swallowing); `zero_amount_carve_out` (skip CDP / pympp upstream verify+settle for $0 settles where the upstream rejects value=0 payloads, including credentials signed against a nonzero quote the merchant re-priced to $0 at settle; parses the credential, lifts signer + network, returns a `ZeroSettleResult` shaped identically to the success path so callers branch on rail, not on result shape), `malformed_payment_credential` (wire-shape gate: payment headers that are not base64 JSON and not token-shaped are rejected before merchant hooks run; `Checkout` applies it by default, opt out with `credential_pre_check=False`); `usd_to_atomic` (Decimal-based USD → atomic int, ROUND_HALF_UP, for Tempo / Solana / Base USDC amount construction). | -| `agentscore_commerce.discovery` | `is_discovery_probe_request`, `build_discovery_probe_response` (with optional `x402_sample` for x402-aware crawlers like `awal x402 details`), `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), `build_redemption_skill_md` (delivery-neutral redemption-code template, printed mailers, emailed codes, API trial credits all covered; `endpoint_path`/`delivery_intro`/`body_shape`/`body_rules`/`extra_recovery_rows` overrides for non-goods shapes), `build_merchant_index_json` (canonical `/` discovery body), `standard_endpoint_descriptions(kind=)` (canonical method+path → description map for goods vs api merchants; optional `include_order_status_route` for goods), `build_success_next_steps` (universal Passport-active success block), `build_agentscore_onboarding_steps` (canonical skill.md onboarding for goods or API merchants), `agentscore_openapi_snippets`, `build_bazaar_discovery_payload`, `NoindexNonDiscoveryMiddleware` (ASGI middleware emitting `X-Robots-Tag: noindex` on every path except the agent-discovery surfaces; pure helpers `is_discovery_path` + `DEFAULT_DISCOVERY_PATHS` for non-ASGI frameworks). Plus the UCP/JWKS publish surface: `build_signed_ucp_response`, `build_signed_jwks_response`, `well_known_preflight_response`, `default_a2a_services`, `bootstrap_ucp_signing_key`, framework-neutral `SignedDiscoveryResponse` + per-framework wrappers `signed_response_{fastapi,flask,django,aiohttp,sanic}`. | +| `agentscore_commerce.discovery` | `is_discovery_probe_request`, `build_discovery_probe_response` (with optional `x402_sample` for x402-aware crawlers like `awal x402 details`; the sample envelope always carries a v2 `resource` (explicit, from `resource_url`, or synthesized from the realm) because envelope validators require it, and threads optional `extensions` (e.g. the Bazaar block with input/output schemas) into header and body so discovery engines can build valid follow-up probe bodies; `Checkout` fills both from its own `url`/`resource_info`/`discovery_extensions` automatically), `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), `build_redemption_skill_md` (delivery-neutral redemption-code template, printed mailers, emailed codes, API trial credits all covered; `endpoint_path`/`delivery_intro`/`body_shape`/`body_rules`/`extra_recovery_rows` overrides for non-goods shapes), `build_merchant_index_json` (canonical `/` discovery body), `standard_endpoint_descriptions(kind=)` (canonical method+path → description map for goods vs api merchants; optional `include_order_status_route` for goods), `build_success_next_steps` (universal Passport-active success block), `build_agentscore_onboarding_steps` (canonical skill.md onboarding for goods or API merchants), `agentscore_openapi_snippets`, `build_bazaar_discovery_payload`, `NoindexNonDiscoveryMiddleware` (ASGI middleware emitting `X-Robots-Tag: noindex` on every path except the agent-discovery surfaces; pure helpers `is_discovery_path` + `DEFAULT_DISCOVERY_PATHS` for non-ASGI frameworks). Plus the UCP/JWKS publish surface: `build_signed_ucp_response`, `build_signed_jwks_response`, `well_known_preflight_response`, `default_a2a_services`, `bootstrap_ucp_signing_key`, framework-neutral `SignedDiscoveryResponse` + per-framework wrappers `signed_response_{fastapi,flask,django,aiohttp,sanic}`. | | `agentscore_commerce.challenge` | `build_402_body`, `build_accepted_methods`, `build_identity_metadata` (auto-attached by `Checkout` when an inbound `X-Wallet-Address` header is present), `build_how_to_pay`, `build_agent_instructions` (auto-emits per-rail `compatible_clients`: smoke-verified CLIs the agent should use; vendor override supported; pure helper `compatible_clients_by_rails(rails)` returns the same map for vendors building custom 402s), `build_pricing_block` (cents to 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), `Receipt` + `ReceiptNextSteps` + `ProductInfo` + `ShippingAddress` (canonical 200-receipt dataclasses, universal across goods + API merchants); `respond_402`, a 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` (returns `MultichainPaymentIntentResult(payment_intent_id, deposit_addresses)`; read `result.deposit_addresses[network]` directly), `create_pay_to_address_from_stripe_pi(authorization_header=, amount_cents=, stripe=, pi_cache=, networks=, static_recipients=, metadata=, order_id=, preferred_network=)`, per-order payTo resolver: on the settle leg, reuses the buyer's signed-against payTo from the MPP credential (after `pi_cache.has_address` check OR a `static_recipients` match, the static address is always-accepted because the merchant owns it); on the discovery leg, mints a fresh PI for the rails NOT covered by `static_recipients`, caches the merged map, registers static addresses with `pi_cache.cache_address` so verify-leg lookups pass. `mint_multichain_recipients(...same kwargs)`, structured variant returning `MintMultichainRecipientsResult(recipients, payment_intent_id, reused_from_credential)` for the full per-rail map (typical multi-rail merchant hook). Solana MPP requires a static recipient: pass `static_recipients={"solana": ""}` and pre-fund its USDC ATA once from any external wallet. Rotating (per-order, Stripe-minted) Solana recipients do not settle, since the settlement transaction does not create the recipient's token account; the SDK logs a warning when one is minted. The static recipient is reused forever, each settle pays only the ~$0.001 per-tx fee, and this also sidesteps the per-call ATA rent (~$0.50 against MPP spec §13.6). `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`. | | `agentscore_commerce.api` | Everything from `agentscore-py` re-exported in one place: `AgentScore` + `AgentScoreError`, `AGENTSCORE_TEST_ADDRESSES` + `is_agentscore_test_address`. **Don't add `agentscore-py` as a separate dep**: the two can drift versions and cause subtle type mismatches. | diff --git a/agentscore_commerce/checkout.py b/agentscore_commerce/checkout.py index 02cefa4..ba1963d 100644 --- a/agentscore_commerce/checkout.py +++ b/agentscore_commerce/checkout.py @@ -1136,6 +1136,38 @@ async def handle(self, request: CheckoutRequest) -> CheckoutResult: body_text = json.dumps(request.body) if request.body else "" if await is_discovery_probe_request(request.method, auth, body_text): cfg = self.discovery_probe + # Default the sample envelope's ``resource`` and ``extensions`` from + # what the real 402 would emit (the checkout's own url + resource_info, + # and the enriched Bazaar extensions): v2 validators hard-require + # ``resource``, and discovery engines read the Bazaar example input to + # build valid bodies for their follow-up probes. Explicit values win. + x402_sample = cfg.x402_sample + if x402_sample is not None: + from dataclasses import replace as _dc_replace + from urllib.parse import urlparse as _urlparse + + from agentscore_commerce.discovery.bazaar import ( + enrich_bazaar_discovery_extensions, + ) + + default_resource = x402_sample.resource + if default_resource is None and not x402_sample.resource_url: + default_resource = {"url": self.url, "mimeType": "application/json"} + if self.resource_info: + default_resource.update(self.resource_info) + default_extensions = x402_sample.extensions + if default_extensions is None and self.discovery_extensions is not None: + probe_path = _urlparse(self.url).path or self.url + enriched = enrich_bazaar_discovery_extensions( + self.discovery_extensions, method=request.method, path=probe_path + ) + if enriched: + default_extensions = enriched + x402_sample = _dc_replace( + x402_sample, + resource=default_resource, + extensions=default_extensions, + ) probe = build_discovery_probe_response( realm=cfg.realm, sample_rail=cfg.sample_rail, @@ -1145,7 +1177,7 @@ async def handle(self, request: CheckoutRequest) -> CheckoutResult: ttl_seconds=cfg.ttl_seconds, docs_url=cfg.docs_url, message=cfg.message, - x402_sample=cfg.x402_sample, + x402_sample=x402_sample, ) return CheckoutResult( status=probe.status, diff --git a/agentscore_commerce/discovery/probe.py b/agentscore_commerce/discovery/probe.py index de27e9a..9aedc1a 100644 --- a/agentscore_commerce/discovery/probe.py +++ b/agentscore_commerce/discovery/probe.py @@ -90,6 +90,17 @@ class X402SampleProbe: amount_atomic: str = "1000000" version: Literal[1, 2] = 2 resource_url: str | None = None + resource: dict[str, Any] | None = None + """Full x402 v2 ResourceInfo for the sample envelope; overrides ``resource_url``. + When neither is set a minimal resource is synthesized from the realm: v2 + envelope validators (mppx, x402scan's shared engine) hard-require ``resource``, + so a resource-less sample header reads as "no valid x402 response" however + correct the accepts are.""" + extensions: dict[str, Any] | None = None + """x402 v2 ``extensions`` for the sample envelope (header AND body), e.g. the + Bazaar block with input/output schemas. Discovery validators read the example + input from here to build VALID bodies for their follow-up probes. ``Checkout`` + fills this from its own ``discovery_extensions`` automatically.""" @dataclass @@ -143,12 +154,23 @@ def build_discovery_probe_response( ] # Emit the sample accepts as-is (no v1<->v2 amount alias) so the probe # sample matches what the real 402 emits; clients version-route on x402Version. - header_kwargs: dict[str, Any] = {"x402_version": x402v, "accepts": sample_accepts} - if x402_sample.resource_url: - header_kwargs["resource"] = { - "url": x402_sample.resource_url, - "mimeType": "application/json", - } + # The v2 envelope REQUIRES ``resource``: validators (mppx, x402scan's shared + # engine) refuse a resource-less PAYMENT-REQUIRED header outright, so when + # the caller supplied neither form, synthesize a minimal one from the realm. + if x402_sample.resource is not None: + resource = x402_sample.resource + elif x402_sample.resource_url: + resource = {"url": x402_sample.resource_url, "mimeType": "application/json"} + else: + realm_url = realm if realm.startswith("http") else f"https://{realm}" + resource = {"url": realm_url, "mimeType": "application/json"} + header_kwargs: dict[str, Any] = { + "x402_version": x402v, + "accepts": sample_accepts, + "resource": resource, + } + if x402_sample.extensions: + header_kwargs["extensions"] = x402_sample.extensions encoded = payment_required_header(**header_kwargs) headers["payment-required"] = encoded # Mirror the header's accepts in the body so clients that fall back from @@ -156,6 +178,9 @@ def build_discovery_probe_response( decoded = json.loads(base64.b64decode(encoded).decode()) body_obj["x402Version"] = x402v body_obj["accepts"] = decoded["accepts"] + body_obj["resource"] = decoded["resource"] + if "extensions" in decoded: + body_obj["extensions"] = decoded["extensions"] return DiscoveryProbeResponse( status=402, diff --git a/pyproject.toml b/pyproject.toml index 966afac..babe75c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-commerce" -version = "2.6.0" +version = "2.7.0" description = "Agentic 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 agentic commerce." readme = "README.md" license = "MIT" diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 0bc23f2..33f67ad 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -248,6 +248,35 @@ def test_probe_response_with_resource_url() -> None: assert decoded["resource"]["url"] == "https://example.com/api" +def test_probe_response_always_carries_resource_and_mirrors_extensions() -> None: + # v2 envelope validators (mppx, x402scan's shared engine) hard-require + # ``resource``; a resource-less sample header reads as "no valid x402 response". + import base64 + import json as _json + + from agentscore_commerce.discovery.probe import X402SampleProbe, build_discovery_probe_response + + synthesized = build_discovery_probe_response(**_probe_opts(x402_sample=X402SampleProbe(networks=["eip155:84532"]))) + decoded = _json.loads(base64.b64decode(synthesized.headers["payment-required"]).decode()) + assert decoded["resource"]["url"].startswith("https://") + assert _json.loads(synthesized.body)["resource"] == decoded["resource"] + + extensions = {"bazaar": {"info": {"input": {"type": "http", "method": "POST", "bodyType": "json"}}}} + explicit = build_discovery_probe_response( + **_probe_opts( + x402_sample=X402SampleProbe( + networks=["eip155:84532"], + resource={"url": "https://example.com/api", "serviceName": "Example", "mimeType": "application/json"}, + extensions=extensions, + ) + ) + ) + decoded = _json.loads(base64.b64decode(explicit.headers["payment-required"]).decode()) + assert decoded["resource"]["serviceName"] == "Example" + assert decoded["extensions"] == extensions + assert _json.loads(explicit.body)["extensions"] == extensions + + def test_probe_response_with_docs_url() -> None: import json as _json diff --git a/tests/test_seamless_helpers.py b/tests/test_seamless_helpers.py index 68e3beb..33e39d7 100644 --- a/tests/test_seamless_helpers.py +++ b/tests/test_seamless_helpers.py @@ -1037,6 +1037,48 @@ async def _pricing(_ctx: Any) -> PricingResult: assert result.body.get("error", {}).get("code") == "payment_required" +@pytest.mark.asyncio +async def test_checkout_discovery_probe_inherits_resource_and_bazaar_extensions() -> None: + import base64 + import json as _json + + from agentscore_commerce import ( + Checkout, + CheckoutRequest, + DiscoveryProbeConfig, + PricingResult, + ) + from agentscore_commerce.discovery.probe import X402SampleProbe + from agentscore_commerce.payment.rail_spec import TempoRailSpec + + async def _pricing(_ctx: Any) -> PricingResult: + return PricingResult(amount_usd=0.01) + + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0x" + "00" * 19 + "dE")}, + url="https://api.example/search", + compute_pricing=_pricing, + resource_info={"serviceName": "Example API"}, + discovery_extensions={"bazaar": {"info": {"input": {"type": "http", "bodyType": "json", "body": {"q": "x"}}}}}, + discovery_probe=DiscoveryProbeConfig( + realm="api.example", + sample_rail="tempo-mainnet", + sample_amount_usd=0.01, + sample_recipient="0xRecipient", + x402_sample=X402SampleProbe(networks=["eip155:8453"]), + ), + ) + result = await checkout.handle( + CheckoutRequest(method="POST", url="https://api.example/search", headers={}, body={}), + ) + assert result.status == 402 + decoded = _json.loads(base64.b64decode(result.headers["payment-required"]).decode()) + assert decoded["resource"]["url"] == "https://api.example/search" + assert decoded["resource"]["serviceName"] == "Example API" + assert "bazaar" in decoded["extensions"] + assert result.body["resource"] == decoded["resource"] + + @pytest.mark.asyncio async def test_checkout_discovery_probe_skipped_when_payment_header_present() -> None: from agentscore_commerce import (