Skip to content
Merged
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,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.identity.{fastapi,flask,django,aiohttp,sanic,middleware}` | Trust gate middleware (KYC, age, sanctions on both account name and signer wallet, jurisdiction) |
| `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), `process_x402_settle` (verify+settle in one call), `create_mppx_server` (wraps `pympp[server,tempo,stripe]>=0.6`), 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) |
Expand All @@ -17,7 +17,7 @@ Every helper is extracted from a real consumer, not speculated.

## Architecture

Single Python package, hatchling-built, published to PyPI as `agentscore-commerce`. Per-framework identity adapters expose the same surface (`AgentScoreGate`, or `agentscore_gate(app, ...)` for Flask/Sanic; `capture_wallet`, `verify_wallet_signer_match`, `get_agentscore_data`, `get_gate_degraded_state`, `get_gate_quota_info`) with network-aware address normalization (EVM lowercased, Solana base58 preserved verbatim).
Single Python package, hatchling-built, published to PyPI as `agentscore-commerce`. Per-framework identity adapters expose the same surface (`AgentScoreGate`, or `agentscore_gate(app, ...)` for Flask/Sanic; `capture_wallet`, `get_signer_verdict`, `get_agentscore_data`, `get_gate_degraded_state`, `get_gate_quota_info`) with network-aware address normalization (EVM lowercased, Solana base58 preserved verbatim). The gate middleware extracts the inbound payment signer pre-evaluate (`extract_payment_signer(x402_header)`) and passes it to `/v1/assess` via the SDK's `signer` kwarg, so the API composes both wallet-binding (`signer_match`) and OFAC SDN wallet-address (`signer_sanctions`) verdicts on one round trip; merchants read both back synchronously via `get_signer_verdict(request)` off the gate's cache.

