fix(x402): migrate to v2 SDK and close 4 payment-bypass bug classes#532
Conversation
Rewrites the x402 middleware on top of x402 SDK v2 and clears every
high-severity x402 entry in bugs/known-issues.md.
Bug classes closed (all catalogued in known-issues.md):
- x402-middleware-fails-open-on-body-parse: bare `except Exception` is
replaced with a narrow `except (JSONDecodeError, UnicodeDecodeError)`
that returns 402. Malformed bodies no longer flow through to the
agent.
- x402-no-replay-prevention: new `nonce_store.py` with a Redis SETNX
backend (production) and InMemoryNonceStore fallback. The middleware
claims `(network, asset, nonce)` BEFORE the facilitator round-trip,
so replays short-circuit without spending a verify call.
- x402-no-signature-verification: the manual `_validate_payment_manually`
method that never verified EIP-3009 signatures is gone. Verification
now goes through `x402ResourceServer.verify_payment(...)`, which
delegates to HTTPFacilitatorClient.verify; the facilitator runs full
EIP-712 typed-data recovery (mechanisms/evm/exact/facilitator.py).
Smoke against the real x402.org facilitator: a forged signature is
rejected with `invalid_exact_evm_signature` before the handler runs.
- x402-balance-check-skipped-on-missing-contract-code: the fall-through
branch is gone. `verify_payment` returns a structured `VerifyResponse`
with `is_valid=False` on missing contract code; the middleware
returns 402 instead of allowing the request.
API migration mechanically:
- x402.encoding → x402.http.utils
- x402.paywall.get_paywall_html → x402.http.paywall builder
(create_paywall().with_network(evm_paywall).build())
- x402.types.* → x402 top-level + x402.schemas.payments.ResourceInfo
- x402.common.process_price_to_atomic_amount →
ExactEvmServerScheme().parse_price()
- x402.common.find_matching_payment_requirements →
x402ResourceServer.find_matching_requirements()
- x402.facilitator.FacilitatorClient(config=...) →
x402.http.HTTPFacilitatorClient(FacilitatorConfig(url=...))
- PaymentRequirements.max_amount_required → .amount
- PaymentPayload.{scheme,network} → .get_scheme() / .get_network()
- PaywallConfig.cdp_client_key field removed in v2
Other cleanup driven by the migration:
- pyproject.toml: x402 ==0.2.1 → >=2.3.0,<3 (clears the SDK CVE).
- pyproject.toml: pydantic-settings added as a direct dep — was being
pulled in transitively by old fastapi extras of x402 0.x; v2 drops
fastapi from required deps, so the implicit transitive vanished.
- bindu/settings.py: removed dead `rpc_urls_by_network` dict. v0.2.1's
middleware made direct RPC calls for `balanceOf`; in v2 that's the
facilitator's job. Setting the value here is now a no-op.
- bindu/server/applications.py: friendly network names ("base-sepolia")
are translated to CAIP-2 ("eip155:84532") at the boundary. v2's
`parse_price` raises on non-CAIP-2 input; surfaced by the smoke
test before users would have hit it.
- tests/conftest_stubs.py: dropped setup_x402_stubs entirely. The
stubs faked v1 module paths (x402.types, x402.encoding, ...) that
shadowed the real install. With x402 as a hard runtime dep there's
no reason to stub it.
- bugs/known-issues.md: removed the 4 fixed entries; updated counters
(High: 4 → 0 for Bindu Core).
- bugs/core/2026-05-12-x402-v2-migration-hardening.md: new postmortem
describing all 4 bug classes, the architectural shape that produced
them, and the v2 resource-server-owned-by-facilitator pattern that
closes them.
New tests:
- tests/unit/server/middleware/x402/test_nonce_store.py: 10 tests for
key construction, replay rejection, TTL expiry, concurrency.
- tests/unit/server/middleware/x402/test_x402_middleware.py: 9 tests
exercising the full dispatch path with a mocked ResourceServer.
Verified: 969 unit + integration tests pass, ruff clean, ty
diagnostics 71 → 59 (net -12 for this PR). End-to-end smoke against
the real x402.org facilitator confirmed all four bug classes are
visibly closed at the HTTP layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis PR migrates Bindu's x402 payment middleware from SDK v1 to v2, adding explicit nonce replay prevention and rewriting payment dispatch to fail-closed. The new NonceStore system (with Redis and in-memory backends) deduplicates payment nonces before facilitator verification. Middleware now delegates all signature and balance checks to x402ResourceServer.verify_payment, removing prior manual Web3 validation code and four payment-bypass vulnerabilities. ChangesX402 v2 Migration & Payment Security Hardening
Sequence DiagramsequenceDiagram
participant Client
participant X402Middleware as Middleware
participant NonceStore
participant ResourceServer
participant Agent
Client->>X402Middleware: JSON-RPC + X-PAYMENT
X402Middleware->>X402Middleware: Parse body (fail-closed)
X402Middleware->>X402Middleware: Parse X-PAYMENT header
X402Middleware->>NonceStore: claim(network, asset, nonce)
alt Nonce claim succeeds
X402Middleware->>ResourceServer: verify_payment(payload)
alt is_valid=True
ResourceServer-->>X402Middleware: VerifyResponse
X402Middleware->>Agent: Forward request
Agent-->>Client: 200 + response
else is_valid=False
X402Middleware-->>Client: 402 PaymentRequired
end
else Replay or error
X402Middleware-->>Client: 402 PaymentRequired
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
tests/unit/server/workers/test_manifest_worker.py (1)
25-31:⚠️ Potential issue | 🔴 Critical | ⚡ Quick winFix type mismatch in test.
The test is passing
list[dict[str, str]]tobuild_message_history, but the method signature expectslist[Message]. This is causing type-checker failures in the CI pipeline.🐛 Proposed fix
from bindu.common.protocol.types import Task, TaskSendParams +from bindu.common.protocol.types import Message from bindu.server.workers.manifest_worker import ManifestWorker @@ -22,9 +23,15 @@ class TestManifestWorker: manifest=mock_manifest, scheduler=mock_scheduler, storage=mock_storage ) - messages = [ - {"role": "user", "content": "Hello"}, - {"role": "assistant", "content": "Hi there"}, + from uuid import uuid4 + task_id = uuid4() + context_id = uuid4() + + messages: list[Message] = [ + {"role": "user", "parts": [{"kind": "text", "text": "Hello"}], + "timestamp": "2024-01-01T00:00:00Z", "task_id": task_id, "context_id": context_id}, + {"role": "assistant", "parts": [{"kind": "text", "text": "Hi there"}], + "timestamp": "2024-01-01T00:00:01Z", "task_id": task_id, "context_id": context_id}, ] # The method delegates to MessageConverter.to_chat_format🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/unit/server/workers/test_manifest_worker.py` around lines 25 - 31, The test is passing plain dicts to worker.build_message_history which expects a list[Message]; change the test to construct Message instances (e.g., Message(role="user", content="Hello")) instead of dicts so the types align, ensuring imports or factory helpers for the Message class are added if needed; keep the assertion and the delegation comment about MessageConverter.to_chat_format the same.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@bindu/server/endpoints/payment_sessions.py`:
- Around line 146-149: The paywall resource currently points to app.manifest.url
which sends the user to "/" so the session never completes; update the
PaymentRequired.resource (the ResourceInfo url) to route to the payment capture
endpoint with the session_id query (i.e. the URL that triggers
payment_capture_endpoint with ?session_id={session_id}) so the browser
round-trips into payment_capture_endpoint() to complete the session (replace the
use of app.manifest.url?session_id=... with the proper /payment-capture URL or
the framework URL-helper for payment_capture_endpoint including session_id).
- Around line 113-118: The capture path is parsing payment_token directly
instead of base64-decoding it first, so update the logic in the
payment_status_endpoint (where payment_token is read and passed to
parse_payment_payload) to base64-decode the token string (the same way the
middleware does) before calling parse_payment_payload; ensure you decode the
token bytes (e.g., base64.b64decode) and then pass the resulting UTF-8 JSON
bytes to parse_payment_payload so the parsed value can be correctly recognized
as a PaymentPayload.
In `@bindu/server/middleware/x402/nonce_store.py`:
- Around line 47-53: The _key function currently calls asset.lower() and
nonce.lower() which will raise AttributeError if either is None or not a string;
add defensive validation at the start of _key (function name: _key) to assert
that network, asset, and nonce are non-empty strings (e.g., isinstance(..., str)
and asset and nonce) and raise a clear ValueError/TypeError with a descriptive
message if invalid, before performing .lower(); this ensures callers get an
explicit error rather than an AttributeError and prevents silent failures.
- Around line 130-139: The claim method currently lets exceptions from await
client.set(...) propagate, which can cause fail-open behavior; wrap the Redis
call in a try/except around the client = await self._get_client() /
client.set(...) sequence inside claim, catch any exception (e.g.,
connection/auth errors) and return False to reject the claim, and optionally log
the exception via the instance logger; ensure you still compute key with
_key(network, asset, nonce) and keep the original TTL handling so behavior is
unchanged when Redis succeeds.
In `@bindu/server/middleware/x402/x402_middleware.py`:
- Around line 163-173: The nonce is being claimed via
self._nonce_store.claim(payload.get_network(), requirement.asset, nonce, ttl)
before calling verify_payment(), but on exceptions the code returns a 402
without releasing the nonce; update the exception branches that log "x402: nonce
store error; rejecting payment" (and the other similar branch around the
verify_payment call) to call the corresponding release method (e.g.
self._nonce_store.release(payload.get_network(), requirement.asset, nonce)) on
exception-only paths before returning self._create_402_response("Payment
validation temporarily unavailable"), ensuring release is only attempted for
nonces that were successfully claimed and catching/logging any release errors
separately.
In `@tests/unit/server/middleware/x402/test_x402_middleware.py`:
- Around line 67-95: The test currently assigns sets to
app_settings.x402.protected_methods and accepts a set for the _build_app
parameter, but settings expects a list[str]; change the _build_app signature and
any calls to pass a list (protected_methods: list[str] | None) and set
app_settings.x402.protected_methods = protected_methods or ["message/send"] (and
update any other occurrences in this test file where protected_methods is
provided as a set to use list literals) so the test uses list types matching
bindu.settings.app_settings.x402.protected_methods.
---
Outside diff comments:
In `@tests/unit/server/workers/test_manifest_worker.py`:
- Around line 25-31: The test is passing plain dicts to
worker.build_message_history which expects a list[Message]; change the test to
construct Message instances (e.g., Message(role="user", content="Hello"))
instead of dicts so the types align, ensuring imports or factory helpers for the
Message class are added if needed; keep the assertion and the delegation comment
about MessageConverter.to_chat_format the same.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 0f9e4a66-1dc1-48a3-94c0-dc49e6d47b47
⛔ Files ignored due to path filters (1)
uv.lockis excluded by!**/*.lock
📒 Files selected for processing (15)
bindu/server/applications.pybindu/server/endpoints/payment_sessions.pybindu/server/middleware/x402/nonce_store.pybindu/server/middleware/x402/payment_session_manager.pybindu/server/middleware/x402/x402_middleware.pybindu/server/workers/manifest_worker.pybindu/settings.pybugs/core/2026-05-12-x402-v2-migration-hardening.mdbugs/known-issues.mdpyproject.tomltests/conftest.pytests/conftest_stubs.pytests/unit/server/middleware/x402/test_nonce_store.pytests/unit/server/middleware/x402/test_x402_middleware.pytests/unit/server/workers/test_manifest_worker.py
💤 Files with no reviewable changes (1)
- tests/conftest_stubs.py
| parsed = parse_payment_payload(payment_token.encode("utf-8")) | ||
| if not isinstance(parsed, PaymentPayload): | ||
| # v1 payloads are no longer supported on the verify side | ||
| # (middleware rejects them); reject here too for consistency. | ||
| raise ValueError("v1 payment payloads are no longer accepted") | ||
| payment_payload = parsed |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify the token contract used across the x402 flow.
rg -nC2 'base64\.b64encode|safe_base64_decode|parse_payment_payload' \
bindu/server/endpoints/payment_sessions.py \
bindu/server/middleware/x402/x402_middleware.pyRepository: GetBindu/Bindu
Length of output: 3627
Base64-decode the captured token before parsing it.
payment_status_endpoint() emits payment_token as base64-encoded JSON (line 205), and the middleware also expects base64 decoding before parse_payment_payload (see x402_middleware.py lines 132-133). The capture endpoint at line 113 skips this step, which will reject valid tokens from the browser payment flow.
Suggested fix
+from x402.http.utils import safe_base64_decode
...
- parsed = parse_payment_payload(payment_token.encode("utf-8"))
+ decoded = safe_base64_decode(payment_token)
+ parsed = parse_payment_payload(decoded.encode("utf-8"))🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@bindu/server/endpoints/payment_sessions.py` around lines 113 - 118, The
capture path is parsing payment_token directly instead of base64-decoding it
first, so update the logic in the payment_status_endpoint (where payment_token
is read and passed to parse_payment_payload) to base64-decode the token string
(the same way the middleware does) before calling parse_payment_payload; ensure
you decode the token bytes (e.g., base64.b64decode) and then pass the resulting
UTF-8 JSON bytes to parse_payment_payload so the parsed value can be correctly
recognized as a PaymentPayload.
| payment_required = PaymentRequired( | ||
| error="Complete payment to continue", | ||
| payment_requirements=payment_reqs_with_session, | ||
| paywall_config=app._paywall_config, | ||
| accepts=app._payment_requirements, | ||
| resource=ResourceInfo(url=f"{app.manifest.url}?session_id={session_id}"), |
There was a problem hiding this comment.
Point the paywall resource back to /payment-capture.
This page's job is to round-trip the browser into payment_capture_endpoint() with the signed token. Using app.manifest.url?session_id=... sends the flow to /, so the session never gets completed.
Suggested fix
- resource=ResourceInfo(url=f"{app.manifest.url}?session_id={session_id}"),
+ resource=ResourceInfo(
+ url=f"{app.manifest.url}/payment-capture?session_id={session_id}"
+ ),📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| payment_required = PaymentRequired( | |
| error="Complete payment to continue", | |
| payment_requirements=payment_reqs_with_session, | |
| paywall_config=app._paywall_config, | |
| accepts=app._payment_requirements, | |
| resource=ResourceInfo(url=f"{app.manifest.url}?session_id={session_id}"), | |
| payment_required = PaymentRequired( | |
| error="Complete payment to continue", | |
| accepts=app._payment_requirements, | |
| resource=ResourceInfo( | |
| url=f"{app.manifest.url}/payment-capture?session_id={session_id}" | |
| ), |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@bindu/server/endpoints/payment_sessions.py` around lines 146 - 149, The
paywall resource currently points to app.manifest.url which sends the user to
"/" so the session never completes; update the PaymentRequired.resource (the
ResourceInfo url) to route to the payment capture endpoint with the session_id
query (i.e. the URL that triggers payment_capture_endpoint with
?session_id={session_id}) so the browser round-trips into
payment_capture_endpoint() to complete the session (replace the use of
app.manifest.url?session_id=... with the proper /payment-capture URL or the
framework URL-helper for payment_capture_endpoint including session_id).
| def _key(network: str, asset: str, nonce: str) -> str: | ||
| """Build the canonical nonce key. | ||
|
|
||
| The asset is included so the same nonce on two different token | ||
| contracts cannot collide. | ||
| """ | ||
| return f"{NONCE_KEY_PREFIX}:{network}:{asset.lower()}:{nonce.lower()}" |
There was a problem hiding this comment.
Consider adding validation for None inputs.
The _key function calls .lower() on asset and nonce without checking if they're None or empty. If the middleware passes invalid values, this will raise an AttributeError.
🛡️ Proposed defensive validation
def _key(network: str, asset: str, nonce: str) -> str:
"""Build the canonical nonce key.
The asset is included so the same nonce on two different token
contracts cannot collide.
"""
+ if not network or not asset or not nonce:
+ raise ValueError("network, asset, and nonce must be non-empty")
return f"{NONCE_KEY_PREFIX}:{network}:{asset.lower()}:{nonce.lower()}"🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@bindu/server/middleware/x402/nonce_store.py` around lines 47 - 53, The _key
function currently calls asset.lower() and nonce.lower() which will raise
AttributeError if either is None or not a string; add defensive validation at
the start of _key (function name: _key) to assert that network, asset, and nonce
are non-empty strings (e.g., isinstance(..., str) and asset and nonce) and raise
a clear ValueError/TypeError with a descriptive message if invalid, before
performing .lower(); this ensures callers get an explicit error rather than an
AttributeError and prevents silent failures.
| async def claim( | ||
| self, network: str, asset: str, nonce: str, ttl_seconds: int | ||
| ) -> bool: | ||
| """Return True on a fresh claim; False if Redis already holds the key.""" | ||
| client = await self._get_client() | ||
| key = _key(network, asset, nonce) | ||
| # SET NX EX is atomic — no race between the existence check and the | ||
| # write. Redis returns None when NX fails, "OK" on success. | ||
| result = await client.set(key, "1", nx=True, ex=ttl_seconds) | ||
| return result is not None |
There was a problem hiding this comment.
Redis connection failures fail-open (security concern).
If client.set(...) raises an exception (network partition, Redis down, auth failure), the exception propagates uncaught and the middleware's exception handler may fall through, potentially allowing unpaid requests.
Consider wrapping the Redis call in try/except and returning False (reject the claim) on any Redis error to fail-closed.
🔒 Proposed fail-closed error handling
async def claim(
self, network: str, asset: str, nonce: str, ttl_seconds: int
) -> bool:
"""Return True on a fresh claim; False if Redis already holds the key."""
- client = await self._get_client()
- key = _key(network, asset, nonce)
- # SET NX EX is atomic — no race between the existence check and the
- # write. Redis returns None when NX fails, "OK" on success.
- result = await client.set(key, "1", nx=True, ex=ttl_seconds)
- return result is not None
+ try:
+ client = await self._get_client()
+ key = _key(network, asset, nonce)
+ # SET NX EX is atomic — no race between the existence check and the
+ # write. Redis returns None when NX fails, "OK" on success.
+ result = await client.set(key, "1", nx=True, ex=ttl_seconds)
+ return result is not None
+ except Exception as e:
+ logger.error(f"Redis nonce claim failed: {e}", exc_info=True)
+ # Fail closed: treat Redis errors as "nonce already claimed"
+ # to prevent payment bypass on Redis outage
+ return False🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@bindu/server/middleware/x402/nonce_store.py` around lines 130 - 139, The
claim method currently lets exceptions from await client.set(...) propagate,
which can cause fail-open behavior; wrap the Redis call in a try/except around
the client = await self._get_client() / client.set(...) sequence inside claim,
catch any exception (e.g., connection/auth errors) and return False to reject
the claim, and optionally log the exception via the instance logger; ensure you
still compute key with _key(network, asset, nonce) and keep the original TTL
handling so behavior is unchanged when Redis succeeds.
| claimed = await self._nonce_store.claim( | ||
| payload.get_network(), requirement.asset, nonce, ttl | ||
| ) | ||
| logger.info( | ||
| f"Manual payment validation: is_valid={is_valid}, error_reason={error_reason}" | ||
| except Exception: | ||
| # The nonce store should fail loudly: a silent failure here would | ||
| # collapse straight back into the replay vulnerability we're trying | ||
| # to fix. Reject the request and let the operator investigate. | ||
| logger.exception("x402: nonce store error; rejecting payment") | ||
| return self._create_402_response( | ||
| "Payment validation temporarily unavailable" | ||
| ) |
There was a problem hiding this comment.
Don't burn the nonce when facilitator verification errors out.
The nonce is claimed before verify_payment(), but the exception path returns 402 without releasing it. A transient facilitator/network failure will make a legitimate signed payment look like a replay for the rest of its TTL. Add a rollback path for exceptions only, e.g. a NonceStore.release(...) used from the verify_payment except branch.
Also applies to: 188-192
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@bindu/server/middleware/x402/x402_middleware.py` around lines 163 - 173, The
nonce is being claimed via self._nonce_store.claim(payload.get_network(),
requirement.asset, nonce, ttl) before calling verify_payment(), but on
exceptions the code returns a 402 without releasing the nonce; update the
exception branches that log "x402: nonce store error; rejecting payment" (and
the other similar branch around the verify_payment call) to call the
corresponding release method (e.g.
self._nonce_store.release(payload.get_network(), requirement.asset, nonce)) on
exception-only paths before returning self._create_402_response("Payment
validation temporarily unavailable"), ensuring release is only attempted for
nonces that were successfully claimed and catching/logging any release errors
separately.
| def _build_app( | ||
| *, | ||
| verify_result: VerifyResponse, | ||
| nonce_store: InMemoryNonceStore | None = None, | ||
| protected_methods: set[str] | None = None, | ||
| ): | ||
| """Spin up a minimal Starlette app with the middleware in front of | ||
| an agent that simply echoes ``OK``.""" | ||
|
|
||
| async def agent(request: Request) -> JSONResponse: | ||
| return JSONResponse({"ok": True}) | ||
|
|
||
| resource_server = MagicMock() | ||
| resource_server.find_matching_requirements = MagicMock(return_value=REQUIREMENT) | ||
| resource_server.verify_payment = AsyncMock(return_value=verify_result) | ||
|
|
||
| manifest = MagicMock() | ||
| manifest.name = "test-agent" | ||
| manifest.description = "test" | ||
| manifest.did_extension = None | ||
|
|
||
| x402_ext = MagicMock() | ||
|
|
||
| # Patch app_settings.x402.protected_methods just for the dispatch check. | ||
| from bindu.settings import app_settings | ||
|
|
||
| original = app_settings.x402.protected_methods | ||
| app_settings.x402.protected_methods = protected_methods or {"message/send"} | ||
|
|
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find the remaining set-based assignments/call sites for protected_methods in this test.
rg -n 'protected_methods|\\{"message/send"\\}' tests/unit/server/middleware/x402/test_x402_middleware.pyRepository: GetBindu/Bindu
Length of output: 200
🏁 Script executed:
#!/bin/bash
# Correct search: find all lines with protected_methods in the test file
rg -n 'protected_methods' tests/unit/server/middleware/x402/test_x402_middleware.pyRepository: GetBindu/Bindu
Length of output: 1237
🏁 Script executed:
#!/bin/bash
# Also find the actual type definition in settings
fd -name '*.py' -type f | xargs rg -l 'app_settings.*x402' | head -5Repository: GetBindu/Bindu
Length of output: 479
🏁 Script executed:
#!/bin/bash
# Find where app_settings.x402 is defined with protected_methods
rg -n 'protected_methods' --type py | grep -i settings | head -10Repository: GetBindu/Bindu
Length of output: 992
🏁 Script executed:
#!/bin/bash
# Read the settings.py file around line 301 to confirm the type
sed -n '298,305p' bindu/settings.pyRepository: GetBindu/Bindu
Length of output: 379
Change protected_methods to use list instead of set to match the type annotation in settings.
app_settings.x402.protected_methods is typed as list[str] in bindu/settings.py:301, but the test file uses set types and set literals instead.
Changes needed
def _build_app(
*,
verify_result: VerifyResponse,
nonce_store: InMemoryNonceStore | None = None,
- protected_methods: set[str] | None = None,
+ protected_methods: list[str] | None = None,
):- app_settings.x402.protected_methods = protected_methods or {"message/send"}
+ app_settings.x402.protected_methods = protected_methods or ["message/send"] def _restore_protected_methods():
- app_settings.x402.protected_methods = {"message/send"}
+ app_settings.x402.protected_methods = ["message/send"] protected_methods={"message/send"},
+ protected_methods=["message/send"],Lines: 71, 94, 123, 173
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| def _build_app( | |
| *, | |
| verify_result: VerifyResponse, | |
| nonce_store: InMemoryNonceStore | None = None, | |
| protected_methods: set[str] | None = None, | |
| ): | |
| """Spin up a minimal Starlette app with the middleware in front of | |
| an agent that simply echoes ``OK``.""" | |
| async def agent(request: Request) -> JSONResponse: | |
| return JSONResponse({"ok": True}) | |
| resource_server = MagicMock() | |
| resource_server.find_matching_requirements = MagicMock(return_value=REQUIREMENT) | |
| resource_server.verify_payment = AsyncMock(return_value=verify_result) | |
| manifest = MagicMock() | |
| manifest.name = "test-agent" | |
| manifest.description = "test" | |
| manifest.did_extension = None | |
| x402_ext = MagicMock() | |
| # Patch app_settings.x402.protected_methods just for the dispatch check. | |
| from bindu.settings import app_settings | |
| original = app_settings.x402.protected_methods | |
| app_settings.x402.protected_methods = protected_methods or {"message/send"} | |
| def _build_app( | |
| *, | |
| verify_result: VerifyResponse, | |
| nonce_store: InMemoryNonceStore | None = None, | |
| protected_methods: list[str] | None = None, | |
| ): | |
| """Spin up a minimal Starlette app with the middleware in front of | |
| an agent that simply echoes ``OK``.""" | |
| async def agent(request: Request) -> JSONResponse: | |
| return JSONResponse({"ok": True}) | |
| resource_server = MagicMock() | |
| resource_server.find_matching_requirements = MagicMock(return_value=REQUIREMENT) | |
| resource_server.verify_payment = AsyncMock(return_value=verify_result) | |
| manifest = MagicMock() | |
| manifest.name = "test-agent" | |
| manifest.description = "test" | |
| manifest.did_extension = None | |
| x402_ext = MagicMock() | |
| # Patch app_settings.x402.protected_methods just for the dispatch check. | |
| from bindu.settings import app_settings | |
| original = app_settings.x402.protected_methods | |
| app_settings.x402.protected_methods = protected_methods or ["message/send"] |
🧰 Tools
🪛 GitHub Actions: CI / 3_Unit Tests (3.12).txt
[error] 94-94: Invalid assignment: protected_methods expects list[str], found set[str] (error[invalid-assignment]).
🪛 GitHub Actions: CI / 4_Unit Tests (3.13).txt
[error] 94-94: ty error[invalid-assignment]: protected_methods expects list[str], got set[str].
🪛 GitHub Actions: CI / Unit Tests (3.12)
[error] 94-94: ty: invalid-assignment: Object of type set[str] is not assignable to attribute protected_methods of type list[str].
🪛 GitHub Actions: CI / Unit Tests (3.13)
[error] 94-94: ty error[invalid-assignment]: Object of type set[str] is not assignable to attribute protected_methods of type list[str].
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/unit/server/middleware/x402/test_x402_middleware.py` around lines 67 -
95, The test currently assigns sets to app_settings.x402.protected_methods and
accepts a set for the _build_app parameter, but settings expects a list[str];
change the _build_app signature and any calls to pass a list (protected_methods:
list[str] | None) and set app_settings.x402.protected_methods =
protected_methods or ["message/send"] (and update any other occurrences in this
test file where protected_methods is provided as a set to use list literals) so
the test uses list types matching
bindu.settings.app_settings.x402.protected_methods.
Summary
Rewrites the x402 middleware on top of x402 SDK v2 (
x402>=2.3.0) and clears every high-severity x402 entry frombugs/known-issues.md. Closes the Dependabot CVE onx402<2.3.0at the same time.Bug classes closed
x402-middleware-fails-open-on-body-parseexcept Exception→call_next(request)(JSONDecodeError, UnicodeDecodeError)→ 402x402-no-replay-preventionX-PAYMENTusable untilvalidBeforenonce_store(RedisSETNX/ in-memory) claimed before facilitator verifyx402-no-signature-verificationx402ResourceServer.verify_paymentdelegates to facilitator'sverify_typed_datax402-balance-check-skipped-on-missing-contract-codereturn Trueverify_paymentreturnsis_valid=Falsewith reason; middleware returns 402, no fall-throughFull postmortem:
bugs/core/2026-05-12-x402-v2-migration-hardening.md.End-to-end smoke (real x402.org facilitator)
Booted the agent via
bindufy()withexecution_cost, hit it over HTTP:POST /PaymentRequired(CAIP-2, USDC auto-filled, atomic amount, EIP-712 domain)API migration
x402.encoding.safe_base64_decodex402.http.utils.safe_base64_decodex402.paywall.get_paywall_htmlcreate_paywall().with_network(evm_paywall).build()x402.types.*from x402 import …(top-level)x402.common.process_price_to_atomic_amountExactEvmServerScheme().parse_price()x402.common.find_matching_payment_requirementsx402ResourceServer.find_matching_requirements()x402.facilitator.FacilitatorClientx402.http.HTTPFacilitatorClientPaymentRequirements.max_amount_required.amountPaymentPayload.{scheme,network}(top-level).accepted.scheme/.get_network()PaywallConfig.cdp_client_keyCleanup driven by the migration
pyproject.toml: x402==0.2.1→>=2.3.0,<3; addedpydantic-settingsas a direct dep (was a latent transitive that vanished when v2 dropped fastapi from required deps).bindu/settings.py: removed deadrpc_urls_by_networkdict — v0.2.1 made direct RPC calls; in v2 the facilitator owns RPC.applications.py: friendly network names (base-sepolia) translated to CAIP-2 (eip155:84532) at the boundary — surfaced by smoke before users would have hit it.tests/conftest_stubs.py: droppedsetup_x402_stubsentirely — the stubs were faking v1 module paths that shadowed the real install. With x402 as a hard runtime dep no reason to stub.New tests
tests/unit/server/middleware/x402/test_nonce_store.py(10 tests) — key namespacing, replay rejection, TTL expiry, concurrent claims.tests/unit/server/middleware/x402/test_x402_middleware.py(9 tests) — full dispatch path with mocked ResourceServer covering all 5 rejection branches plus the happy path.Dependabot impact
Test plan
--no-verify; follow-up will drive the remaining 59 down)🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
Bug Fixes
Tests