diff --git a/CLAUDE.md b/CLAUDE.md index cc227c7..6565976 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) | @@ -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 | |---|---| @@ -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) diff --git a/README.md b/README.md index d841cbe..319aa49 100644 --- a/README.md +++ b/README.md @@ -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). | @@ -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() diff --git a/agentscore_commerce/identity/__init__.py b/agentscore_commerce/identity/__init__.py index 81ce082..5e6fb78 100644 --- a/agentscore_commerce/identity/__init__.py +++ b/agentscore_commerce/identity/__init__.py @@ -42,7 +42,8 @@ DenialCode, DenialReason, OperatorVerification, - VerifyWalletSignerMatchOptions, + SignerSanctions, + SignerVerdict, VerifyWalletSignerResult, build_agent_memory_hint, ) @@ -116,6 +117,8 @@ def _load_asgi_middleware() -> tuple[Any, Any]: "IdentityStatus", "OperatorVerification", "PolicyBlock", + "SignerSanctions", + "SignerVerdict", "UCPCapabilityBinding", "UCPPaymentHandlerBinding", "UCPProfile", @@ -123,7 +126,6 @@ def _load_asgi_middleware() -> tuple[Any, Any]: "UCPServiceBinding", "UCPSigningKey", "UCPVerificationError", - "VerifyWalletSignerMatchOptions", "VerifyWalletSignerResult", "build_a2a_agent_card", "build_agent_memory_hint", diff --git a/agentscore_commerce/identity/_denial.py b/agentscore_commerce/identity/_denial.py index 486c464..2fdcf4c 100644 --- a/agentscore_commerce/identity/_denial.py +++ b/agentscore_commerce/identity/_denial.py @@ -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 @@ -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" diff --git a/agentscore_commerce/identity/aiohttp.py b/agentscore_commerce/identity/aiohttp.py index 7997ee0..7fe0a16 100644 --- a/agentscore_commerce/identity/aiohttp.py +++ b/agentscore_commerce/identity/aiohttp.py @@ -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, ) @@ -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", ] @@ -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) @@ -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( diff --git a/agentscore_commerce/identity/client.py b/agentscore_commerce/identity/client.py index 280ca2b..e3f04cc 100644 --- a/agentscore_commerce/identity/client.py +++ b/agentscore_commerce/identity/client.py @@ -40,12 +40,12 @@ GateQuotaInfo, Network, OperatorVerification, - VerifyWalletSignerMatchOptions, + SignerVerdict, VerifyWalletSignerResult, ) if TYPE_CHECKING: - from agentscore.types import DecisionPolicy, ResolveSigner + from agentscore.types import DecisionPolicy, Signer from agentscore_commerce.identity.types import DenialReason @@ -91,6 +91,11 @@ def __init__( default_ua = f"agentscore-commerce/{_pkg_version('agentscore-commerce')}" self.user_agent = f"{user_agent} ({default_ua})" if user_agent else default_ua self._cache: TTLCache[AssessResult] = TTLCache(cache_seconds) + # Parallel cache of the raw /v1/assess response dict — populated alongside the + # projected AssessResult cache so get_signer_verdict() can read signer_match + + # signer_sanctions directly off the wire without re-shaping them through the + # projector. Same TTL semantics as _cache. + self._raw_response_cache: TTLCache[dict[str, Any]] = TTLCache(cache_seconds) self._policy: dict[str, Any] = {} if require_kyc is not None: @@ -134,7 +139,11 @@ def _cache_key(self, address: str | None = None, operator_token: str | None = No return normalize_address(address) if address else "" def _build_body( - self, address: str | None = None, chain: str | None = None, operator_token: str | None = None + self, + address: str | None = None, + chain: str | None = None, + operator_token: str | None = None, + signer: dict[str, str] | None = None, ) -> dict[str, Any]: """Construct the assess request body. @@ -151,6 +160,8 @@ def _build_body( body["chain"] = effective_chain if self._policy: body["policy"] = self._policy + if signer is not None: + body["signer"] = signer return body def _headers(self) -> dict[str, str]: @@ -249,9 +260,18 @@ def _project(self, data: dict[str, Any]) -> AssessResult: ) def check( - self, address: str | None = None, chain: str | None = None, operator_token: str | None = None + self, + address: str | None = None, + chain: str | None = None, + operator_token: str | None = None, + signer: dict[str, str] | None = None, ) -> AssessResult: - """Synchronous assess call with caching. Accepts address and/or operator_token.""" + """Synchronous assess call with caching. Accepts address and/or operator_token. + + When ``signer`` is provided (extracted by the adapter middleware from the + inbound request's payment credential), the API composes ``signer_match`` and + ``signer_sanctions`` verdicts on the response in one round trip. + """ key = self._cache_key(address, operator_token) cached = self._cache.get(key) @@ -266,6 +286,7 @@ def check( operator_token=operator_token, chain=effective_chain, policy=cast("DecisionPolicy | None", self._policy or None), + signer=cast("Signer | None", signer), ) except SdkPaymentRequiredError as exc: raise PaymentRequiredError from exc @@ -296,14 +317,25 @@ def check( status = exc.status_code or 0 raise RuntimeError(f"AgentScore API returned {status}: {exc}") from exc - result = self._project(cast("dict[str, Any]", data)) + raw = cast("dict[str, Any]", data) + result = self._project(raw) self._cache.set(key, result) + # Cache the raw response under the same key so get_signer_verdict() can read + # signer_match + signer_sanctions verdicts that the projector doesn't expose. + self._raw_response_cache.set(key, raw) return result async def acheck( - self, address: str | None = None, chain: str | None = None, operator_token: str | None = None + self, + address: str | None = None, + chain: str | None = None, + operator_token: str | None = None, + signer: dict[str, str] | None = None, ) -> AssessResult: - """Asynchronous assess call with caching. Accepts address and/or operator_token.""" + """Asynchronous assess call with caching. Accepts address and/or operator_token. + + See :meth:`check` for the ``signer`` contract. + """ key = self._cache_key(address, operator_token) cached = self._cache.get(key) @@ -317,6 +349,7 @@ async def acheck( operator_token=operator_token, chain=effective_chain, policy=cast("DecisionPolicy | None", self._policy or None), + signer=cast("Signer | None", signer), ) except SdkPaymentRequiredError as exc: raise PaymentRequiredError from exc @@ -339,17 +372,71 @@ async def acheck( status = exc.status_code or 0 raise RuntimeError(f"AgentScore API returned {status}: {exc}") from exc - result = self._project(cast("dict[str, Any]", data)) + raw = cast("dict[str, Any]", data) + result = self._project(raw) self._cache.set(key, result) + # Cache the raw response under the same key so get_signer_verdict() can read + # signer_match + signer_sanctions verdicts that the projector doesn't expose. + self._raw_response_cache.set(key, raw) return result - def check_identity(self, identity: AgentIdentity, chain: str | None = None) -> AssessResult: + def check_identity( + self, + identity: AgentIdentity, + chain: str | None = None, + signer: dict[str, str] | None = None, + ) -> AssessResult: """Convenience method to check using an AgentIdentity object.""" - return self.check(address=identity.address, chain=chain, operator_token=identity.operator_token) + return self.check( + address=identity.address, + chain=chain, + operator_token=identity.operator_token, + signer=signer, + ) - async def acheck_identity(self, identity: AgentIdentity, chain: str | None = None) -> AssessResult: + async def acheck_identity( + self, + identity: AgentIdentity, + chain: str | None = None, + signer: dict[str, str] | None = None, + ) -> AssessResult: """Async convenience method to check using an AgentIdentity object.""" - return await self.acheck(address=identity.address, chain=chain, operator_token=identity.operator_token) + return await self.acheck( + address=identity.address, + chain=chain, + operator_token=identity.operator_token, + signer=signer, + ) + + def get_signer_verdict(self, claimed_address: str) -> SignerVerdict | None: + """Synchronous read of the cached signer verdicts (signer_match + signer_sanctions). + + Both verdicts were composed by the gate's primary /v1/assess call on this + request — single round trip. Returns ``None`` when the gate didn't run with + a signer (operator-token-only paths, discovery legs). + + Under ``policy.require_sanctions_clear``, OFAC SDN wallet-address hits are + already enforced by the gate (decision -> deny before the handler runs); + merchant code typically only needs this for the signer_match wallet-binding + verdict. + """ + claimed_norm = normalize_address(claimed_address) + key = self._cache_key(address=claimed_norm) + raw = self._raw_response_cache.get(key) + if not raw: + return None + signer_match = raw.get("signer_match") if isinstance(raw, dict) else None + signer_sanctions = raw.get("signer_sanctions") if isinstance(raw, dict) else None + if not signer_match and not signer_sanctions: + return None + actual_signer = signer_match.get("actual_signer") if isinstance(signer_match, dict) else None + signer_norm = actual_signer if isinstance(actual_signer, str) else claimed_norm + return SignerVerdict( + signer_match=( + self._project_signer_match(signer_match, claimed_norm, signer_norm) if signer_match else None + ), + signer_sanctions=signer_sanctions if signer_sanctions else None, + ) def capture_wallet( self, @@ -395,84 +482,6 @@ async def acapture_wallet( # Wallet-auth signer binding # ------------------------------------------------------------------ - def _resolve_from_cache(self, wallet: str) -> tuple[bool, str | None, list[str]]: - """Look up a wallet in either cache. Returns (hit, operator, linked_wallets).""" - for key in (wallet, f"resolve:{wallet}"): - cached = self._cache.get(key) - if cached is not None: - raw = cached.raw or {} - op = raw.get("resolved_operator") - links_raw = raw.get("linked_wallets") - links = [w for w in links_raw if isinstance(w, str)] if isinstance(links_raw, list) else [] - if op is None or isinstance(op, str): - return True, op, links - return False, None, [] - - def _resolve_wallet_to_operator(self, wallet_address: str) -> tuple[bool, str | None, list[str]]: - """Resolve a wallet to its operator id via /v1/assess. - - Returns ``(ok, operator)``: - - ``(True, )``: wallet linked to that operator - - ``(True, None)``: wallet is valid but unlinked - - ``(False, None)``: transient API failure (network / non-2xx). Caller should emit - an ``api_error`` result rather than silently assert the wallet is unlinked. - - Checks both the main evaluate cache and the resolve-specific cache before calling - the API — saves a second /v1/assess when the gate already resolved this wallet - during identity evaluation. - - Returns ``(ok, operator, linked_wallets)``. ``linked_wallets`` is the set of wallets - sharing the same operator (both wallet-claim and captured-signer links); echoed back to - agents on ``wallet_signer_mismatch`` denials so they know which wallets they can - legitimately sign with. - """ - # Network-aware: lowercase EVM, preserve Solana base58 case. The DB stores both - # formats verbatim in operator_credential_wallets.wallet_address; lowercasing a - # Solana address would never match. The cache key uses the same normalized form. - wallet = normalize_address(wallet_address) - hit, op, links = self._resolve_from_cache(wallet) - if hit: - return True, op, links - try: - data = self._sdk.assess(address=wallet) - except AgentScoreError: - return False, None, [] - data_dict = cast("dict[str, Any]", data) - self._cache.set(f"resolve:{wallet}", AssessResult(allow=True, raw=data_dict)) - op_value = data_dict.get("resolved_operator") - linked_raw = data_dict.get("linked_wallets") - linked = [w for w in linked_raw if isinstance(w, str)] if isinstance(linked_raw, list) else [] - return True, (op_value if isinstance(op_value, str) else None), linked - - async def _aresolve_wallet_to_operator(self, wallet_address: str) -> tuple[bool, str | None, list[str]]: - # Same network-aware normalization as the sync path; see _resolve_wallet_to_operator. - wallet = normalize_address(wallet_address) - hit, op, links = self._resolve_from_cache(wallet) - if hit: - return True, op, links - try: - data = await self._sdk.aassess(address=wallet) - except AgentScoreError: - return False, None, [] - data_dict = cast("dict[str, Any]", data) - self._cache.set(f"resolve:{wallet}", AssessResult(allow=True, raw=data_dict)) - op_value = data_dict.get("resolved_operator") - linked_raw = data_dict.get("linked_wallets") - linked = [w for w in linked_raw if isinstance(w, str)] if isinstance(linked_raw, list) else [] - return True, (op_value if isinstance(op_value, str) else None), linked - - def _report_signer_event_sync(self, kind: str) -> None: - """Fire-and-forget telemetry post. Never raises. - - The SDK's ``telemetry_signer_match`` already swallows all errors internally — - this method is just the commerce-side dispatch. - """ - self._sdk.telemetry_signer_match({"kind": kind}) - - async def _report_signer_event_async(self, kind: str) -> None: - """Async variant. SDK swallows all errors internally.""" - await self._sdk.atelemetry_signer_match({"kind": kind}) - def _project_signer_match( self, sm: dict[str, Any], claimed_norm: str, signer_norm: str ) -> VerifyWalletSignerResult: @@ -510,162 +519,6 @@ def _project_signer_match( def _infer_signer_network(self, signer: str) -> str: return "evm" if signer.startswith("0x") else "solana" - def verify_wallet_signer_match(self, options: VerifyWalletSignerMatchOptions) -> VerifyWalletSignerResult: - """Verify payment signer resolves to the same operator as the claimed wallet. - - Single-call path: makes one ``/v1/assess`` request with ``resolve_signer`` set; - the response carries a ``signer_match`` verdict the gate projects directly. Falls - back to a two-resolve path when the response has no ``signer_match`` so the gate - still produces a verdict. - - Returns: - ``kind='pass'`` when the signer is the claimed wallet (byte-equal) or both resolve - to the same operator. ``kind='wallet_signer_mismatch'`` when operators differ. - ``kind='wallet_auth_requires_wallet_signing'`` when ``signer`` is ``None`` (SPT/card). - ``kind='api_error'`` when /v1/assess resolve failed — caller should retry or surface - as 503; distinct from mismatch so legitimate users aren't rejected on network flakes. - """ - signer = options.signer - if signer is None: - self._report_signer_event_sync("wallet_auth_requires_wallet_signing") - return VerifyWalletSignerResult( - kind="wallet_auth_requires_wallet_signing", - claimed_wallet=options.claimed_wallet, - agent_instructions=WALLET_AUTH_REQUIRES_WALLET_SIGNING_INSTRUCTIONS, - ) - # Network-aware normalization: lowercase EVM, preserve Solana base58. Both the - # byte-equal short-circuit AND the cache key derive from this — lowercasing - # Solana would corrupt both and make every Solana signer-match return api_error. - claimed = normalize_address(options.claimed_wallet) - signer_norm = normalize_address(signer) - if claimed == signer_norm: - self._report_signer_event_sync("pass") - return VerifyWalletSignerResult(kind="pass") - - # Cache hit: a prior call for this same (claimed, signer) pair populated signer_match. - # Skip both the round trip AND the SDK telemetry post (the API recorded it last time). - cached_entry = self._cache.get(claimed) - if cached_entry is not None: - cached_match = cached_entry.signer_match_by_signer.get(signer_norm) - if cached_match is not None: - return self._project_signer_match(cached_match, claimed, signer_norm) - - # Single resolve_signer-aware assess call — server-side resolves both wallets and - # returns a verdict in one round trip. - network = options.network or self._infer_signer_network(signer_norm) - try: - data = self._sdk.assess( - address=claimed, - resolve_signer=cast("ResolveSigner", {"address": signer_norm, "network": network}), - ) - except AgentScoreError as exc: - _log.warning("[gate] verify_wallet_signer_match assess failed: %s", exc) - self._report_signer_event_sync("api_error") - return VerifyWalletSignerResult(kind="api_error", claimed_wallet=claimed) - - data_dict = cast("dict[str, Any]", data) - sm = data_dict.get("signer_match") - if isinstance(sm, dict): - if cached_entry is not None: - # Mutate in place — TTLCache.get() returns a reference, so the stored - # entry sees the new sub-dict without a set() call. This preserves the - # gate's original cache TTL window (set() would reset it forward, - # causing the gate verdict to be served past its intended freshness). - cached_entry.signer_match_by_signer[signer_norm] = sm - else: - # No prior gate cache for this wallet — create a fresh entry with the - # verdict attached so a subsequent same-pair call hits cache. - entry = AssessResult(allow=True, raw=data_dict) - entry.signer_match_by_signer[signer_norm] = sm - self._cache.set(claimed, entry) - return self._project_signer_match(sm, claimed, signer_norm) - - # API response had no signer_match (server didn't compute one). Two-resolve - # path produces a verdict from the same operator graph. - claimed_ok, claimed_op, claimed_links = self._resolve_wallet_to_operator(claimed) - signer_ok, signer_op, _ = self._resolve_wallet_to_operator(signer_norm) - if not claimed_ok or not signer_ok: - self._report_signer_event_sync("api_error") - return VerifyWalletSignerResult(kind="api_error", claimed_wallet=claimed) - if claimed_op and signer_op and claimed_op == signer_op: - self._report_signer_event_sync("pass") - return VerifyWalletSignerResult(kind="pass", claimed_operator=claimed_op, signer_operator=signer_op) - self._report_signer_event_sync("wallet_signer_mismatch") - return VerifyWalletSignerResult( - kind="wallet_signer_mismatch", - claimed_operator=claimed_op, - actual_signer_operator=signer_op, - expected_signer=claimed, - actual_signer=signer_norm, - linked_wallets=claimed_links, - agent_instructions=WALLET_SIGNER_MISMATCH_INSTRUCTIONS, - ) - - async def averify_wallet_signer_match(self, options: VerifyWalletSignerMatchOptions) -> VerifyWalletSignerResult: - """Async variant of :meth:`verify_wallet_signer_match`.""" - signer = options.signer - if signer is None: - await self._report_signer_event_async("wallet_auth_requires_wallet_signing") - return VerifyWalletSignerResult( - kind="wallet_auth_requires_wallet_signing", - claimed_wallet=options.claimed_wallet, - agent_instructions=WALLET_AUTH_REQUIRES_WALLET_SIGNING_INSTRUCTIONS, - ) - claimed = normalize_address(options.claimed_wallet) - signer_norm = normalize_address(signer) - if claimed == signer_norm: - await self._report_signer_event_async("pass") - return VerifyWalletSignerResult(kind="pass") - - cached_entry = self._cache.get(claimed) - if cached_entry is not None: - cached_match = cached_entry.signer_match_by_signer.get(signer_norm) - if cached_match is not None: - return self._project_signer_match(cached_match, claimed, signer_norm) - - network = options.network or self._infer_signer_network(signer_norm) - try: - data = await self._sdk.aassess( - address=claimed, - resolve_signer=cast("ResolveSigner", {"address": signer_norm, "network": network}), - ) - except AgentScoreError as exc: - _log.warning("[gate] averify_wallet_signer_match assess failed: %s", exc) - await self._report_signer_event_async("api_error") - return VerifyWalletSignerResult(kind="api_error", claimed_wallet=claimed) - - data_dict = cast("dict[str, Any]", data) - sm = data_dict.get("signer_match") - if isinstance(sm, dict): - if cached_entry is not None: - # Async mirror of the in-place-mutate optimization in :meth:`verify_wallet_signer_match`. - cached_entry.signer_match_by_signer[signer_norm] = sm - else: - entry = AssessResult(allow=True, raw=data_dict) - entry.signer_match_by_signer[signer_norm] = sm - self._cache.set(claimed, entry) - return self._project_signer_match(sm, claimed, signer_norm) - - # Legacy fallback — async mirror of the sync path. - claimed_ok, claimed_op, claimed_links = await self._aresolve_wallet_to_operator(claimed) - signer_ok, signer_op, _ = await self._aresolve_wallet_to_operator(signer_norm) - if not claimed_ok or not signer_ok: - await self._report_signer_event_async("api_error") - return VerifyWalletSignerResult(kind="api_error", claimed_wallet=claimed) - if claimed_op and signer_op and claimed_op == signer_op: - await self._report_signer_event_async("pass") - return VerifyWalletSignerResult(kind="pass", claimed_operator=claimed_op, signer_operator=signer_op) - await self._report_signer_event_async("wallet_signer_mismatch") - return VerifyWalletSignerResult( - kind="wallet_signer_mismatch", - claimed_operator=claimed_op, - actual_signer_operator=signer_op, - expected_signer=claimed, - actual_signer=signer_norm, - linked_wallets=claimed_links, - agent_instructions=WALLET_SIGNER_MISMATCH_INSTRUCTIONS, - ) - # Re-export the timeout error class so adapters can recognize SDK-side timeouts # without having to import it from the underlying SDK directly. diff --git a/agentscore_commerce/identity/django.py b/agentscore_commerce/identity/django.py index 31ed987..3efa46b 100644 --- a/agentscore_commerce/identity/django.py +++ b/agentscore_commerce/identity/django.py @@ -35,13 +35,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, ) @@ -65,14 +63,13 @@ def _mark_degraded_django(request: HttpRequest, 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", ] @@ -204,11 +201,18 @@ def __call__(self, request: HttpRequest) -> Any: chain_override = self._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 check_identity is wrapped — get_response (which runs the downstream view) must # NOT be in the try, otherwise an exception in the user's view would be misclassified # as an AgentScore infra failure and (under fail_open) re-invoke their view. try: - result = self._client.check_identity(identity, chain_override) + result = self._client.check_identity(identity, chain_override, signer=signer_payload) except PaymentRequiredError: if self._client.fail_open: return self.get_response(request) @@ -272,26 +276,19 @@ def __call__(self, request: HttpRequest) -> Any: return self._on_denied(request, reason) -def verify_wallet_signer_match( - request: HttpRequest, - signer: str | None, - network: Network = "evm", -) -> VerifyWalletSignerResult: - """Verify payment signer matches claimed X-Wallet-Address. +def get_signer_verdict(request: HttpRequest) -> 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 = getattr(request, "_agentscore_gate", None) - if not state or not state.get("wallet_address") or state.get("operator_token"): - return VerifyWalletSignerResult(kind="pass") - return state["client"].verify_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"]) def capture_wallet( diff --git a/agentscore_commerce/identity/fastapi.py b/agentscore_commerce/identity/fastapi.py index 3ba54b4..b7a5180 100644 --- a/agentscore_commerce/identity/fastapi.py +++ b/agentscore_commerce/identity/fastapi.py @@ -41,13 +41,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, ) @@ -111,14 +109,13 @@ def get_gate_quota_info(request: Request) -> GateQuotaInfo | 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", ] @@ -239,8 +236,15 @@ async def __call__(self, request: Request) -> None: chain_override = self._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} + try: - result = await self._client.acheck_identity(identity, chain_override) + result = await self._client.acheck_identity(identity, chain_override, signer=signer_payload) except PaymentRequiredError: if self._client.fail_open: return @@ -319,26 +323,24 @@ async def purchase(assess = Depends(get_agentscore_data)): return getattr(request.state, ASSESS_STATE_KEY, None) -async def verify_wallet_signer_match( - request: Request, - signer: str | None, - network: Network = "evm", -) -> VerifyWalletSignerResult: - """Verify payment signer matches claimed X-Wallet-Address. +def get_signer_verdict(request: 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. + The gate middleware pre-extracts the payment signer and passes it to ``/v1/assess`` + so the API returns ``signer_match`` + ``signer_sanctions`` blocks on the same + round trip. This getter reads those projected verdicts off the gate's cache; no + extra HTTP call. + + Returns ``None`` for operator-token-only requests, for requests with no payment + credential yet (discovery legs), and for fail-open pass-throughs (no assess call). """ state = getattr(request.state, GATE_STATE_KEY, None) - 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( diff --git a/agentscore_commerce/identity/flask.py b/agentscore_commerce/identity/flask.py index 33b531c..47049fc 100644 --- a/agentscore_commerce/identity/flask.py +++ b/agentscore_commerce/identity/flask.py @@ -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, ) @@ -63,14 +61,13 @@ "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", ] @@ -224,8 +221,15 @@ def _agentscore_check() -> Response | tuple[Response, int] | None: chain_override = _extract_chain(flask_request) + signer_payload: dict[str, str] | None = None + if identity.address: + x402_header = read_x402_payment_header(dict(flask_request.headers)) + recovered = extract_payment_signer(x402_header) + if recovered is not None: + signer_payload = {"address": recovered.address, "network": recovered.network} + try: - result = client.check_identity(identity, chain_override) + result = client.check_identity(identity, chain_override, signer=signer_payload) if result.allow: g.agentscore = result.raw @@ -288,31 +292,25 @@ def _agentscore_check() -> Response | tuple[Response, int] | None: return _deny(DenialReason(code="api_error")) -def verify_wallet_signer_match( - signer: str | None, - network: Network = "evm", -) -> VerifyWalletSignerResult: - """Verify payment signer matches claimed X-Wallet-Address. +def get_signer_verdict() -> SignerVerdict | None: + """Synchronous read of the cached signer verdicts for the current request. - Reads gate state from Flask's ``g`` object. 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. + Reads gate state from Flask's ``g`` object. Returns ``None`` for operator-token-only + requests, requests with no payment credential, or fail-open pass-throughs (no + assess call). See :class:`SignerVerdict` for the verdict shape. """ from flask import g try: state = getattr(g, "_agentscore_gate", None) except RuntimeError: - return VerifyWalletSignerResult(kind="pass") - if not state or not state.get("wallet_address") or state.get("operator_token"): - return VerifyWalletSignerResult(kind="pass") - return state["client"].verify_wallet_signer_match( - VerifyWalletSignerMatchOptions( - claimed_wallet=state["wallet_address"], - signer=signer, - network=network, - ), - ) + return None + 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"]) def capture_wallet( diff --git a/agentscore_commerce/identity/middleware.py b/agentscore_commerce/identity/middleware.py index 7cffa04..18f57dd 100644 --- a/agentscore_commerce/identity/middleware.py +++ b/agentscore_commerce/identity/middleware.py @@ -36,13 +36,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, ) @@ -72,14 +70,13 @@ def _mark_degraded_asgi(scope: Scope, 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", ] @@ -225,11 +222,18 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: chain_override = self._extract_chain(request) if self._extract_chain else None + 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 — `await self.app(...)` (which runs the downstream # ASGI app) must NOT be in the try, otherwise an exception in the user's app would # be misclassified as an AgentScore infra failure and (under fail_open) re-invoke it. try: - result = await self._client.acheck_identity(identity, chain_override) + result = await self._client.acheck_identity(identity, chain_override, signer=signer_payload) except PaymentRequiredError: if self._client.fail_open: await self.app(scope, receive, send) @@ -315,33 +319,28 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await response(scope, receive, send) -async def verify_wallet_signer_match( - request: Request, - signer: str | None, - network: Network = "evm", -) -> VerifyWalletSignerResult: - """Verify the payment signer resolves to the same operator as the claimed X-Wallet-Address. +def get_signer_verdict(request: Request) -> SignerVerdict | None: + """Synchronous read of the cached signer verdicts for the current request. - Call this AFTER parsing the payment credential, BEFORE settlement. Returns: + Both ``signer_match`` (wallet-binding) and ``signer_sanctions`` (OFAC SDN wallet check) + are composed by the gate's primary ``/v1/assess`` call on this request — single round trip. + This getter projects them off the gate's cache; no extra HTTP call. - * ``kind='pass'`` — byte-equal or same-operator match - * ``kind='wallet_signer_mismatch'`` — different operator / unlinked signer - * ``kind='wallet_auth_requires_wallet_signing'`` — signer is None (SPT/card) + Returns ``None`` when the gate didn't run with a signer: operator-token-only paths, + discovery legs that arrive without a payment credential, and fail-open pass-throughs. - No-ops (returns ``pass`` with ``claimed_operator=None``) when the request was operator-token - authenticated or when both headers were sent (operator-token wins — the caller opted out of - strict wallet-auth). Signer-match only runs on strict wallet-auth requests. + Under ``policy.require_sanctions_clear``, OFAC SDN wallet-address hits already flip the + gate to ``decision=deny`` before the handler runs — merchant code typically only reads + ``signer_match`` for the wallet-binding verdict (e.g. via + :func:`build_signer_mismatch_body`). """ state = request.scope.get("state", {}).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( diff --git a/agentscore_commerce/identity/sanic.py b/agentscore_commerce/identity/sanic.py index 14cc811..d08a186 100644 --- a/agentscore_commerce/identity/sanic.py +++ b/agentscore_commerce/identity/sanic.py @@ -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, ) @@ -70,14 +68,13 @@ def _mark_degraded_sanic(request: 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", ] @@ -216,8 +213,15 @@ async def _agentscore_check(request: Request) -> HTTPResponse | None: 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} + try: - result = await client.acheck_identity(identity, chain_override) + result = await client.acheck_identity(identity, chain_override, signer=signer_payload) if result.allow: request.ctx.agentscore = result.raw @@ -289,26 +293,19 @@ async def _agentscore_check(request: Request) -> HTTPResponse | None: return response.json(body, status=status) -async def verify_wallet_signer_match( - request: Request, - signer: str | None, - network: Network = "evm", -) -> VerifyWalletSignerResult: - """Verify payment signer matches claimed X-Wallet-Address. +def get_signer_verdict(request: 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 = getattr(request.ctx, GATE_STATE_ATTR, None) - 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( diff --git a/agentscore_commerce/identity/signer.py b/agentscore_commerce/identity/signer.py index 08e46aa..223dbe1 100644 --- a/agentscore_commerce/identity/signer.py +++ b/agentscore_commerce/identity/signer.py @@ -7,8 +7,8 @@ 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. +``None``; custom adapters that recover the Solana signer themselves should pass +``signer={address, network}`` directly to ``GateClient.check``/``acheck``. 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 6dce6be..f9fad6d 100644 --- a/agentscore_commerce/identity/types.py +++ b/agentscore_commerce/identity/types.py @@ -3,7 +3,8 @@ from dataclasses import dataclass, field from typing import Any, Literal -from agentscore import Network as Network # noqa: TC002 — runtime re-export for vendors +from agentscore import Network as Network +from agentscore.types import SignerSanctions as SignerSanctions # noqa: TC002 — runtime re-export for vendors # Reputation-API types (Activity, Classification, Identity, Reputation, ScoreDetail, # Grade, ScoreStatus) live in agentscore-py — not re-exported here. Commerce SDK is @@ -90,31 +91,20 @@ class DenialReason: linked_wallets: list[str] = field(default_factory=list) -@dataclass -class VerifyWalletSignerMatchOptions: - """Input for GateClient.verify_wallet_signer_match.""" - - claimed_wallet: str - signer: str | None - # Optional explicit network. When omitted, the gate infers from the signer's address - # shape (EVM `0x...` → "evm", base58 → "solana"). Parity with node-commerce, where - # this field is optional with no default. - network: Network | None = None - - VerifyWalletSignerKind = Literal[ "pass", "wallet_signer_mismatch", "wallet_auth_requires_wallet_signing", - # Transient — resolve call to /v1/assess failed or timed out. Caller should retry or - # surface 503 rather than reject the user as a signer mismatch on a network flake. - "api_error", ] @dataclass class VerifyWalletSignerResult: - """Result of GateClient.verify_wallet_signer_match.""" + """Projected wallet-signer-match verdict surfaced inside :class:`SignerVerdict`. + + Kept as a dataclass for API parity with node-commerce and so existing + ``build_signer_mismatch_body(...)`` helpers consume it unchanged. + """ kind: VerifyWalletSignerKind claimed_operator: str | None = None @@ -124,12 +114,30 @@ class VerifyWalletSignerResult: actual_signer: str | None = None linked_wallets: list[str] = field(default_factory=list) claimed_wallet: str | None = None - # JSON-encoded action copy (action + steps + user_message) populated on non-pass/ - # non-api_error kinds so the merchant can spread it directly into a 403 body and - # the agent sees a concrete recovery path without a discovery-doc round trip. + # JSON-encoded action copy (action + steps + user_message) populated on non-pass + # kinds so the merchant can spread it directly into a 403 body and the agent sees + # a concrete recovery path without a discovery-doc round trip. agent_instructions: str | None = None +@dataclass +class SignerVerdict: + """Combined wallet-signer verdict surfaced by :meth:`GateClient.get_signer_verdict`. + + Both ``signer_match`` and ``signer_sanctions`` come through the gate's primary + ``/v1/assess`` call (single round trip). ``signer_match`` describes the wallet- + binding; ``signer_sanctions`` describes the OFAC SDN wallet-address check. + + Under ``policy.require_sanctions_clear``, a ``signer_sanctions`` hit OR an + unavailable lookup already flips ``decision -> deny`` inside the gate before the + handler runs — merchant code typically only needs to read ``signer_match`` here + for the wallet-binding verdict. + """ + + signer_match: VerifyWalletSignerResult | None = None + signer_sanctions: SignerSanctions | None = None + + # 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" @@ -255,10 +263,3 @@ class AssessResult: # Per-account assess quota captured from X-Quota-* response headers. Absent on # Enterprise / unlimited tiers, or when the gate didn't call assess. quota: GateQuotaInfo | None = None - # Per-signer wallet-match verdicts cached from prior verify_wallet_signer_match() calls - # for this same claimed wallet. Each signer gets its own slot so two payments under the - # same claimed identity but from different signer wallets don't serve stale verdicts to - # each other. Verdicts come from the API's ``signer_match`` response field (populated - # when the assess request carried ``resolve_signer``), so reading a hit skips the round - # trip altogether. - signer_match_by_signer: dict[str, dict[str, Any]] = field(default_factory=dict) diff --git a/agentscore_commerce/payment/__init__.py b/agentscore_commerce/payment/__init__.py index 208440f..e47d09f 100644 --- a/agentscore_commerce/payment/__init__.py +++ b/agentscore_commerce/payment/__init__.py @@ -35,7 +35,6 @@ PaymentSigner, SignerNetwork, extract_payment_signer, - extract_payment_signer_address, extract_x402_signer, read_x402_payment_header, ) @@ -126,7 +125,6 @@ "create_x402_server", "dispatch_settlement_by_network", "extract_payment_signer", - "extract_payment_signer_address", "extract_x402_signer", "lookup_rail", "network_family", diff --git a/agentscore_commerce/payment/signer.py b/agentscore_commerce/payment/signer.py index 2e77967..6806936 100644 --- a/agentscore_commerce/payment/signer.py +++ b/agentscore_commerce/payment/signer.py @@ -2,8 +2,8 @@ Returns `{address, network}` so vendors can pass the network into `capture_wallet(...)` 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. +the signer themselves and pass `signer={address, network}` to `GateClient.check`/`acheck` +directly. """ from __future__ import annotations @@ -62,16 +62,6 @@ def extract_payment_signer(x402_payment_header: str | None) -> PaymentSigner | N return None -def extract_payment_signer_address(x402_payment_header: str | None) -> str | None: - """Address-only convenience over :func:`extract_payment_signer`. - - Used by gate adapters where only the address matters for operator-equivalence - comparison. - """ - result = extract_payment_signer(x402_payment_header) - return result.address if result else None - - def read_x402_payment_header(headers: Mapping[str, str]) -> str | None: """Read the x402 payment header from a request headers mapping (case-insensitive). @@ -87,7 +77,6 @@ def read_x402_payment_header(headers: Mapping[str, str]) -> str | None: "PaymentSigner", "SignerNetwork", "extract_payment_signer", - "extract_payment_signer_address", "extract_x402_signer", "read_x402_payment_header", ] diff --git a/examples/README.md b/examples/README.md index 744ec6c..14be31f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -47,7 +47,7 @@ AgentScore Commerce handles the agent commerce protocol layer; everything else i Python wraps `x402[evm]` and `pympp[server,tempo,stripe]` as peer deps; `@solana/mpp` has no Python equivalent today. Two implications: -1. **`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. +1. **`extract_payment_signer` returns EVM only.** Solana SPL Token payer recovery requires a Solana SDK (`solders` / `solana-py`) which isn't bundled. Custom adapters that wire Solana signer recovery should pass `signer={address, network}` directly to `GateClient.acheck()`; the API returns the wallet-binding + sanctions verdicts on the same response. 2. **Streaming session payments (variable_cost_merchant.py)** sketches the protocol but doesn't ship a working tempo session implementation; there's no pip-installable `mppx` equivalent. The example shows the response shape; vendors using session payments today should check the [tempo session protocol docs](https://mpp.dev/guides/streamed-payments) and bind to a Solana wallet library directly. For Python merchants on x402 alone (Base or Solana), every helper (`create_x402_server`, `create_mppx_server`, directives, headers, dispatch, settle-overrides, signer extraction for EVM, accepted_methods, agent_instructions, how_to_pay) is fully native. diff --git a/examples/compliance_merchant.py b/examples/compliance_merchant.py index 37e677a..49d6eb2 100644 --- a/examples/compliance_merchant.py +++ b/examples/compliance_merchant.py @@ -15,7 +15,7 @@ * build_contact_support_next_steps for the unfixable branch * denial_reason_to_body + denial_reason_status for the standard fall-through (token_expired, invalid_credential, api_error get the right status + body for free) - - verify_wallet_signer_match + build_signer_mismatch_body for wallet-auth verification + - get_signer_verdict (cached signer_match read) + build_signer_mismatch_body for wallet-auth verification The pattern: vendors only write the BUSINESS-SPECIFIC denial branches. Everything else is a one-line helper call. @@ -44,7 +44,7 @@ is_fixable_denial, verification_agent_instructions, ) -from agentscore_commerce.identity.fastapi import AgentScoreGate, get_agentscore_data, verify_wallet_signer_match +from agentscore_commerce.identity.fastapi import AgentScoreGate, get_agentscore_data, get_signer_verdict SUPPORT_EMAIL = "support@example.com" @@ -136,13 +136,13 @@ async def gate_on_settle(request: Request) -> None: @app.post("/buy", dependencies=[Depends(gate_on_settle)]) async def buy(request: Request, assess: dict = Depends(get_agentscore_data)): - # Wallet-auth: verify the payment signer matches the claimed wallet (or a same-operator - # linked wallet). No-ops for operator_token requests. Pass `signer=` from your real x402/MPP - # credential extraction (use extract_payment_signer from commerce.payment, etc.). - signer_match = await verify_wallet_signer_match(request, signer=None) - mismatch_body = build_signer_mismatch_body(signer_match) - if mismatch_body: - return JSONResponse(mismatch_body, status_code=403) + # Wallet-auth: read the cached signer_match verdict the gate composed on its + # primary /v1/assess call (single round trip). Returns None on operator_token paths. + verdict = get_signer_verdict(request) + if verdict is not None and verdict.signer_match is not None: + mismatch_body = build_signer_mismatch_body(verdict.signer_match) + if mismatch_body: + return JSONResponse(mismatch_body, status_code=403) # Compliance + signer-match passed. Run the actual purchase. return {"ok": True, "identity_method": assess.get("identity_method")} diff --git a/pyproject.toml b/pyproject.toml index b76e9d0..cc31d51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-commerce" -version = "1.7.0" +version = "1.8.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" @@ -12,7 +12,7 @@ requires-python = ">=3.11" keywords = ["agentscore", "agent-commerce", "agentic-payments", "402", "x402", "mpp", "machine-payments-protocol", "fastapi", "starlette", "flask", "django", "aiohttp", "sanic", "middleware", "trust", "reputation", "kyc", "identity", "stripe", "tempo", "solana", "base", "ai-agent"] dependencies = [ "httpx>=0.25.0,<1.0.0", - "agentscore-py>=2.1.0", + "agentscore-py>=2.3.0", ] classifiers = [ "Development Status :: 5 - Production/Stable", diff --git a/tests/test_client.py b/tests/test_client.py index e39c5c8..2a50874 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -781,50 +781,6 @@ def test_build_invalid_credential_reason_carries_action_copy(self): assert reason.poll_secret is None -class TestResolveWalletErrorHandling: - """The wallet→operator resolve path is used by verify_wallet_signer_match. HTTPError - and non-2xx responses must return (False, None, []) so the caller can map to api_error - instead of crashing the request.""" - - @respx.mock - def test_resolve_returns_false_on_http_error(self): - respx.post(ASSESS_URL).mock(side_effect=httpx.ConnectError("dns failure")) - client = _make_client() - ok, op, links = client._resolve_wallet_to_operator("0xfeedfeedfeedfeedfeedfeedfeedfeedfeedfeed") - assert ok is False - assert op is None - assert links == [] - - @respx.mock - def test_resolve_returns_false_on_non_success(self): - respx.post(ASSESS_URL).mock(return_value=httpx.Response(500, json={"error": "boom"})) - client = _make_client() - ok, op, links = client._resolve_wallet_to_operator("0xfeedfeedfeedfeedfeedfeedfeedfeedfeedfeed") - assert ok is False - assert op is None - assert links == [] - - @pytest.mark.asyncio - @respx.mock - async def test_aresolve_returns_false_on_http_error(self): - respx.post(ASSESS_URL).mock(side_effect=httpx.ConnectError("dns failure")) - client = _make_client() - ok, op, links = await client._aresolve_wallet_to_operator("0xfeedfeedfeedfeedfeedfeedfeedfeedfeedfeed") - assert ok is False - assert op is None - assert links == [] - - @pytest.mark.asyncio - @respx.mock - async def test_aresolve_returns_false_on_non_success(self): - respx.post(ASSESS_URL).mock(return_value=httpx.Response(500, json={"error": "boom"})) - client = _make_client() - ok, op, links = await client._aresolve_wallet_to_operator("0xfeedfeedfeedfeedfeedfeedfeedfeedfeedfeed") - assert ok is False - assert op is None - assert links == [] - - class TestAcheckTypedErrors: """Async path mirror of TestCheckFailOpen — exercises SdkXxxError → commerce-error mapping in :meth:`acheck`. Pinned independently of the sync path because adapters wire each diff --git a/tests/test_denial.py b/tests/test_denial.py index 27aa334..d149295 100644 --- a/tests/test_denial.py +++ b/tests/test_denial.py @@ -66,9 +66,8 @@ def test_any_permanent_returns_false(self): class TestBuildSignerMismatchBody: - def test_returns_none_for_pass_or_api_error(self): + def test_returns_none_for_pass(self): assert build_signer_mismatch_body(VerifyWalletSignerResult(kind="pass")) is None - assert build_signer_mismatch_body(VerifyWalletSignerResult(kind="api_error")) is None def test_wallet_signer_mismatch_with_linked_wallets(self): result = VerifyWalletSignerResult( diff --git a/tests/test_flask.py b/tests/test_flask.py index 2d0484e..22270a8 100644 --- a/tests/test_flask.py +++ b/tests/test_flask.py @@ -761,27 +761,25 @@ def test_api_error_branch_bad_on_denied_shape(self) -> None: client.get("/", headers={"x-wallet-address": "0xabc"}) -class TestFlaskVerifyWalletSignerMatchNoOp: - """verify_wallet_signer_match silently returns pass when Flask state isn't available.""" +class TestFlaskGetSignerVerdictNoOp: + """get_signer_verdict silently returns None when Flask state isn't available.""" def test_no_op_outside_request_context(self) -> None: - from agentscore_commerce.identity.flask import verify_wallet_signer_match + from agentscore_commerce.identity.flask import get_signer_verdict # Flask's g raises RuntimeError when accessed outside an app context. - # The adapter should catch and return pass, not propagate. - result = verify_wallet_signer_match(signer="0xabc") - assert result.kind == "pass" + # The adapter catches and returns None, not propagate. + assert get_signer_verdict() is None def test_no_op_when_gate_state_missing_in_request(self) -> None: from flask import Flask - from agentscore_commerce.identity.flask import verify_wallet_signer_match + from agentscore_commerce.identity.flask import get_signer_verdict # App without the gate registered → g._agentscore_gate is absent. app = Flask(__name__) with app.test_request_context("/"): - result = verify_wallet_signer_match(signer="0xabc") - assert result.kind == "pass" + assert get_signer_verdict() is None class TestFlaskCaptureWalletNoOp: diff --git a/tests/test_get_signer_verdict.py b/tests/test_get_signer_verdict.py new file mode 100644 index 0000000..4bd566a --- /dev/null +++ b/tests/test_get_signer_verdict.py @@ -0,0 +1,253 @@ +"""Per-adapter coverage for ``get_signer_verdict`` — returns ``None`` when no signer was +extracted (operator-token-only paths, no payment credential, missing gate state).""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +WALLET = "0x1111111111111111111111111111111111111111" + + +# --------------------------------------------------------------------------- +# ASGI middleware +# --------------------------------------------------------------------------- + + +def test_asgi_get_signer_verdict_returns_none_without_state() -> None: + from agentscore_commerce.identity.middleware import get_signer_verdict + + request = MagicMock() + request.scope = {"state": {}} + assert get_signer_verdict(request) is None + + +def test_asgi_get_signer_verdict_returns_none_for_operator_token_only() -> None: + from agentscore_commerce.identity.middleware import GATE_STATE_KEY, get_signer_verdict + + fake_client = MagicMock() + request = MagicMock() + request.scope = {"state": {GATE_STATE_KEY: {"client": fake_client, "wallet_address": None}}} + assert get_signer_verdict(request) is None + fake_client.get_signer_verdict.assert_not_called() + + +def test_asgi_get_signer_verdict_delegates_to_client() -> None: + from agentscore_commerce.identity.middleware import GATE_STATE_KEY, get_signer_verdict + from agentscore_commerce.identity.types import SignerVerdict + + sentinel = SignerVerdict(signer_match=None, signer_sanctions={"kind": "clear"}) + fake_client = MagicMock() + fake_client.get_signer_verdict.return_value = sentinel + request = MagicMock() + request.scope = {"state": {GATE_STATE_KEY: {"client": fake_client, "wallet_address": WALLET}}} + verdict = get_signer_verdict(request) + assert verdict is sentinel + fake_client.get_signer_verdict.assert_called_once_with(WALLET) + + +# --------------------------------------------------------------------------- +# FastAPI +# --------------------------------------------------------------------------- + + +def test_fastapi_get_signer_verdict_returns_none_for_operator_token_only() -> None: + from agentscore_commerce.identity.fastapi import GATE_STATE_KEY, get_signer_verdict + + fake_client = MagicMock() + request = MagicMock() + setattr(request.state, GATE_STATE_KEY, {"client": fake_client, "wallet_address": None}) + assert get_signer_verdict(request) is None + fake_client.get_signer_verdict.assert_not_called() + + +def test_fastapi_get_signer_verdict_delegates_to_client() -> None: + from agentscore_commerce.identity.fastapi import GATE_STATE_KEY, get_signer_verdict + from agentscore_commerce.identity.types import SignerVerdict + + sentinel = SignerVerdict(signer_match=None, signer_sanctions=None) + fake_client = MagicMock() + fake_client.get_signer_verdict.return_value = sentinel + request = MagicMock() + setattr(request.state, GATE_STATE_KEY, {"client": fake_client, "wallet_address": WALLET}) + verdict = get_signer_verdict(request) + assert verdict is sentinel + fake_client.get_signer_verdict.assert_called_once_with(WALLET) + + +# --------------------------------------------------------------------------- +# Flask +# --------------------------------------------------------------------------- + + +def test_flask_get_signer_verdict_returns_none_for_operator_token_only() -> None: + from flask import Flask + + from agentscore_commerce.identity.flask import get_signer_verdict + + app = Flask(__name__) + fake_client = MagicMock() + with app.test_request_context("/"): + from flask import g + + g._agentscore_gate = {"client": fake_client, "wallet_address": None} # type: ignore[attr-defined] + assert get_signer_verdict() is None + fake_client.get_signer_verdict.assert_not_called() + + +def test_flask_get_signer_verdict_delegates_to_client() -> None: + from flask import Flask + + from agentscore_commerce.identity.flask import get_signer_verdict + from agentscore_commerce.identity.types import SignerVerdict + + sentinel = SignerVerdict(signer_match=None, signer_sanctions=None) + app = Flask(__name__) + fake_client = MagicMock() + fake_client.get_signer_verdict.return_value = sentinel + with app.test_request_context("/"): + from flask import g + + g._agentscore_gate = {"client": fake_client, "wallet_address": WALLET} # type: ignore[attr-defined] + verdict = get_signer_verdict() + assert verdict is sentinel + fake_client.get_signer_verdict.assert_called_once_with(WALLET) + + +# --------------------------------------------------------------------------- +# Django +# --------------------------------------------------------------------------- + + +def test_django_get_signer_verdict_returns_none_for_operator_token_only() -> None: + from agentscore_commerce.identity.django import get_signer_verdict + + fake_client = MagicMock() + request = MagicMock() + request._agentscore_gate = {"client": fake_client, "wallet_address": None} + assert get_signer_verdict(request) is None + fake_client.get_signer_verdict.assert_not_called() + + +def test_django_get_signer_verdict_delegates_to_client() -> None: + from agentscore_commerce.identity.django import get_signer_verdict + from agentscore_commerce.identity.types import SignerVerdict + + sentinel = SignerVerdict(signer_match=None, signer_sanctions=None) + fake_client = MagicMock() + fake_client.get_signer_verdict.return_value = sentinel + request = MagicMock() + request._agentscore_gate = {"client": fake_client, "wallet_address": WALLET} + verdict = get_signer_verdict(request) + assert verdict is sentinel + fake_client.get_signer_verdict.assert_called_once_with(WALLET) + + +# --------------------------------------------------------------------------- +# aiohttp +# --------------------------------------------------------------------------- + + +def test_aiohttp_get_signer_verdict_returns_none_for_operator_token_only() -> None: + from agentscore_commerce.identity.aiohttp import GATE_STATE_KEY, get_signer_verdict + + fake_client = MagicMock() + request = MagicMock() + request.get.side_effect = lambda key: ( + {"client": fake_client, "wallet_address": None} if key == GATE_STATE_KEY else None + ) + assert get_signer_verdict(request) is None + fake_client.get_signer_verdict.assert_not_called() + + +def test_aiohttp_get_signer_verdict_delegates_to_client() -> None: + from agentscore_commerce.identity.aiohttp import GATE_STATE_KEY, get_signer_verdict + from agentscore_commerce.identity.types import SignerVerdict + + sentinel = SignerVerdict(signer_match=None, signer_sanctions=None) + fake_client = MagicMock() + fake_client.get_signer_verdict.return_value = sentinel + request = MagicMock() + request.get.side_effect = lambda key: ( + {"client": fake_client, "wallet_address": WALLET} if key == GATE_STATE_KEY else None + ) + verdict = get_signer_verdict(request) + assert verdict is sentinel + fake_client.get_signer_verdict.assert_called_once_with(WALLET) + + +# --------------------------------------------------------------------------- +# Sanic +# --------------------------------------------------------------------------- + + +def test_sanic_get_signer_verdict_returns_none_for_operator_token_only() -> None: + from agentscore_commerce.identity.sanic import GATE_STATE_ATTR, get_signer_verdict + + fake_client = MagicMock() + request = MagicMock() + setattr(request.ctx, GATE_STATE_ATTR, {"client": fake_client, "wallet_address": None}) + assert get_signer_verdict(request) is None + fake_client.get_signer_verdict.assert_not_called() + + +def test_sanic_get_signer_verdict_delegates_to_client() -> None: + from agentscore_commerce.identity.sanic import GATE_STATE_ATTR, get_signer_verdict + from agentscore_commerce.identity.types import SignerVerdict + + sentinel = SignerVerdict(signer_match=None, signer_sanctions=None) + fake_client = MagicMock() + fake_client.get_signer_verdict.return_value = sentinel + request = MagicMock() + setattr(request.ctx, GATE_STATE_ATTR, {"client": fake_client, "wallet_address": WALLET}) + verdict = get_signer_verdict(request) + assert verdict is sentinel + fake_client.get_signer_verdict.assert_called_once_with(WALLET) + + +# --------------------------------------------------------------------------- +# GateClient.get_signer_verdict — projection branches +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("kind", "expected_kind"), + [ + ("pass", "pass"), + ("wallet_auth_requires_wallet_signing", "wallet_auth_requires_wallet_signing"), + ], +) +def test_client_get_signer_verdict_projects_each_kind(kind: str, expected_kind: str) -> None: + """Cover the branches in _project_signer_match (pass + wallet_auth_requires_wallet_signing).""" + from unittest.mock import patch + + from agentscore_commerce.identity.client import GateClient + + client = GateClient(api_key="test-api-key") + + def fake_post(*_args: object, **_kwargs: object) -> MagicMock: + resp = MagicMock() + resp.is_success = True + resp.status_code = 200 + resp.json = lambda: { + "decision": "allow", + "decision_reasons": [], + "resolved_operator": "op_x", + "signer_match": { + "kind": kind, + "claimed_operator": "op_x", + "signer_operator": "op_x", + "claimed_wallet": WALLET, + }, + } + return resp + + with patch.object(client._sync_client, "post", side_effect=fake_post): + client.check(address=WALLET, signer={"address": WALLET, "network": "evm"}) + + verdict = client.get_signer_verdict(WALLET) + assert verdict is not None + signer_match = verdict.signer_match + assert signer_match is not None + assert signer_match.kind == expected_kind diff --git a/tests/test_payment_signer.py b/tests/test_payment_signer.py index 87a33f9..a9cbf70 100644 --- a/tests/test_payment_signer.py +++ b/tests/test_payment_signer.py @@ -6,7 +6,6 @@ from agentscore_commerce.payment.signer import ( PaymentSigner, extract_payment_signer, - extract_payment_signer_address, read_x402_payment_header, ) @@ -53,19 +52,6 @@ def test_returns_none_when_from_is_not_an_evm_address(self): assert extract_payment_signer(header) is None -class TestExtractPaymentSignerAddress: - def test_returns_address_only(self): - header = _encode_x402( - {"accepted": {"network": "eip155:8453"}, "payload": {"authorization": {"from": EVM_MIXED}}} - ) - assert extract_payment_signer_address(header) == EVM_LOWER - - def test_returns_none_when_signer_unrecoverable(self): - assert extract_payment_signer_address(None) is None - assert extract_payment_signer_address("") is None - assert extract_payment_signer_address("!!!") is None - - class TestReadX402PaymentHeader: def test_prefers_payment_signature(self): assert read_x402_payment_header({"payment-signature": "ps_value", "x-payment": "xp_value"}) == "ps_value" diff --git a/tests/test_signer_match.py b/tests/test_signer_match.py index 6d6e1cd..9095a46 100644 --- a/tests/test_signer_match.py +++ b/tests/test_signer_match.py @@ -1,4 +1,10 @@ -"""Tests for the signer-match helpers and agent_memory hint.""" +"""Tests for signer-verdict surface, agent_memory hint, and 401 token_expired path. + +The verify_wallet_signer_match 2-call path was retired in favor of the gate-pre-extract +architecture: adapters extract the payment signer up front, pass it to /v1/assess via +the SDK's ``signer`` kwarg, and the API returns ``signer_match`` + ``signer_sanctions`` +on the same response. Merchants read the verdicts back via ``get_signer_verdict``. +""" from __future__ import annotations @@ -6,13 +12,11 @@ import json from unittest.mock import MagicMock, patch -import httpx import pytest from agentscore_commerce.identity import ( AgentMemoryHint, GateClient, - VerifyWalletSignerMatchOptions, build_agent_memory_hint, extract_x402_signer, ) @@ -79,53 +83,32 @@ def test_extract_x402_signer_rejects_non_evm() -> None: # --------------------------------------------------------------------------- -# GateClient.verify_wallet_signer_match +# GateClient.check passes signer through; client.get_signer_verdict reads it back # --------------------------------------------------------------------------- -def test_verify_wallet_signer_match_byte_equal_pass() -> None: - client = GateClient(api_key=API_KEY) - result = client.verify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_A), - ) - assert result.kind == "pass" - - -def test_verify_wallet_signer_match_requires_signing_on_null_signer() -> None: - client = GateClient(api_key=API_KEY) - result = client.verify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=None), - ) - assert result.kind == "wallet_auth_requires_wallet_signing" - assert result.claimed_wallet == WALLET_A - assert result.agent_instructions is not None - assert "switch_to_operator_token" in result.agent_instructions - # user_message lives inside agent_instructions (single source of truth). - instr = json.loads(result.agent_instructions) - assert "wallet-signing rails" in instr["user_message"] or "Wallet-address identity" in instr["user_message"] - - -def test_verify_wallet_signer_match_same_operator_pass() -> None: +def test_check_forwards_signer_to_assess_body() -> None: + """Adapter pre-extracts the signer; client.check threads it onto the request body.""" client = GateClient(api_key=API_KEY) + captured: dict[str, object] = {} - def fake_post(*_args: object, **_kwargs: object) -> MagicMock: + def fake_post(*_args: object, **kwargs: object) -> MagicMock: + if "json" in kwargs and isinstance(kwargs["json"], dict): + captured.update(kwargs["json"]) resp = MagicMock() resp.is_success = True resp.status_code = 200 - resp.json = lambda: {"resolved_operator": "op_shared", "decision": "allow"} + resp.json = lambda: {"decision": "allow", "decision_reasons": [], "resolved_operator": "op_x"} return resp with patch.object(client._sync_client, "post", side_effect=fake_post): - result = client.verify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), - ) - assert result.kind == "pass" - assert result.claimed_operator == "op_shared" - assert result.signer_operator == "op_shared" + client.check(address=WALLET_A, signer={"address": WALLET_B, "network": "evm"}) + assert captured.get("signer") == {"address": WALLET_B, "network": "evm"} -def test_verify_wallet_signer_match_different_operator_rejects() -> None: - """New 1-call path: API returns signer_match in the assess response.""" + +def test_get_signer_verdict_projects_cached_signer_match() -> None: + """After a check() with signer, get_signer_verdict reads signer_match + signer_sanctions.""" client = GateClient(api_key=API_KEY) def fake_post(*_args: object, **_kwargs: object) -> MagicMock: @@ -143,494 +126,46 @@ def fake_post(*_args: object, **_kwargs: object) -> MagicMock: "expected_signer": WALLET_A.lower(), "actual_signer": WALLET_B.lower(), "linked_wallets": [WALLET_A.lower()], - "agent_instructions": json.dumps( - { - "action": "resign_or_switch_to_operator_token", - "steps": ["re-sign with linked_wallets"], - "user_message": "Different operator detected", - } - ), }, + "signer_sanctions": {"kind": "clear"}, } return resp with patch.object(client._sync_client, "post", side_effect=fake_post): - result = client.verify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), - ) - assert result.kind == "wallet_signer_mismatch" - assert result.claimed_operator == "op_claimed" - assert result.actual_signer_operator == "op_attacker" - assert result.expected_signer == WALLET_A.lower() - assert result.actual_signer == WALLET_B.lower() - assert result.agent_instructions is not None - assert "resign_or_switch_to_operator_token" in result.agent_instructions - instr = json.loads(result.agent_instructions) - assert "operator" in instr["user_message"] - - -def test_verify_wallet_signer_match_transient_error_emits_api_error() -> None: - """Sec2: transient /v1/assess failures must NOT be conflated with wallet_signer_mismatch.""" - client = GateClient(api_key=API_KEY) + client.check(address=WALLET_A, signer={"address": WALLET_B, "network": "evm"}) - def fake_post(*_args: object, **_kwargs: object) -> MagicMock: - resp = MagicMock() - resp.is_success = False - resp.status_code = 503 - return resp - - with patch.object(client._sync_client, "post", side_effect=fake_post): - result = client.verify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), - ) - assert result.kind == "api_error" - assert result.claimed_wallet == WALLET_A.lower() - - -@pytest.mark.asyncio -async def test_averify_wallet_signer_match_transient_error_emits_api_error() -> None: - client = GateClient(api_key=API_KEY) - from unittest.mock import AsyncMock - - async def fake_apost(*_args: object, **_kwargs: object) -> MagicMock: - resp = MagicMock() - # SDK gates response handling on status_code >= 400 — set 503 so the SDK - # raises AgentScoreError, which commerce maps to api_error. - resp.status_code = 503 - resp.is_success = False - return resp - - client._async_client.post = AsyncMock(side_effect=fake_apost) - result = await client.averify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), - ) - assert result.kind == "api_error" + verdict = client.get_signer_verdict(WALLET_A) + assert verdict is not None + signer_match = verdict.signer_match + assert signer_match is not None + assert signer_match.kind == "wallet_signer_mismatch" + assert signer_match.expected_signer == WALLET_A.lower() + assert signer_match.actual_signer == WALLET_B.lower() + assert signer_match.linked_wallets == [WALLET_A.lower()] + assert verdict.signer_sanctions == {"kind": "clear"} -def test_verify_wallet_signer_match_unlinked_signer_rejects() -> None: - """New 1-call path: API returns signer_match with signer_operator=None for unlinked wallet.""" +def test_get_signer_verdict_returns_none_when_no_signer_blocks() -> None: + """Operator-token-only paths leave signer_match + signer_sanctions absent.""" client = GateClient(api_key=API_KEY) def fake_post(*_args: object, **_kwargs: object) -> MagicMock: resp = MagicMock() resp.is_success = True resp.status_code = 200 - resp.json = lambda: { - "decision": "allow", - "decision_reasons": [], - "resolved_operator": "op_claimed", - "signer_match": { - "kind": "wallet_signer_mismatch", - "claimed_operator": "op_claimed", - "signer_operator": None, - "expected_signer": WALLET_A.lower(), - "actual_signer": WALLET_B.lower(), - }, - } + resp.json = lambda: {"decision": "allow", "decision_reasons": [], "resolved_operator": "op_x"} return resp with patch.object(client._sync_client, "post", side_effect=fake_post): - result = client.verify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), - ) - assert result.kind == "wallet_signer_mismatch" - assert result.actual_signer_operator is None - - -@pytest.mark.asyncio -async def test_averify_wallet_signer_match_byte_equal_pass() -> None: - client = GateClient(api_key=API_KEY) - result = await client.averify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_A), - ) - assert result.kind == "pass" - - -@pytest.mark.asyncio -async def test_averify_wallet_signer_match_linked_wallets_threaded_through() -> None: - """Async path surfaces linked_wallets from the server-side signer_match response.""" - from unittest.mock import AsyncMock - - client = GateClient(api_key=API_KEY) - extra_wallet = "0xcccc000000000000000000000000000000000000" - - async def fake_apost(*_args: object, **_kwargs: object) -> MagicMock: - resp = MagicMock() - resp.is_success = True - resp.status_code = 200 - resp.json = MagicMock( - return_value={ - "decision": "allow", - "decision_reasons": [], - "resolved_operator": "op_claimed", - "signer_match": { - "kind": "wallet_signer_mismatch", - "claimed_operator": "op_claimed", - "signer_operator": "op_signer", - "expected_signer": WALLET_A.lower(), - "actual_signer": WALLET_B.lower(), - "linked_wallets": [WALLET_A.lower(), extra_wallet], - }, - } - ) - return resp - - client._async_client.post = AsyncMock(side_effect=fake_apost) - result = await client.averify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), - ) - assert result.kind == "wallet_signer_mismatch" - assert result.linked_wallets == [WALLET_A.lower(), extra_wallet] - - -@pytest.mark.asyncio -async def test_averify_wallet_signer_match_requires_signing_on_null_signer() -> None: - client = GateClient(api_key=API_KEY) - result = await client.averify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=None), - ) - assert result.kind == "wallet_auth_requires_wallet_signing" - - -# --------------------------------------------------------------------------- -# Adapter wrapper tests — operator-token wins and the signer check no-ops when -# BOTH headers are sent. Also exercises agent_memory body serialization. -# --------------------------------------------------------------------------- - - -class _FakeState(dict): - """Minimal state dict shape the adapter wrappers read.""" - - -@pytest.mark.asyncio -async def test_asgi_verify_wallet_signer_match_no_op_on_operator_token_path() -> None: - """ASGI wrapper returns pass without calling client when request was operator-token authenticated.""" - from unittest.mock import AsyncMock - - from agentscore_commerce.identity.middleware import GATE_STATE_KEY, verify_wallet_signer_match - - fake_client = AsyncMock() - request = MagicMock() - request.scope = { - "state": {GATE_STATE_KEY: {"client": fake_client, "operator_token": "opc_test", "wallet_address": None}}, - } - - result = await verify_wallet_signer_match(request, signer="0xabc") - - assert result.kind == "pass" - fake_client.averify_wallet_signer_match.assert_not_called() - - -@pytest.mark.asyncio -async def test_asgi_verify_wallet_signer_match_no_op_when_both_headers_sent() -> None: - """Section IV: token wins when both headers sent — signer check must no-op.""" - from unittest.mock import AsyncMock - - from agentscore_commerce.identity.middleware import GATE_STATE_KEY, verify_wallet_signer_match - - fake_client = AsyncMock() - request = MagicMock() - request.scope = { - "state": { - GATE_STATE_KEY: { - "client": fake_client, - "operator_token": "opc_test", - "wallet_address": WALLET_A, - }, - }, - } - - result = await verify_wallet_signer_match(request, signer=WALLET_B) - - assert result.kind == "pass" - assert result.claimed_operator is None - fake_client.averify_wallet_signer_match.assert_not_called() - - -@pytest.mark.asyncio -async def test_asgi_verify_wallet_signer_match_invokes_client_on_wallet_auth() -> None: - """Strict wallet-auth path — helper calls client and returns its result.""" - from unittest.mock import AsyncMock - - from agentscore_commerce.identity.middleware import GATE_STATE_KEY, verify_wallet_signer_match - from agentscore_commerce.identity.types import VerifyWalletSignerResult - - fake_client = AsyncMock() - fake_client.averify_wallet_signer_match.return_value = VerifyWalletSignerResult( - kind="wallet_signer_mismatch", - claimed_operator="op_claimed", - actual_signer_operator="op_signer", - ) - request = MagicMock() - request.scope = { - "state": { - GATE_STATE_KEY: { - "client": fake_client, - "operator_token": None, - "wallet_address": WALLET_A, - }, - }, - } - - result = await verify_wallet_signer_match(request, signer=WALLET_B) - - assert result.kind == "wallet_signer_mismatch" - fake_client.averify_wallet_signer_match.assert_called_once() - - -def test_denial_reason_to_body_includes_agent_memory() -> None: - """The shared serializer marshals agent_memory into the body dict.""" - from agentscore_commerce.identity._response import denial_reason_to_body - from agentscore_commerce.identity.types import DenialReason, build_agent_memory_hint - - reason = DenialReason( - code="missing_identity", - agent_memory=build_agent_memory_hint(), - ) - body = denial_reason_to_body(reason) - - assert body["error"]["code"] == "missing_identity" - assert "agent_memory" in body - assert body["agent_memory"]["save_for_future_agentscore_gates"] is True - assert "identity_paths" in body["agent_memory"] - - -def test_denial_reason_to_body_includes_wallet_signer_mismatch_fields() -> None: - """The shared serializer marshals wallet-signer-match fields into the body.""" - from agentscore_commerce.identity._response import denial_reason_to_body - from agentscore_commerce.identity.types import DenialReason - - reason = DenialReason( - code="wallet_signer_mismatch", - claimed_operator="op_claimed", - actual_signer_operator="op_signer", - expected_signer=WALLET_A.lower(), - actual_signer=WALLET_B.lower(), - linked_wallets=[WALLET_A.lower()], - ) - body = denial_reason_to_body(reason) - - assert body["error"]["code"] == "wallet_signer_mismatch" - assert body["claimed_operator"] == "op_claimed" - assert body["actual_signer_operator"] == "op_signer" - assert body["expected_signer"] == WALLET_A.lower() - assert body["actual_signer"] == WALLET_B.lower() - assert body["linked_wallets"] == [WALLET_A.lower()] - - -def test_build_missing_identity_reason_attaches_memory_hint() -> None: - """The missing_identity builder attaches an agent_memory hint by default.""" - from agentscore_commerce.identity._response import build_missing_identity_reason - - reason = build_missing_identity_reason() - assert reason.code == "missing_identity" - assert reason.agent_memory is not None - assert reason.agent_memory.save_for_future_agentscore_gates is True - - -def test_build_missing_identity_reason_hints_probe_strategy() -> None: - """Bootstrap denial carries agent_instructions that describe the full probe strategy — - wallet-first on signing rails, fall back to stored opc_..., fall back to session flow.""" - from agentscore_commerce.identity._response import build_missing_identity_reason, denial_reason_to_body - - reason = build_missing_identity_reason() - assert reason.agent_instructions is not None - - instructions = json.loads(reason.agent_instructions) - assert instructions["action"] == "probe_identity_then_session" - assert isinstance(instructions["steps"], list) - assert len(instructions["steps"]) >= 3 - assert "X-Operator-Token" in instructions["user_message"] or "X-Wallet-Address" in instructions["user_message"] - - # Shows up in the serialized body. - body = denial_reason_to_body(reason) - body_instructions = json.loads(body["agent_instructions"]) - assert body_instructions["action"] == "probe_identity_then_session" - - -# --------------------------------------------------------------------------- -# Adapter parity — operator-token wins when both headers sent. Each adapter reads -# gate state from a framework-specific location, so each needs its own no-op test. -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_fastapi_verify_wallet_signer_match_no_op_when_both_headers_sent() -> None: - from unittest.mock import AsyncMock - - from agentscore_commerce.identity.fastapi import GATE_STATE_KEY, verify_wallet_signer_match - - fake_client = AsyncMock() - request = MagicMock() - setattr( - request.state, - GATE_STATE_KEY, - { - "client": fake_client, - "operator_token": "opc_test", - "wallet_address": WALLET_A, - }, - ) - - result = await verify_wallet_signer_match(request, signer=WALLET_B) - - assert result.kind == "pass" - fake_client.averify_wallet_signer_match.assert_not_called() + client.check(address=WALLET_A) + assert client.get_signer_verdict(WALLET_A) is None -def test_flask_verify_wallet_signer_match_no_op_when_both_headers_sent() -> None: - from flask import Flask - from agentscore_commerce.identity.flask import verify_wallet_signer_match - - fake_client = MagicMock() - app = Flask(__name__) - with app.test_request_context("/"): - from flask import g - - g._agentscore_gate = { # type: ignore[attr-defined] - "client": fake_client, - "operator_token": "opc_test", - "wallet_address": WALLET_A, - } - result = verify_wallet_signer_match(signer=WALLET_B) - - assert result.kind == "pass" - fake_client.verify_wallet_signer_match.assert_not_called() - - -def test_django_verify_wallet_signer_match_no_op_when_both_headers_sent() -> None: - from agentscore_commerce.identity.django import verify_wallet_signer_match - - fake_client = MagicMock() - request = MagicMock() - request._agentscore_gate = { - "client": fake_client, - "operator_token": "opc_test", - "wallet_address": WALLET_A, - } - - result = verify_wallet_signer_match(request, signer=WALLET_B) - - assert result.kind == "pass" - fake_client.verify_wallet_signer_match.assert_not_called() - - -@pytest.mark.asyncio -async def test_aiohttp_verify_wallet_signer_match_no_op_when_both_headers_sent() -> None: - from unittest.mock import AsyncMock - - from agentscore_commerce.identity.aiohttp import GATE_STATE_KEY, verify_wallet_signer_match - - fake_client = AsyncMock() - request: dict[str, object] = { - GATE_STATE_KEY: { - "client": fake_client, - "operator_token": "opc_test", - "wallet_address": WALLET_A, - }, - } - - result = await verify_wallet_signer_match(request, signer=WALLET_B) # type: ignore[arg-type] - - assert result.kind == "pass" - fake_client.averify_wallet_signer_match.assert_not_called() - - -@pytest.mark.asyncio -async def test_sanic_verify_wallet_signer_match_no_op_when_both_headers_sent() -> None: - from unittest.mock import AsyncMock - - from agentscore_commerce.identity.sanic import GATE_STATE_ATTR, verify_wallet_signer_match - - fake_client = AsyncMock() - request = MagicMock() - setattr( - request.ctx, - GATE_STATE_ATTR, - { - "client": fake_client, - "operator_token": "opc_test", - "wallet_address": WALLET_A, - }, - ) - - result = await verify_wallet_signer_match(request, signer=WALLET_B) - - assert result.kind == "pass" - fake_client.averify_wallet_signer_match.assert_not_called() - - -# --------------------------------------------------------------------------- -# Sync path linked_wallets threading — mirror of the async test above. -# --------------------------------------------------------------------------- - - -def test_verify_wallet_signer_match_linked_wallets_threaded_through_sync() -> None: - """Sync path surfaces linked_wallets from the server-side signer_match response.""" +def test_get_signer_verdict_returns_none_when_address_not_cached() -> None: + """No assess call yet → no cache entry → no verdict.""" client = GateClient(api_key=API_KEY) - extra_wallet = "0xcccc000000000000000000000000000000000000" - - def fake_post(*_args: object, **_kwargs: object) -> MagicMock: - resp = MagicMock() - resp.status_code = 200 - resp.is_success = True - resp.json.return_value = { - "decision": "allow", - "decision_reasons": [], - "resolved_operator": "op_claimed", - "signer_match": { - "kind": "wallet_signer_mismatch", - "claimed_operator": "op_claimed", - "signer_operator": "op_signer", - "expected_signer": WALLET_A.lower(), - "actual_signer": WALLET_B.lower(), - "linked_wallets": [WALLET_A.lower(), extra_wallet], - }, - } - return resp - - with patch.object(client._sync_client, "post", side_effect=fake_post): - result = client.verify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), - ) - - assert result.kind == "wallet_signer_mismatch" - assert result.linked_wallets == [WALLET_A.lower(), extra_wallet] - - -# --------------------------------------------------------------------------- -# agent_memory gating — present on bootstrap denials, absent on everything else. -# --------------------------------------------------------------------------- - - -def test_denial_reason_to_body_omits_agent_memory_on_non_bootstrap_denial() -> None: - """wallet_signer_mismatch is post-identity — body must NOT carry an agent_memory hint.""" - from agentscore_commerce.identity._response import denial_reason_to_body - from agentscore_commerce.identity.types import DenialReason - - reason = DenialReason( - code="wallet_signer_mismatch", - claimed_operator="op_claimed", - actual_signer_operator="op_signer", - expected_signer=WALLET_A.lower(), - actual_signer=WALLET_B.lower(), - linked_wallets=[WALLET_A.lower()], - ) - body = denial_reason_to_body(reason) - - assert body["error"]["code"] == "wallet_signer_mismatch" - assert "agent_memory" not in body - - -def test_denial_reason_to_body_omits_agent_memory_on_wallet_not_trusted() -> None: - """wallet_not_trusted is also post-identity; no agent_memory hint in the body.""" - from agentscore_commerce.identity._response import denial_reason_to_body - from agentscore_commerce.identity.types import DenialReason - - body = denial_reason_to_body(DenialReason(code="wallet_not_trusted")) - assert body["error"]["code"] == "wallet_not_trusted" - assert "agent_memory" not in body + assert client.get_signer_verdict(WALLET_A) is None # --------------------------------------------------------------------------- @@ -667,8 +202,6 @@ def test_check_raises_token_denied_on_401_expired() -> None: def test_check_raises_token_denied_on_401_revoked() -> None: - # Revoked credentials surface to the client as the same token_expired code — the API - # deliberately doesn't disclose which case (revoked vs TTL-expired) to the gate. from agentscore_commerce.identity.client import TokenDeniedError client = GateClient(api_key=API_KEY) @@ -745,413 +278,106 @@ def _homepage(_request: object) -> JSONResponse: assert res.status_code == 401 body = res.json() assert body["error"]["code"] == "token_expired" - # agent_instructions is a JSON string of next_steps assert json.loads(body["agent_instructions"]) == {"action": "deliver_verify_url_and_poll"} # --------------------------------------------------------------------------- -# Signer-match telemetry — fire-and-forget POST to /v1/telemetry/signer-match +# denial_reason_to_body — agent_memory + wallet-signer-match field marshalling # --------------------------------------------------------------------------- -def test_verify_wallet_signer_match_posts_pass_telemetry() -> None: - client = GateClient(api_key=API_KEY) - telemetry_calls: list[str] = [] - - def capture(url: str, **kwargs: object) -> MagicMock: - if "/v1/telemetry/signer-match" in url: - # SDK uses httpx's `json=` kwarg (auto-serializes dict). Older tests - # patched a raw httpx call that took `content=`; both shapes - # carry the same payload — try `json` first, fall back to `content`. - payload = kwargs.get("json") - if payload is None and "content" in kwargs: - payload = json.loads(kwargs["content"]) # type: ignore[arg-type] - telemetry_calls.append(payload["kind"]) # type: ignore[index] - resp = MagicMock() - resp.is_success = True - resp.status_code = 201 - resp.json.return_value = {} - return resp - - with patch.object(client._sync_client, "post", side_effect=capture): - result = client.verify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_A), - ) - - assert result.kind == "pass" - assert telemetry_calls == ["pass"] - - -def test_verify_wallet_signer_match_posts_requires_signing_telemetry() -> None: - client = GateClient(api_key=API_KEY) - telemetry_calls: list[str] = [] - - def capture(url: str, **kwargs: object) -> MagicMock: - if "/v1/telemetry/signer-match" in url: - # SDK uses httpx's `json=` kwarg (auto-serializes dict). Older tests - # patched a raw httpx call that took `content=`; both shapes - # carry the same payload — try `json` first, fall back to `content`. - payload = kwargs.get("json") - if payload is None and "content" in kwargs: - payload = json.loads(kwargs["content"]) # type: ignore[arg-type] - telemetry_calls.append(payload["kind"]) # type: ignore[index] - resp = MagicMock() - resp.is_success = True - resp.status_code = 201 - resp.json.return_value = {} - return resp - - with patch.object(client._sync_client, "post", side_effect=capture): - client.verify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=None), - ) - - assert telemetry_calls == ["wallet_auth_requires_wallet_signing"] - - -def test_verify_wallet_signer_match_telemetry_failure_does_not_raise() -> None: - """Gate decision must not depend on telemetry availability.""" - client = GateClient(api_key=API_KEY) - - def raiser(*_args: object, **_kwargs: object) -> MagicMock: - raise httpx.HTTPError("telemetry outage") - - with patch.object(client._sync_client, "post", side_effect=raiser): - # byte-equal short-circuit returns pass; telemetry failure is swallowed. - result = client.verify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_A), - ) - assert result.kind == "pass" - - -@pytest.mark.asyncio -async def test_averify_wallet_signer_match_telemetry_failure_does_not_raise() -> None: - """Async path mirror — telemetry outage must not break gate decisions.""" - client = GateClient(api_key=API_KEY) - - async def araiser(*_args: object, **_kwargs: object) -> MagicMock: - raise httpx.HTTPError("telemetry outage") - - from unittest.mock import AsyncMock - - client._async_client.post = AsyncMock(side_effect=araiser) - result = await client.averify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_A), - ) - assert result.kind == "pass" - - -@pytest.mark.asyncio -async def test_averify_wallet_signer_match_pass_via_shared_operator() -> None: - """Different signer wallet but same operator → pass. - - Mirrors the sync ``test_verify_wallet_signer_match_pass_via_shared_operator`` - branch — pinned independently because the async path's signer_match decode + - telemetry chain is wired separately and a regression in either wouldn't show - up via the sync test. - """ - client = GateClient(api_key=API_KEY) - - async def fake_apost(*_args: object, **_kwargs: object) -> MagicMock: - resp = MagicMock() - resp.status_code = 200 - resp.is_success = True - resp.json.return_value = { - "decision": "allow", - "decision_reasons": [], - "resolved_operator": "op_shared", - "signer_match": { - "kind": "pass", - "claimed_operator": "op_shared", - "signer_operator": "op_shared", - }, - } - return resp - - from unittest.mock import AsyncMock +def test_denial_reason_to_body_includes_agent_memory() -> None: + """The shared serializer marshals agent_memory into the body dict.""" + from agentscore_commerce.identity._response import denial_reason_to_body + from agentscore_commerce.identity.types import DenialReason, build_agent_memory_hint - client._async_client.post = AsyncMock(side_effect=fake_apost) - result = await client.averify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), + reason = DenialReason( + code="missing_identity", + agent_memory=build_agent_memory_hint(), ) - assert result.kind == "pass" - assert result.claimed_operator == "op_shared" - assert result.signer_operator == "op_shared" - - -def test_verify_wallet_signer_match_cache_hit_skips_assess() -> None: - """Repeat (claimed, signer) lookup hits the signer_match cache — no fresh assess.""" - client = GateClient(api_key=API_KEY) - call_count = 0 - - def fake_post(*_args: object, **_kwargs: object) -> MagicMock: - nonlocal call_count - call_count += 1 - resp = MagicMock() - resp.is_success = True - resp.status_code = 200 - resp.json = lambda: { - "decision": "allow", - "decision_reasons": [], - "resolved_operator": "op_shared", - "signer_match": {"kind": "pass", "claimed_operator": "op_shared", "signer_operator": "op_shared"}, - } - return resp - - opts = VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B) - with patch.object(client._sync_client, "post", side_effect=fake_post): - client.verify_wallet_signer_match(opts) - first = call_count - client.verify_wallet_signer_match(opts) - assert call_count == first # second run reads cached signer_match - - -def test_verify_wallet_signer_match_legacy_fallback_when_signer_match_absent() -> None: - """Old API responses lack signer_match — commerce falls back to the 2-resolve path.""" - client = GateClient(api_key=API_KEY) - call_index = 0 - - def fake_post(*_args: object, **_kwargs: object) -> MagicMock: - nonlocal call_index - call_index += 1 - resp = MagicMock() - resp.is_success = True - resp.status_code = 200 - bodies = [ - {"decision": "allow", "decision_reasons": [], "resolved_operator": "op_claimed"}, - { - "decision": "allow", - "decision_reasons": [], - "resolved_operator": "op_claimed", - "linked_wallets": [WALLET_A.lower()], - }, - {"decision": "allow", "decision_reasons": [], "resolved_operator": "op_signer"}, - ] - resp.json = lambda i=call_index - 1: bodies[i] if i < len(bodies) else {} - return resp - - with patch.object(client._sync_client, "post", side_effect=fake_post): - result = client.verify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), - ) - assert result.kind == "wallet_signer_mismatch" - assert result.claimed_operator == "op_claimed" - assert result.actual_signer_operator == "op_signer" - - -def test_verify_wallet_signer_match_assess_failure_returns_api_error() -> None: - """SDK raising AgentScoreError on the resolve_signer-aware assess → api_error.""" - from agentscore import AgentScoreError as SdkErr - - client = GateClient(api_key=API_KEY) - - def raise_err(*_args: object, **_kwargs: object) -> object: - raise SdkErr("network_error", "DNS failure", 0) - - with patch.object(client._sdk, "assess", side_effect=raise_err): - result = client.verify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), - ) - assert result.kind == "api_error" - assert result.claimed_wallet == WALLET_A.lower() - - -@pytest.mark.asyncio -async def test_averify_wallet_signer_match_assess_failure_returns_api_error() -> None: - """Async mirror — SDK raising AgentScoreError on aassess → api_error.""" - from unittest.mock import AsyncMock + body = denial_reason_to_body(reason) - from agentscore import AgentScoreError as SdkErr + assert body["error"]["code"] == "missing_identity" + assert "agent_memory" in body + assert body["agent_memory"]["save_for_future_agentscore_gates"] is True + assert "identity_paths" in body["agent_memory"] - client = GateClient(api_key=API_KEY) - async def raise_err(*_args: object, **_kwargs: object) -> object: - raise SdkErr("network_error", "DNS failure", 0) +def test_denial_reason_to_body_includes_wallet_signer_mismatch_fields() -> None: + """The shared serializer marshals wallet-signer-match fields into the body.""" + from agentscore_commerce.identity._response import denial_reason_to_body + from agentscore_commerce.identity.types import DenialReason - client._sdk.aassess = AsyncMock(side_effect=raise_err) - result = await client.averify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), + reason = DenialReason( + code="wallet_signer_mismatch", + claimed_operator="op_claimed", + actual_signer_operator="op_signer", + expected_signer=WALLET_A.lower(), + actual_signer=WALLET_B.lower(), + linked_wallets=[WALLET_A.lower()], ) - assert result.kind == "api_error" - assert result.claimed_wallet == WALLET_A.lower() - - -@pytest.mark.asyncio -async def test_averify_wallet_signer_match_cache_hit_skips_assess() -> None: - """Async mirror of cache hit test.""" - from unittest.mock import AsyncMock - - client = GateClient(api_key=API_KEY) - call_count = 0 + body = denial_reason_to_body(reason) - async def fake_apost(*_args: object, **_kwargs: object) -> MagicMock: - nonlocal call_count - call_count += 1 - resp = MagicMock() - resp.is_success = True - resp.status_code = 200 - resp.json = MagicMock( - return_value={ - "decision": "allow", - "decision_reasons": [], - "resolved_operator": "op_shared", - "signer_match": {"kind": "pass", "claimed_operator": "op_shared", "signer_operator": "op_shared"}, - } - ) - return resp + assert body["error"]["code"] == "wallet_signer_mismatch" + assert body["claimed_operator"] == "op_claimed" + assert body["actual_signer_operator"] == "op_signer" + assert body["expected_signer"] == WALLET_A.lower() + assert body["actual_signer"] == WALLET_B.lower() + assert body["linked_wallets"] == [WALLET_A.lower()] - client._async_client.post = AsyncMock(side_effect=fake_apost) - opts = VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B) - await client.averify_wallet_signer_match(opts) - first = call_count - await client.averify_wallet_signer_match(opts) - assert call_count == first +def test_build_missing_identity_reason_attaches_memory_hint() -> None: + """The missing_identity builder attaches an agent_memory hint by default.""" + from agentscore_commerce.identity._response import build_missing_identity_reason -def test_verify_wallet_signer_match_solana_signer_normalizes_correctly() -> None: - """Solana base58 signers must NOT be lowercased — `_infer_signer_network` returns 'solana'.""" - client = GateClient(api_key=API_KEY) - solana_claimed = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy" - solana_signer = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM" + reason = build_missing_identity_reason() + assert reason.code == "missing_identity" + assert reason.agent_memory is not None + assert reason.agent_memory.save_for_future_agentscore_gates is True - captured_body: dict[str, object] = {} - def fake_post(*_args: object, **kwargs: object) -> MagicMock: - # SDK serializes the body via httpx `json=` kwarg. - if "json" in kwargs: - payload = kwargs["json"] - if isinstance(payload, dict): - captured_body.update(payload) - resp = MagicMock() - resp.is_success = True - resp.status_code = 200 - resp.json = lambda: { - "decision": "allow", - "decision_reasons": [], - "resolved_operator": "op_x", - "signer_match": {"kind": "pass", "claimed_operator": "op_x", "signer_operator": "op_x"}, - } - return resp +def test_build_missing_identity_reason_hints_probe_strategy() -> None: + """Bootstrap denial carries agent_instructions that describe the full probe strategy.""" + from agentscore_commerce.identity._response import build_missing_identity_reason, denial_reason_to_body - with patch.object(client._sync_client, "post", side_effect=fake_post): - result = client.verify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=solana_claimed, signer=solana_signer), - ) - assert result.kind == "pass" - # Solana detection: base58 (no 0x prefix) → network='solana' on the request body. - rs = captured_body.get("resolve_signer", {}) - if isinstance(rs, dict): - assert rs.get("network") == "solana" - - -def test_verify_wallet_signer_match_signer_match_with_wallet_auth_requires_signing_kind() -> None: - """API-side `wallet_auth_requires_wallet_signing` verdict (e.g. signer null on the API - side) projects onto VerifyWalletSignerResult of the same kind. Distinct from the - client-side null short-circuit because the API may emit this for other reasons. - """ - client = GateClient(api_key=API_KEY) + reason = build_missing_identity_reason() + assert reason.agent_instructions is not None - def fake_post(*_args: object, **_kwargs: object) -> MagicMock: - resp = MagicMock() - resp.is_success = True - resp.status_code = 200 - resp.json = lambda: { - "decision": "allow", - "decision_reasons": [], - "resolved_operator": "op_x", - "signer_match": { - "kind": "wallet_auth_requires_wallet_signing", - "claimed_wallet": WALLET_A.lower(), - "agent_instructions": json.dumps( - { - "action": "switch_to_operator_token", - "steps": [], - "user_message": "test", - } - ), - }, - } - return resp + instructions = json.loads(reason.agent_instructions) + assert instructions["action"] == "probe_identity_then_session" + assert isinstance(instructions["steps"], list) + assert len(instructions["steps"]) >= 3 + assert "X-Operator-Token" in instructions["user_message"] or "X-Wallet-Address" in instructions["user_message"] - with patch.object(client._sync_client, "post", side_effect=fake_post): - result = client.verify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), - ) - assert result.kind == "wallet_auth_requires_wallet_signing" - assert result.claimed_wallet == WALLET_A.lower() + body = denial_reason_to_body(reason) + body_instructions = json.loads(body["agent_instructions"]) + assert body_instructions["action"] == "probe_identity_then_session" -def test_verify_wallet_signer_match_legacy_fallback_uses_resolve_cache() -> None: - """Legacy fallback path reads the resolve: cache when a previous resolveWalletToOperator - call already populated it — saves the second /v1/assess on the legacy branch. - """ - from agentscore_commerce.identity.types import AssessResult +def test_denial_reason_to_body_omits_agent_memory_on_non_bootstrap_denial() -> None: + """wallet_signer_mismatch is post-identity — body must NOT carry an agent_memory hint.""" + from agentscore_commerce.identity._response import denial_reason_to_body + from agentscore_commerce.identity.types import DenialReason - client = GateClient(api_key=API_KEY) - # Pre-warm the resolve: cache for both wallets so the legacy fallback hits cache, - # not the network. Mirrors what would happen if a prior signer-match-disabled - # API response had already left these entries in cache from a different gate path. - client._cache.set( - f"resolve:{WALLET_A.lower()}", - AssessResult( - allow=True, raw={"resolved_operator": "op_shared", "linked_wallets": [WALLET_A.lower(), WALLET_B.lower()]} - ), - ) - client._cache.set( - f"resolve:{WALLET_B.lower()}", - AssessResult( - allow=True, raw={"resolved_operator": "op_shared", "linked_wallets": [WALLET_A.lower(), WALLET_B.lower()]} - ), + reason = DenialReason( + code="wallet_signer_mismatch", + claimed_operator="op_claimed", + actual_signer_operator="op_signer", + expected_signer=WALLET_A.lower(), + actual_signer=WALLET_B.lower(), + linked_wallets=[WALLET_A.lower()], ) + body = denial_reason_to_body(reason) - def fake_post(*_args: object, **_kwargs: object) -> MagicMock: - # Only the resolveSigner-aware assess hits the wire; both legacy resolves hit cache. - resp = MagicMock() - resp.is_success = True - resp.status_code = 200 - # No signer_match in body → falls through to legacy. Cache hits for both resolves. - resp.json = lambda: {"decision": "allow", "decision_reasons": [], "resolved_operator": "op_shared"} - return resp - - with patch.object(client._sync_client, "post", side_effect=fake_post): - result = client.verify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), - ) - assert result.kind == "pass" - assert result.claimed_operator == "op_shared" - - -@pytest.mark.asyncio -async def test_averify_wallet_signer_match_legacy_fallback_when_signer_match_absent() -> None: - """Async mirror of legacy fallback path.""" - from unittest.mock import AsyncMock + assert body["error"]["code"] == "wallet_signer_mismatch" + assert "agent_memory" not in body - client = GateClient(api_key=API_KEY) - call_index = 0 - async def fake_apost(*_args: object, **_kwargs: object) -> MagicMock: - nonlocal call_index - call_index += 1 - resp = MagicMock() - resp.is_success = True - resp.status_code = 200 - bodies = [ - {"decision": "allow", "decision_reasons": [], "resolved_operator": "op_claimed"}, - { - "decision": "allow", - "decision_reasons": [], - "resolved_operator": "op_claimed", - "linked_wallets": [WALLET_A.lower()], - }, - {"decision": "allow", "decision_reasons": [], "resolved_operator": "op_signer"}, - ] - resp.json = MagicMock(return_value=bodies[call_index - 1] if call_index <= len(bodies) else {}) - return resp +def test_denial_reason_to_body_omits_agent_memory_on_wallet_not_trusted() -> None: + """wallet_not_trusted is also post-identity; no agent_memory hint in the body.""" + from agentscore_commerce.identity._response import denial_reason_to_body + from agentscore_commerce.identity.types import DenialReason - client._async_client.post = AsyncMock(side_effect=fake_apost) - result = await client.averify_wallet_signer_match( - VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), - ) - assert result.kind == "wallet_signer_mismatch" - assert result.claimed_operator == "op_claimed" - assert result.actual_signer_operator == "op_signer" + body = denial_reason_to_body(DenialReason(code="wallet_not_trusted")) + assert body["error"]["code"] == "wallet_not_trusted" + assert "agent_memory" not in body diff --git a/uv.lock b/uv.lock index d4744f5..9500ff0 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ [[package]] name = "agentscore-commerce" -version = "1.7.0" +version = "1.8.0" source = { editable = "." } dependencies = [ { name = "agentscore-py" }, @@ -80,7 +80,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "agentscore-py", specifier = ">=2.1.0" }, + { name = "agentscore-py", specifier = ">=2.3.0" }, { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.8.0" }, { name = "cdp-sdk", marker = "extra == 'coinbase'", specifier = ">=1.0,<2" }, { name = "django", marker = "extra == 'django'", specifier = ">=4.0" }, @@ -122,14 +122,14 @@ dev = [ [[package]] name = "agentscore-py" -version = "2.1.2" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/82/65c94878455fca417374c04f9f35c9bbdcee0fe98d43cb1cde5d072b20f6/agentscore_py-2.1.2.tar.gz", hash = "sha256:8830765d89aaea102c092084d24a542c1eb7f83b2b29370eae31417d41c22a41", size = 58027, upload-time = "2026-05-11T00:45:40.84Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/87/5c372b863b3da2080d042e240731268247a4dbaec57c304b0ee5c5596b26/agentscore_py-2.3.0.tar.gz", hash = "sha256:66070088d857e7cd79806342de571b0eab269535fca6998e3820ca0f473d3070", size = 60397, upload-time = "2026-05-11T18:48:52.565Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/b3/e80ca6f484f198347ec48aa8a190511827b591679da309ee162d43e17856/agentscore_py-2.1.2-py3-none-any.whl", hash = "sha256:c7220cab0de7957e91632634c4502c562e2235548a37f367473548001ee3af38", size = 19087, upload-time = "2026-05-11T00:45:39.067Z" }, + { url = "https://files.pythonhosted.org/packages/98/db/7af9892295b56f005763a4a9e88f3f14dd0134a62bd0df17ce0681e20062/agentscore_py-2.3.0-py3-none-any.whl", hash = "sha256:1627c7903b4cab9bd398780c667815f3c633532eaa50ebbf785f6b222c4d9171", size = 20419, upload-time = "2026-05-11T18:48:51.076Z" }, ] [[package]] diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 9c9315c..afdc65f 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -16,4 +16,4 @@ # TYPE_CHECKING imports referenced inside string-literal cast() calls DecisionPolicy # noqa: F821 -ResolveSigner # noqa: F821 +Signer # noqa: F821