| Directory | Contents |
|---|---|
Expand Down Expand Up @@ -77,11 +77,11 @@ Two identity types: wallet (`X-Wallet-Address`) and operator-token (`X-Operator-

`DenialReason` codes (`missing_identity`, `identity_verification_required`, `token_expired`, `invalid_credential`, `wallet_signer_mismatch`, `wallet_auth_requires_wallet_signing`, `wallet_not_trusted`, `api_error`, `payment_required`) each carry a structured `agent_instructions` JSON block describing concrete recovery actions. See `agentscore_commerce/identity/_response.py` for the canned action copy.

`create_session_on_missing` auto-mints a verification session when no identity is present and returns 403 with `verify_url` + poll instructions. `verify_wallet_signer_match` (per-adapter) compares the recovered signer against `linked_wallets[]` for cross-chain wallet-stack matching.
`create_session_on_missing` auto-mints a verification session when no identity is present and returns 403 with `verify_url` + poll instructions. `get_signer_verdict(request)` (per-adapter) returns the cached `signer_match` + `signer_sanctions` verdicts the gate composed on its primary `/v1/assess` call (single round trip; merchants build a 403 with `build_signer_mismatch_body(result=verdict.signer_match)` when `kind != "pass"`).

Captured wallets: `capture_wallet(...)` is fire-and-forget. Reads `operator_token` stashed during gating and POSTs to `/v1/credentials/wallets`. No-ops for wallet-authenticated requests.

Wallet-signer-match: `verify_wallet_signer_match` / `averify_wallet_signer_match` makes a single `/v1/assess` call with `resolve_signer` set; the API resolves both wallets and emits a `signer_match` verdict in the same response, collapsing the legacy 2 follow-up assess calls into one round trip. Repeat lookups for the same `(claimed, signer)` pair hit a per-cache-entry `signer_match_by_signer` sub-dict and skip the API entirely. Falls back to a 2-resolve path when the API doesn't emit `signer_match` (canary rollout safety).
Wallet-signer-match + signer-sanctions: the gate adapter calls `extract_payment_signer(x402_header)` pre-evaluate and passes `signer={address, network}` to the SDK's `assess`. The API returns both `signer_match` (wallet-binding) and `signer_sanctions` (OFAC SDN wallet-address) on the same response; commerce caches the raw body alongside the projected verdicts so `get_signer_verdict` is a pure cache read. Under `policy.require_sanctions_clear`, an OFAC SDN signer hit already flips `decision -> deny` before the handler runs.

### Fail-open (opt-in)

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ pip install 'agentscore-commerce[fastapi,x402,coinbase]'

| Submodule | What it provides |
|---|---|
| `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_agentscore_data(...)`, `capture_wallet(...)`, `verify_wallet_signer_match(...)`. |
| `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` (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 that 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 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), `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,jwks.json}`, `/favicon.{png,ico}`; pure helpers `is_discovery_path` + `DEFAULT_DISCOVERY_PATHS` for non-ASGI frameworks). |
Expand All @@ -40,7 +40,7 @@ from agentscore_commerce.identity.fastapi import (
AgentScoreGate,
capture_wallet,
get_agentscore_data,
verify_wallet_signer_match,
get_signer_verdict,
)

app = FastAPI()
Expand Down
6 changes: 4 additions & 2 deletions agentscore_commerce/identity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
DenialCode,
DenialReason,
OperatorVerification,
VerifyWalletSignerMatchOptions,
SignerSanctions,
SignerVerdict,
VerifyWalletSignerResult,
build_agent_memory_hint,
)
Expand Down Expand Up @@ -116,14 +117,15 @@ def _load_asgi_middleware() -> tuple[Any, Any]:
"IdentityStatus",
"OperatorVerification",
"PolicyBlock",
"SignerSanctions",
"SignerVerdict",
"UCPCapabilityBinding",
"UCPPaymentHandlerBinding",
"UCPProfile",
"UCPProfileBody",
"UCPServiceBinding",
"UCPSigningKey",
"UCPVerificationError",
"VerifyWalletSignerMatchOptions",
"VerifyWalletSignerResult",
"build_a2a_agent_card",
"build_agent_memory_hint",
Expand Down
19 changes: 10 additions & 9 deletions agentscore_commerce/identity/_denial.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
be resolved by re-completing KYC (vs sanctions / age failures which are permanent).
denial_reason_status — picks the right HTTP status code per denial code (401 for credential
problems, 503 for transient API errors, 403 for everything else).
build_signer_mismatch_body — produces the standard 403 body for a verify_wallet_signer_match
non-pass result.
build_signer_mismatch_body — produces the standard 403 body for a non-pass signer_match
verdict (read via get_signer_verdict).
build_contact_support_next_steps — standard `next_steps.action: "contact_support"` shape for
unfixable compliance denials.
verification_agent_instructions — the canned `agent_instructions` block for
Expand Down Expand Up @@ -73,16 +73,17 @@ def build_signer_mismatch_body(
user_message: str | None = None,
learn_more_url: str | None = None,
) -> dict[str, Any] | None:
"""Standard 403 body for a non-pass `verify_wallet_signer_match` result.
"""Standard 403 body for a non-pass signer_match verdict.

Returns None for pass / api_error so vendors can call unconditionally::
Returns None for pass so vendors can call unconditionally::

result = await verify_wallet_signer_match(request, signer=...)
body = build_signer_mismatch_body(result)
if body:
return JSONResponse(body, status_code=403)
verdict = get_signer_verdict(request)
if verdict is not None and verdict.signer_match is not None:
body = build_signer_mismatch_body(verdict.signer_match)
if body:
return JSONResponse(body, status_code=403)
"""
if result.kind in ("pass", "api_error"):
if result.kind == "pass":
return None

learn_more = learn_more_url or "https://docs.agentscore.sh/guides/agent-identity"
Expand Down
43 changes: 20 additions & 23 deletions agentscore_commerce/identity/aiohttp.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,11 @@
DenialReason,
GateQuotaInfo,
Network,
VerifyWalletSignerMatchOptions,
VerifyWalletSignerResult,
SignerVerdict,
apply_degraded,
)
from agentscore_commerce.payment.signer import (
extract_payment_signer,
extract_payment_signer_address,
read_x402_payment_header,
)

Expand Down Expand Up @@ -70,14 +68,13 @@ def _mark_degraded_aiohttp(request: web.Request, infra_reason: str) -> None:
"denial_reason_status",
"denial_reason_to_body",
"extract_payment_signer",
"extract_payment_signer_address",
"get_agentscore_data",
"get_gate_degraded_state",
"get_gate_quota_info",
"get_signer_verdict",
"is_fixable_denial",
"read_x402_payment_header",
"verification_agent_instructions",
"verify_wallet_signer_match",
]


Expand Down Expand Up @@ -214,11 +211,18 @@ async def _agentscore_middleware(

chain_override = _extract_chain(request)

signer_payload: dict[str, str] | None = None
if identity.address:
x402_header = read_x402_payment_header(dict(request.headers))
recovered = extract_payment_signer(x402_header)
if recovered is not None:
signer_payload = {"address": recovered.address, "network": recovered.network}

# Only acheck_identity is wrapped — the downstream handler call must NOT be in the
# try, otherwise an exception in the user's route would be misclassified as an
# AgentScore infra failure and (under fail_open) re-invoke their handler.
try:
result = await client.acheck_identity(identity, chain_override)
result = await client.acheck_identity(identity, chain_override, signer=signer_payload)
except PaymentRequiredError:
if client.fail_open:
return await handler(request)
Expand Down Expand Up @@ -292,26 +296,19 @@ async def _agentscore_middleware(
return _agentscore_middleware


async def verify_wallet_signer_match(
request: web.Request,
signer: str | None,
network: Network = "evm",
) -> VerifyWalletSignerResult:
"""Verify payment signer matches claimed X-Wallet-Address.
def get_signer_verdict(request: web.Request) -> SignerVerdict | None:
"""Synchronous read of the cached signer verdicts for the current request.

No-ops when operator-token-authenticated or when both headers were sent. See
:func:`agentscore_commerce.identity.middleware.verify_wallet_signer_match` for the full contract.
Returns ``None`` for operator-token-only requests, for requests with no payment
credential, or for fail-open pass-throughs (no assess call).
"""
state = request.get(GATE_STATE_KEY)
if not state or not state.get("wallet_address") or state.get("operator_token"):
return VerifyWalletSignerResult(kind="pass")
return await state["client"].averify_wallet_signer_match(
VerifyWalletSignerMatchOptions(
claimed_wallet=state["wallet_address"],
signer=signer,
network=network,
),
)
if not state or not state.get("wallet_address"):
return None
client = state.get("client")
if client is None:
return None
return client.get_signer_verdict(state["wallet_address"])


async def capture_wallet(
Expand Down
Loading