diff --git a/README.md b/README.md index 780052c..a49dadf 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,10 @@ pip install 'agentscore-commerce[fastapi,x402,coinbase]' | Submodule | What it provides | |---|---| -| `agentscore_commerce` (top-level) | `Checkout` orchestrator + `CheckoutContext` + `CheckoutGateConfig` + `CheckoutValidationError` + `DiscoveryProbeConfig` + `SettleOutcome` + `MppxComposeOutcome` + `PricingResult` (the 2.0 high-level surface: one config object, hooks for pre_validate/compute_pricing/on_settled/mint_recipients/compose_mppx, auto-derived x402+mppx servers, per-framework adapters `handle_fastapi`/`handle_flask`/`handle_django`/`handle_aiohttp`/`handle_sanic`, signed UCP routes via `mount_ucp_routes_{fastapi,flask,django,aiohttp,sanic}`); `pricing_result` (factory: cents-denominated → typed `PricingResult` with embedded `PricingBlock`); `validation_response_{fastapi,flask,django,aiohttp,sanic}` (per-framework 4xx envelope wrappers); `make_mppx_compose_hook` (canonical pympp compose adapter). | +| `agentscore_commerce` (top-level) | `Checkout` orchestrator + `CheckoutContext` + `CheckoutGateConfig` + `CheckoutValidationError` + `DiscoveryProbeConfig` + `SettleOutcome` + `MppxComposeOutcome` + `PricingResult` (the 2.0 high-level surface: one config object, hooks for pre_validate/compute_pricing/on_settled/mint_recipients/compose_mppx, auto-derived x402+mppx servers, per-framework adapters `handle_fastapi`/`handle_flask`/`handle_django`/`handle_aiohttp`/`handle_sanic`, signed UCP routes via `mount_ucp_routes_{fastapi,flask,django,aiohttp,sanic}`); `create_result_cache` (neutral keyed JSON-value cache with a stable body-hash key builder; cache a probe-leg result, e.g. a paid upstream call made in `pre_validate`, and replay it on the settle leg) plus the compute-first-shaped `create_quote_cache` built on it; `pricing_result` (factory: cents-denominated → typed `PricingResult` with embedded `PricingBlock`); `validation_response_{fastapi,flask,django,aiohttp,sanic}` (per-framework 4xx envelope wrappers); `make_mppx_compose_hook` (canonical pympp compose adapter). | | `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; parses the credential, lifts signer + network, returns a `ZeroSettleResult` shaped identically to the success path so callers branch on rail, not on result shape); `usd_to_atomic` (Decimal-based USD → atomic int, ROUND_HALF_UP, for Tempo / Solana / Base USDC amount construction). | +| `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.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). `SolanaMppRailSpec.ata_creation_required` defaults to `True` (data-only; solana method registration through `create_mppx_server` is a follow-up, merchants building the solana method directly via `pympp` should pass the flag themselves to the charge factory). `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`. | diff --git a/pyproject.toml b/pyproject.toml index 4c17025..395e24a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-commerce" -version = "2.5.3" +version = "2.5.4" 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" diff --git a/tests/test_seamless_helpers.py b/tests/test_seamless_helpers.py index 11baafc..68e3beb 100644 --- a/tests/test_seamless_helpers.py +++ b/tests/test_seamless_helpers.py @@ -2204,3 +2204,81 @@ def test_x_payment_info_from_checkout_merges_protocol_extras() -> None: p["mpp"] for p in ext["x-payment-info"]["protocols"] if p.get("mpp", {}).get("method") == "tempo" ) assert tempo_entry["client_command"] == "agentscore-pay pay --chain tempo" + + +@pytest.mark.asyncio +async def test_zero_settle_repriced_hash_credential_settles_free() -> None: + """Repriced-to-zero pinning: an agent signs a hash credential against a + NONZERO quote and the merchant re-prices to $0 at settle (no-match flows). + The carve-out must absorb it: 200, no compose call, nothing charged. This + is the flow that regressed on node 2.6.4 when $0 settles were delegated + upstream; pympp has no proof payload type, so python must keep the + carve-out for every credential shape until it does.""" + compose_calls: list[int] = [] + + async def _compose(_ctx: Any) -> MppxComposeOutcome: + compose_calls.append(1) + return MppxComposeOutcome(status=402, headers={"www-authenticate": 'Payment id="x"'}) + + checkout = _zero_settle_checkout({"tempo": TempoRailSpec(recipient="0x" + "00" * 19 + "dEaD")}) + checkout.compose_mppx = _compose + result = await checkout.handle( + CheckoutRequest( + method="POST", + url="https://api.example/purchase", + headers={ + "authorization": "Payment " + + base64.b64encode( + json.dumps( + { + "challenge": {"id": "ch_1", "realm": "api.example"}, + "payload": {"type": "hash", "hash": "0xabc"}, + "source": "did:pkh:eip155:42431:0xeb2Ca790F72787c7e61bC6c861353a1e4ACDFCa5", + } + ).encode() + ).decode() + }, + body={}, + ), + ) + assert result.status == 200 + assert result.body.get("signer") == "0xeb2ca790f72787c7e61bc6c861353a1e4acdfca5" + assert result.body.get("signer_network") == "evm" + assert compose_calls == [] + + +@pytest.mark.asyncio +async def test_zero_settle_proof_credential_also_takes_carve_out_until_pympp_ships_proofs() -> None: + """A $0 PROOF credential currently takes the carve-out too (pympp cannot + verify proofs). When pympp ships proof support and python adds delegation, + this test must flip to assert the compose path runs; see the node SDK's + zero-settle routing for the target behavior.""" + compose_calls: list[int] = [] + + async def _compose(_ctx: Any) -> MppxComposeOutcome: + compose_calls.append(1) + return MppxComposeOutcome(status=402, headers={"www-authenticate": 'Payment id="x"'}) + + checkout = _zero_settle_checkout({"tempo": TempoRailSpec(recipient="0x" + "00" * 19 + "dEaD")}) + checkout.compose_mppx = _compose + result = await checkout.handle( + CheckoutRequest( + method="POST", + url="https://api.example/purchase", + headers={ + "authorization": "Payment " + + base64.b64encode( + json.dumps( + { + "challenge": {"id": "ch_1", "realm": "api.example"}, + "payload": {"type": "proof", "signature": "0x" + "ab" * 65}, + "source": "did:pkh:eip155:42431:0xeb2Ca790F72787c7e61bC6c861353a1e4ACDFCa5", + } + ).encode() + ).decode() + }, + body={}, + ), + ) + assert result.status == 200 + assert compose_calls == [] diff --git a/uv.lock b/uv.lock index 04800c2..54db0c5 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ constraints = [{ name = "fastapi", specifier = "!=0.136.3" }] [[package]] name = "agentscore-commerce" -version = "2.5.3" +version = "2.5.4" source = { editable = "." } dependencies = [ { name = "agentscore-py" },