agentpayments-python 0.1.2__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/PKG-INFO +77 -1
  2. agentpayments_python-0.3.0/README.md +197 -0
  3. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/django_adapter.py +48 -15
  4. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/fastapi_adapter.py +44 -19
  5. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/flask_adapter.py +39 -16
  6. agentpayments_python-0.3.0/agentpayments_python/grant_store.py +130 -0
  7. agentpayments_python-0.3.0/agentpayments_python/pricing.py +70 -0
  8. agentpayments_python-0.3.0/agentpayments_python/redis_store.py +128 -0
  9. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/solana.py +39 -15
  10. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/x402.py +24 -2
  11. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/PKG-INFO +77 -1
  12. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/SOURCES.txt +2 -0
  13. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/pyproject.toml +1 -1
  14. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/tests/test_core.py +534 -5
  15. agentpayments_python-0.1.2/README.md +0 -121
  16. agentpayments_python-0.1.2/agentpayments_python/grant_store.py +0 -86
  17. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/LICENSE +0 -0
  18. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/__init__.py +0 -0
  19. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/challenge.py +0 -0
  20. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/constants.json +0 -0
  21. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/cookies.py +0 -0
  22. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/crawler.py +0 -0
  23. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/crypto.py +0 -0
  24. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/detection.py +0 -0
  25. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/platform_client.py +0 -0
  26. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/ratelimit.py +0 -0
  27. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/dependency_links.txt +0 -0
  28. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/requires.txt +0 -0
  29. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/top_level.txt +0 -0
  30. {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentpayments-python
3
- Version: 0.1.2
3
+ Version: 0.3.0
4
4
  Summary: AgentPayments gate for Python web frameworks — charge AI agents USDC on Solana before they can access your API
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/adambrzosko/AgentPayments
@@ -116,9 +116,16 @@ register_agentpayments(
116
116
  | `home_wallet_address` | Yes | `''` | Solana wallet address to receive USDC payments. |
117
117
  | `solana_rpc_url` | No | Auto (devnet/mainnet) | Custom Solana RPC endpoint. |
118
118
  | `usdc_mint` | No | Auto (devnet/mainnet) | Custom USDC mint address. |
119
+ | `min_payment` | No | `0.01` | Minimum USDC payment required. Ignored when `pricing_tiers` is set (the lowest tier's `min_amount` becomes the floor). |
120
+ | `access_duration` | No | `None` (forever) | Seconds a successful payment grants access for. Requires `grant_store` to actually persist. |
121
+ | `pricing_tiers` | No | `None` | Payment-amount → access mapping: `[{"min_amount": ..., "duration_seconds": ..., "name": ...}]`. Overrides `min_payment`/`access_duration`. |
122
+ | `routes` | No | `None` | Per-route overrides for a single middleware instance: `[{"path_prefix": ..., "min_payment": ..., "access_duration": ..., "pricing_tiers": ...}]`, matched by longest `path_prefix`. |
119
123
  | `debug` | No | `True` | `True` = devnet. `False` = mainnet + strict mode. |
120
124
  | `api_key` | No | `None` | AgentPayments hosted-platform API key (`ap_live_...`). When set, agent keys are issued and metered via the platform instead of self-signed locally. See **Hosted Platform Mode** below. |
121
125
  | `platform_url` | No | AgentPayments-hosted URL | Override for a self-hosted platform API. |
126
+ | `agent_key_rate_limiter` | No | Built-in in-memory (10/min/IP) | Pluggable rate limiter for the agent-key payment-verification path. Pass a Redis-backed one for multi-process deployments — see **Multi-Process Deployments** below. |
127
+ | `challenge_issue_rate_limiter` | No | Built-in in-memory (30/min/IP) | Pluggable rate limiter for browser challenge-page issuance. |
128
+ | `payment_cache` | No | Module-level in-memory singleton | Pluggable payment-verification cache (10-min positive / 30s negative TTL by default). |
122
129
 
123
130
  Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`, `settings.AGENTPAYMENTS_API_KEY`). FastAPI and Flask accept them as constructor arguments.
124
131
 
@@ -126,6 +133,72 @@ Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`, `settin
126
133
 
127
134
  Setting `api_key` switches agent-key issuance from local (`ag_...`) to platform-issued (`agp_...`), and — when the platform account has an on-chain fee configured — every 402 response's `payment` dict gains a `platform_fee` field describing a second required USDC transfer, to be sent in the **same Solana transaction** as the vendor payment. Missing that second transfer is treated as an unpaid request, same as any other invalid payment. This is opt-in per vendor account and has no effect on self-hosted deployments (no `api_key`). The standards-compliant `accepts[]`/`X-PAYMENT-REQUIRED` x402 fields are untouched — they still describe only the vendor leg.
128
135
 
136
+ ## Pricing & Access Model
137
+
138
+ By default one `min_payment` buys indefinite access. Three parameters layer on top, identical across Django/FastAPI/Flask (Django via `settings.AGENTPAYMENTS_*`, the others as constructor kwargs):
139
+
140
+ ```python
141
+ # FastAPI / Flask
142
+ register_agentpayments( # or AgentPaymentsASGIMiddleware(...)
143
+ app, ...,
144
+ grant_store=FileGrantStore("./data/grants.json"), # required for duration/tiers to persist
145
+ access_duration=86400, # seconds; a paid key is only good for 24h, then re-verifies
146
+ pricing_tiers=[
147
+ {"min_amount": 0.01, "duration_seconds": 3600, "name": "hourly"},
148
+ {"min_amount": 0.05, "duration_seconds": 86400, "name": "daily"},
149
+ {"min_amount": 0.50, "duration_seconds": None, "name": "lifetime"},
150
+ ],
151
+ routes=[
152
+ {"path_prefix": "/api/premium", "min_payment": 0.05},
153
+ {"path_prefix": "/api/basic", "min_payment": 0.01},
154
+ ],
155
+ )
156
+ ```
157
+
158
+ ```python
159
+ # Django settings.py
160
+ AGENTPAYMENTS_GRANT_STORE = FileGrantStore("/var/data/agp_grants.json")
161
+ AGENTPAYMENTS_ACCESS_DURATION = 86400
162
+ AGENTPAYMENTS_PRICING_TIERS = [...] # same shape as above
163
+ AGENTPAYMENTS_ROUTES = [...]
164
+ ```
165
+
166
+ `pricing_tiers` overrides `min_payment`/`access_duration` when set — the gate finds the highest tier the actual on-chain payment clears and grants that tier's duration; the lowest tier's `min_amount` becomes the floor price for any access at all. Every non-floor tier also appears as its own entry in the 402 response's x402 `accepts[]` array. `routes` is matched by longest `path_prefix` on a path-segment boundary; unmatched requests fall back to the top-level `min_payment`/`access_duration`/`pricing_tiers`.
167
+
168
+ **Revocation**: `MemoryGrantStore`/`FileGrantStore` (`agentpayments_python.grant_store`) both gained a `revoke(agent_key)` method — call it from your own admin view to cut off a specific paid key early. The adapters need no changes to respect this: `has()` already returns `False` for a revoked (or expired) grant. A grants file written by an older SDK version (a plain JSON array of key strings) is still read correctly as a set of permanent grants.
169
+
170
+ ## Multi-Process Deployments (Redis)
171
+
172
+ The built-in rate limiters and payment cache are in-memory and **per-process** — with `gunicorn -w 4`, each of the 4 workers enforces its own independent 10-req/min agent-key limit (40/min in aggregate) and caches payment results separately, so a payment verified by one worker isn't recognized by another until its own cache/rate-limit state catches up. For any multi-process deployment, plug in a Redis-backed store instead:
173
+
174
+ ```python
175
+ import redis
176
+ from agentpayments_python.redis_store import create_redis_store
177
+
178
+ r = redis.Redis.from_url(os.environ["REDIS_URL"])
179
+ store = create_redis_store(r)
180
+
181
+ # FastAPI / Flask
182
+ register_agentpayments(app, ..., # or AgentPaymentsASGIMiddleware(...)
183
+ agent_key_rate_limiter=store["agent_key_rate_limiter"],
184
+ challenge_issue_rate_limiter=store["challenge_issue_rate_limiter"],
185
+ payment_cache=store["payment_cache"],
186
+ )
187
+
188
+ # Django settings.py
189
+ AGENTPAYMENTS_AGENT_KEY_RATE_LIMITER = store["agent_key_rate_limiter"]
190
+ AGENTPAYMENTS_CHALLENGE_ISSUE_RATE_LIMITER = store["challenge_issue_rate_limiter"]
191
+ AGENTPAYMENTS_PAYMENT_CACHE = store["payment_cache"]
192
+
193
+ # The /__challenge/verify endpoint has its own rate limiter, passed separately
194
+ # since it's a standalone route/view rather than the main middleware:
195
+ # FastAPI: challenge_verify_endpoint(request, challenge_secret=..., rate_limiter=store["challenge_verify_rate_limiter"])
196
+ # Django: AGENTPAYMENTS_CHALLENGE_VERIFY_RATE_LIMITER = store["challenge_verify_rate_limiter"]
197
+ # Flask: register_agentpayments(app, ..., challenge_verify_rate_limiter=store["challenge_verify_rate_limiter"])
198
+ ```
199
+
200
+ `redis_store.py` has no hard dependency on the `redis` package — it's duck-typed against any client exposing `eval(script, numkeys, *args)`, `get(key)`, and `set(key, value, ex=ttl)` (redis-py's `Redis` client satisfies this directly), mirroring `sdk/node/redis-store.js`. Rate limiting fails open on a Redis error (never blocks legitimate traffic); the payment cache fails to a miss (falls through to a normal chain scan). All four are optional — anything left unset keeps using the default in-memory implementation.
201
+
129
202
  ## Security Features
130
203
 
131
204
  - **Timing-safe HMAC comparison** — uses `hmac.compare_digest()` for all signature checks
@@ -150,6 +223,9 @@ agentpayments_python/
150
223
  detection.py Browser detection and public path checks
151
224
  solana.py On-chain payment verification + caching
152
225
  ratelimit.py Shared IP-based rate limiter
226
+ grant_store.py Durable paid-key persistence (expiry, revocation)
227
+ pricing.py Pricing-tier / access-duration / per-route resolution
228
+ redis_store.py Redis-backed rate limiter + payment cache (multi-process)
153
229
  ```
154
230
 
155
231
  ## Notes
@@ -0,0 +1,197 @@
1
+ # agentpayments-python
2
+
3
+ Python adapters for the AgentPayments gate. Supports Django, FastAPI/Starlette, and Flask.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install agentpayments-python
9
+ # or, in this monorepo:
10
+ # pip install -e sdk/python
11
+ ```
12
+
13
+ ## Django
14
+
15
+ ```python
16
+ # settings.py
17
+ MIDDLEWARE = [
18
+ "django.middleware.security.SecurityMiddleware",
19
+ "agentpayments_python.django_adapter.GateMiddleware",
20
+ # ... other middleware
21
+ ]
22
+
23
+ # Required settings
24
+ CHALLENGE_SECRET = os.environ["CHALLENGE_SECRET"]
25
+ HOME_WALLET_ADDRESS = os.environ["HOME_WALLET_ADDRESS"]
26
+
27
+ # Optional settings
28
+ SOLANA_RPC_URL = os.environ.get("SOLANA_RPC_URL")
29
+ USDC_MINT = os.environ.get("USDC_MINT")
30
+ DEBUG = True # True = devnet, False = mainnet
31
+ ```
32
+
33
+ ## FastAPI
34
+
35
+ ```python
36
+ from fastapi import FastAPI, Request
37
+ from agentpayments_python.fastapi_adapter import (
38
+ AgentPaymentsASGIMiddleware,
39
+ challenge_verify_endpoint,
40
+ )
41
+
42
+ app = FastAPI()
43
+
44
+ app.add_middleware(
45
+ AgentPaymentsASGIMiddleware,
46
+ challenge_secret=os.environ["CHALLENGE_SECRET"],
47
+ home_wallet_address=os.environ["HOME_WALLET_ADDRESS"],
48
+ debug=True,
49
+ )
50
+
51
+ @app.post("/__challenge/verify")
52
+ async def verify(request: Request):
53
+ return await challenge_verify_endpoint(
54
+ request,
55
+ challenge_secret=os.environ["CHALLENGE_SECRET"],
56
+ )
57
+ ```
58
+
59
+ ## Flask
60
+
61
+ ```python
62
+ from flask import Flask
63
+ from agentpayments_python.flask_adapter import register_agentpayments
64
+
65
+ app = Flask(__name__)
66
+ register_agentpayments(
67
+ app,
68
+ challenge_secret=os.environ["CHALLENGE_SECRET"],
69
+ home_wallet_address=os.environ["HOME_WALLET_ADDRESS"],
70
+ debug=True,
71
+ )
72
+ ```
73
+
74
+ ## Configuration
75
+
76
+ | Parameter | Required | Default | Description |
77
+ |---|---|---|---|
78
+ | `challenge_secret` | Yes (production) | `'default-secret-change-me'` | HMAC secret for signing cookies, nonces, and agent keys. |
79
+ | `home_wallet_address` | Yes | `''` | Solana wallet address to receive USDC payments. |
80
+ | `solana_rpc_url` | No | Auto (devnet/mainnet) | Custom Solana RPC endpoint. |
81
+ | `usdc_mint` | No | Auto (devnet/mainnet) | Custom USDC mint address. |
82
+ | `min_payment` | No | `0.01` | Minimum USDC payment required. Ignored when `pricing_tiers` is set (the lowest tier's `min_amount` becomes the floor). |
83
+ | `access_duration` | No | `None` (forever) | Seconds a successful payment grants access for. Requires `grant_store` to actually persist. |
84
+ | `pricing_tiers` | No | `None` | Payment-amount → access mapping: `[{"min_amount": ..., "duration_seconds": ..., "name": ...}]`. Overrides `min_payment`/`access_duration`. |
85
+ | `routes` | No | `None` | Per-route overrides for a single middleware instance: `[{"path_prefix": ..., "min_payment": ..., "access_duration": ..., "pricing_tiers": ...}]`, matched by longest `path_prefix`. |
86
+ | `debug` | No | `True` | `True` = devnet. `False` = mainnet + strict mode. |
87
+ | `api_key` | No | `None` | AgentPayments hosted-platform API key (`ap_live_...`). When set, agent keys are issued and metered via the platform instead of self-signed locally. See **Hosted Platform Mode** below. |
88
+ | `platform_url` | No | AgentPayments-hosted URL | Override for a self-hosted platform API. |
89
+ | `agent_key_rate_limiter` | No | Built-in in-memory (10/min/IP) | Pluggable rate limiter for the agent-key payment-verification path. Pass a Redis-backed one for multi-process deployments — see **Multi-Process Deployments** below. |
90
+ | `challenge_issue_rate_limiter` | No | Built-in in-memory (30/min/IP) | Pluggable rate limiter for browser challenge-page issuance. |
91
+ | `payment_cache` | No | Module-level in-memory singleton | Pluggable payment-verification cache (10-min positive / 30s negative TTL by default). |
92
+
93
+ Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`, `settings.AGENTPAYMENTS_API_KEY`). FastAPI and Flask accept them as constructor arguments.
94
+
95
+ ## Hosted Platform Mode
96
+
97
+ Setting `api_key` switches agent-key issuance from local (`ag_...`) to platform-issued (`agp_...`), and — when the platform account has an on-chain fee configured — every 402 response's `payment` dict gains a `platform_fee` field describing a second required USDC transfer, to be sent in the **same Solana transaction** as the vendor payment. Missing that second transfer is treated as an unpaid request, same as any other invalid payment. This is opt-in per vendor account and has no effect on self-hosted deployments (no `api_key`). The standards-compliant `accepts[]`/`X-PAYMENT-REQUIRED` x402 fields are untouched — they still describe only the vendor leg.
98
+
99
+ ## Pricing & Access Model
100
+
101
+ By default one `min_payment` buys indefinite access. Three parameters layer on top, identical across Django/FastAPI/Flask (Django via `settings.AGENTPAYMENTS_*`, the others as constructor kwargs):
102
+
103
+ ```python
104
+ # FastAPI / Flask
105
+ register_agentpayments( # or AgentPaymentsASGIMiddleware(...)
106
+ app, ...,
107
+ grant_store=FileGrantStore("./data/grants.json"), # required for duration/tiers to persist
108
+ access_duration=86400, # seconds; a paid key is only good for 24h, then re-verifies
109
+ pricing_tiers=[
110
+ {"min_amount": 0.01, "duration_seconds": 3600, "name": "hourly"},
111
+ {"min_amount": 0.05, "duration_seconds": 86400, "name": "daily"},
112
+ {"min_amount": 0.50, "duration_seconds": None, "name": "lifetime"},
113
+ ],
114
+ routes=[
115
+ {"path_prefix": "/api/premium", "min_payment": 0.05},
116
+ {"path_prefix": "/api/basic", "min_payment": 0.01},
117
+ ],
118
+ )
119
+ ```
120
+
121
+ ```python
122
+ # Django settings.py
123
+ AGENTPAYMENTS_GRANT_STORE = FileGrantStore("/var/data/agp_grants.json")
124
+ AGENTPAYMENTS_ACCESS_DURATION = 86400
125
+ AGENTPAYMENTS_PRICING_TIERS = [...] # same shape as above
126
+ AGENTPAYMENTS_ROUTES = [...]
127
+ ```
128
+
129
+ `pricing_tiers` overrides `min_payment`/`access_duration` when set — the gate finds the highest tier the actual on-chain payment clears and grants that tier's duration; the lowest tier's `min_amount` becomes the floor price for any access at all. Every non-floor tier also appears as its own entry in the 402 response's x402 `accepts[]` array. `routes` is matched by longest `path_prefix` on a path-segment boundary; unmatched requests fall back to the top-level `min_payment`/`access_duration`/`pricing_tiers`.
130
+
131
+ **Revocation**: `MemoryGrantStore`/`FileGrantStore` (`agentpayments_python.grant_store`) both gained a `revoke(agent_key)` method — call it from your own admin view to cut off a specific paid key early. The adapters need no changes to respect this: `has()` already returns `False` for a revoked (or expired) grant. A grants file written by an older SDK version (a plain JSON array of key strings) is still read correctly as a set of permanent grants.
132
+
133
+ ## Multi-Process Deployments (Redis)
134
+
135
+ The built-in rate limiters and payment cache are in-memory and **per-process** — with `gunicorn -w 4`, each of the 4 workers enforces its own independent 10-req/min agent-key limit (40/min in aggregate) and caches payment results separately, so a payment verified by one worker isn't recognized by another until its own cache/rate-limit state catches up. For any multi-process deployment, plug in a Redis-backed store instead:
136
+
137
+ ```python
138
+ import redis
139
+ from agentpayments_python.redis_store import create_redis_store
140
+
141
+ r = redis.Redis.from_url(os.environ["REDIS_URL"])
142
+ store = create_redis_store(r)
143
+
144
+ # FastAPI / Flask
145
+ register_agentpayments(app, ..., # or AgentPaymentsASGIMiddleware(...)
146
+ agent_key_rate_limiter=store["agent_key_rate_limiter"],
147
+ challenge_issue_rate_limiter=store["challenge_issue_rate_limiter"],
148
+ payment_cache=store["payment_cache"],
149
+ )
150
+
151
+ # Django settings.py
152
+ AGENTPAYMENTS_AGENT_KEY_RATE_LIMITER = store["agent_key_rate_limiter"]
153
+ AGENTPAYMENTS_CHALLENGE_ISSUE_RATE_LIMITER = store["challenge_issue_rate_limiter"]
154
+ AGENTPAYMENTS_PAYMENT_CACHE = store["payment_cache"]
155
+
156
+ # The /__challenge/verify endpoint has its own rate limiter, passed separately
157
+ # since it's a standalone route/view rather than the main middleware:
158
+ # FastAPI: challenge_verify_endpoint(request, challenge_secret=..., rate_limiter=store["challenge_verify_rate_limiter"])
159
+ # Django: AGENTPAYMENTS_CHALLENGE_VERIFY_RATE_LIMITER = store["challenge_verify_rate_limiter"]
160
+ # Flask: register_agentpayments(app, ..., challenge_verify_rate_limiter=store["challenge_verify_rate_limiter"])
161
+ ```
162
+
163
+ `redis_store.py` has no hard dependency on the `redis` package — it's duck-typed against any client exposing `eval(script, numkeys, *args)`, `get(key)`, and `set(key, value, ex=ttl)` (redis-py's `Redis` client satisfies this directly), mirroring `sdk/node/redis-store.js`. Rate limiting fails open on a Redis error (never blocks legitimate traffic); the payment cache fails to a miss (falls through to a normal chain scan). All four are optional — anything left unset keeps using the default in-memory implementation.
164
+
165
+ ## Security Features
166
+
167
+ - **Timing-safe HMAC comparison** — uses `hmac.compare_digest()` for all signature checks
168
+ - **Payment verification cache** — 10-minute TTL, 1000-entry max (thread-safe)
169
+ - **Rate limiting** — 20 challenge verifications per minute per IP (thread-safe)
170
+ - **Input size limits** — key (64 chars), nonce (128), return URL (2048), fingerprint (128)
171
+ - **Wallet address validation** — base58 format, 32-44 chars, validated at init
172
+ - **Default secret detection** — warns in debug, raises `RuntimeError` in production
173
+ - **Secure cookies** — Django auto-detects HTTPS via `request.is_secure()`
174
+
175
+ ## Module Structure
176
+
177
+ ```
178
+ agentpayments_python/
179
+ __init__.py
180
+ django_adapter.py Django middleware (GateMiddleware)
181
+ fastapi_adapter.py FastAPI/Starlette ASGI middleware
182
+ flask_adapter.py Flask integration (before_request hook)
183
+ challenge.py Shared challenge HTML generation
184
+ cookies.py Cookie creation and validation
185
+ crypto.py HMAC signing and agent key management
186
+ detection.py Browser detection and public path checks
187
+ solana.py On-chain payment verification + caching
188
+ ratelimit.py Shared IP-based rate limiter
189
+ grant_store.py Durable paid-key persistence (expiry, revocation)
190
+ pricing.py Pricing-tier / access-duration / per-route resolution
191
+ redis_store.py Redis-backed rate limiter + payment cache (multi-process)
192
+ ```
193
+
194
+ ## Notes
195
+ - Constants loaded from `sdk/constants.json` via `pathlib`.
196
+ - Logging uses Python stdlib `logging` module.
197
+ - All shared modules are framework-agnostic; adapters are thin wiring.
@@ -1,4 +1,7 @@
1
+ from __future__ import annotations
2
+
1
3
  import logging
4
+ import time as _time
2
5
 
3
6
  from django.conf import settings
4
7
  from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
@@ -11,9 +14,10 @@ from .crypto import generate_agent_key, is_valid_agent_key
11
14
  from .detection import is_browser_from_headers, is_public_path
12
15
  from .ratelimit import _challenge_limiter, _agent_key_limiter, _challenge_issue_limiter
13
16
  from .crawler import is_verified_crawler
14
- from .solana import MIN_PAYMENT, RPC_DEVNET, RPC_MAINNET, USDC_MINT_DEVNET, USDC_MINT_MAINNET, is_valid_solana_address, verify_payment_on_chain
15
- from .x402 import build_payment_object, build_payment_requirements, enrich_402_body, payment_required_header
17
+ from .solana import MIN_PAYMENT, RPC_DEVNET, RPC_MAINNET, USDC_MINT_DEVNET, USDC_MINT_MAINNET, is_valid_solana_address, _scan_for_payment
18
+ from .x402 import build_payment_object, build_payment_requirements, enrich_402_body, payment_required_header, tier_x402_opts_list
16
19
  from .platform_client import HOSTED_KEY_PREFIX, PlatformClient, is_valid_hosted_key
20
+ from .pricing import normalize_price_config, build_routes_table, resolve_route_config, resolve_tier
17
21
 
18
22
  import json as _json
19
23
  from pathlib import Path as _Path
@@ -26,10 +30,11 @@ MAX_POW_LENGTH = _constants["MAX_POW_LENGTH"]
26
30
  logger = logging.getLogger("agentpayments")
27
31
 
28
32
 
29
- def _payment_required_response(body: dict, *, wallet_address: str, mint: str, min_payment: float, debug: bool, agent_key: str = "", resource: str = "") -> JsonResponse:
33
+ def _payment_required_response(body: dict, *, wallet_address: str, mint: str, min_payment: float, debug: bool, agent_key: str = "", resource: str = "", extra_tier_opts: list[dict] | None = None) -> JsonResponse:
30
34
  """Return a 402 JsonResponse enriched with x402-standard fields and header."""
31
35
  pay_req = build_payment_requirements(wallet_address=wallet_address, mint=mint, min_payment=min_payment, debug=debug, agent_key=agent_key, resource=resource)
32
- resp = JsonResponse(enrich_402_body(body, pay_req), status=402, json_dumps_params={"indent": 2})
36
+ extra_reqs = [build_payment_requirements(**opts) for opts in (extra_tier_opts or [])]
37
+ resp = JsonResponse(enrich_402_body(body, pay_req, extra_reqs), status=402, json_dumps_params={"indent": 2})
33
38
  resp["X-PAYMENT-REQUIRED"] = payment_required_header(pay_req)
34
39
  return resp
35
40
 
@@ -69,11 +74,29 @@ class GateMiddleware:
69
74
  self.rpc_url = rpc_url
70
75
  self.usdc_mint = usdc_mint
71
76
  self.network = "devnet" if debug else "mainnet-beta"
77
+ # Price/duration/tier resolution is static for this server — precompute
78
+ # once rather than per-request. Configure via settings.py:
79
+ # AGENTPAYMENTS_MIN_PAYMENT, AGENTPAYMENTS_ACCESS_DURATION,
80
+ # AGENTPAYMENTS_PRICING_TIERS, AGENTPAYMENTS_ROUTES
81
+ min_payment = getattr(settings, "AGENTPAYMENTS_MIN_PAYMENT", MIN_PAYMENT)
82
+ access_duration = getattr(settings, "AGENTPAYMENTS_ACCESS_DURATION", None)
83
+ pricing_tiers = getattr(settings, "AGENTPAYMENTS_PRICING_TIERS", None)
84
+ routes = getattr(settings, "AGENTPAYMENTS_ROUTES", None)
85
+ self.base_price_config = normalize_price_config(min_payment, access_duration, pricing_tiers)
86
+ self.routes_table = build_routes_table(routes, min_payment, access_duration, pricing_tiers)
72
87
  self.pow_difficulty = getattr(settings, "POW_DIFFICULTY", POW_DIFFICULTY)
73
88
  self.verify_crawlers = getattr(settings, "AGENTPAYMENTS_VERIFY_CRAWLERS", True)
74
89
  # Optional grant store for durable paid-key persistence. Set
75
90
  # AGENTPAYMENTS_GRANT_STORE to a GrantStore instance in settings.py.
76
91
  self.grant_store = getattr(settings, "AGENTPAYMENTS_GRANT_STORE", None)
92
+ # Pluggable rate limiters / payment cache — default to the built-in
93
+ # in-memory singletons (fine for single-process deployments). For
94
+ # multi-process (e.g. gunicorn -w 4), set these to Redis-backed
95
+ # instances from agentpayments_python.redis_store so state is shared
96
+ # across workers instead of each worker enforcing its own limit.
97
+ self.agent_key_rate_limiter = getattr(settings, "AGENTPAYMENTS_AGENT_KEY_RATE_LIMITER", None) or _agent_key_limiter
98
+ self.challenge_issue_rate_limiter = getattr(settings, "AGENTPAYMENTS_CHALLENGE_ISSUE_RATE_LIMITER", None) or _challenge_issue_limiter
99
+ self.payment_cache = getattr(settings, "AGENTPAYMENTS_PAYMENT_CACHE", None)
77
100
 
78
101
  def __call__(self, request):
79
102
  secret = self.secret
@@ -81,6 +104,7 @@ class GateMiddleware:
81
104
  network = self.network
82
105
 
83
106
  pathname = request.path
107
+ price_config = resolve_route_config(pathname, self.routes_table, self.base_price_config)
84
108
  if is_public_path(pathname):
85
109
  return self.get_response(request)
86
110
 
@@ -128,21 +152,22 @@ class GateMiddleware:
128
152
  new_key = generate_agent_key(secret)
129
153
  if fee_info:
130
154
  no_key_instructions = (
131
- f'Send {MIN_PAYMENT} USDC on Solana {network} to {wallet_address} with memo "{new_key}", '
155
+ f'Send {price_config["min_payment"]} USDC on Solana {network} to {wallet_address} with memo "{new_key}", '
132
156
  f'AND in the SAME transaction send the platform fee (see platform_fee below) to {fee_info["wallet"]}. '
133
157
  f'Then include the header X-Agent-Key: {new_key} on all subsequent requests.'
134
158
  )
135
159
  else:
136
- no_key_instructions = f'Send {MIN_PAYMENT} USDC on Solana {network} to {wallet_address} with memo "{new_key}". Then include the header X-Agent-Key: {new_key} on all subsequent requests.'
160
+ no_key_instructions = f'Send {price_config["min_payment"]} USDC on Solana {network} to {wallet_address} with memo "{new_key}". Then include the header X-Agent-Key: {new_key} on all subsequent requests.'
137
161
  return _payment_required_response(
138
162
  {
139
163
  "error": "payment_required",
140
164
  "message": "Access requires a paid API key. A key has been generated for you below. Send a USDC payment on Solana with this key as the memo to activate it, then retry your request with the X-Agent-Key header.",
141
165
  "your_key": new_key,
142
- "payment": build_payment_object(network=network, min_payment=MIN_PAYMENT, wallet_address=wallet_address, memo=new_key, fee_info=fee_info, instructions=no_key_instructions),
166
+ "payment": build_payment_object(network=network, min_payment=price_config["min_payment"], wallet_address=wallet_address, memo=new_key, fee_info=fee_info, instructions=no_key_instructions),
143
167
  },
144
- wallet_address=wallet_address, mint=self.usdc_mint, min_payment=MIN_PAYMENT,
168
+ wallet_address=wallet_address, mint=self.usdc_mint, min_payment=price_config["min_payment"],
145
169
  debug=self.debug, agent_key=new_key, resource=pathname,
170
+ extra_tier_opts=tier_x402_opts_list(price_config["pricing_tiers"], {"wallet_address": wallet_address, "mint": self.usdc_mint, "debug": self.debug, "agent_key": new_key, "resource": pathname}),
146
171
  )
147
172
 
148
173
  # Validate the key. Platform-issued (agp_) use verificationSecret;
@@ -160,7 +185,7 @@ class GateMiddleware:
160
185
  elif not is_valid_agent_key(agent_key, secret):
161
186
  return JsonResponse({"error": "forbidden", "message": "Invalid API key. Keys must be issued by this server."}, status=403)
162
187
 
163
- if not _agent_key_limiter.check(_client_ip(request)):
188
+ if not self.agent_key_rate_limiter.check(_client_ip(request)):
164
189
  return JsonResponse({"error": "rate_limited", "message": "Too many payment verification requests. Please wait and try again."}, status=429)
165
190
 
166
191
  if not wallet_address:
@@ -170,19 +195,24 @@ class GateMiddleware:
170
195
  if self.grant_store and self.grant_store.has(agent_key):
171
196
  return self.get_response(request)
172
197
 
173
- paid = verify_payment_on_chain(agent_key, wallet_address, self.rpc_url, self.usdc_mint, fee_info=fee_info)
198
+ scan_result = _scan_for_payment(agent_key, wallet_address, self.rpc_url, self.usdc_mint, min_payment=price_config["min_payment"], fee_info=fee_info, payment_cache=self.payment_cache)
199
+ paid = scan_result["paid"]
174
200
  if paid and self.grant_store:
175
- self.grant_store.add(agent_key)
201
+ tier = resolve_tier(scan_result["amount_paid"], price_config["pricing_tiers"])
202
+ duration_seconds = tier["duration_seconds"] if tier else price_config["access_duration"]
203
+ expires_at = (_time.time() + duration_seconds) if duration_seconds else None
204
+ self.grant_store.add(agent_key, expires_at=expires_at, tier=(tier["name"] if tier else None))
176
205
  if not paid:
177
206
  return _payment_required_response(
178
207
  {
179
208
  "error": "payment_required",
180
209
  "message": "Key is valid but payment has not been verified on-chain yet.",
181
210
  "your_key": agent_key,
182
- "payment": build_payment_object(network=network, min_payment=MIN_PAYMENT, wallet_address=wallet_address, memo=agent_key, fee_info=fee_info),
211
+ "payment": build_payment_object(network=network, min_payment=price_config["min_payment"], wallet_address=wallet_address, memo=agent_key, fee_info=fee_info),
183
212
  },
184
- wallet_address=wallet_address, mint=self.usdc_mint, min_payment=MIN_PAYMENT,
213
+ wallet_address=wallet_address, mint=self.usdc_mint, min_payment=price_config["min_payment"],
185
214
  debug=self.debug, agent_key=agent_key, resource=pathname,
215
+ extra_tier_opts=tier_x402_opts_list(price_config["pricing_tiers"], {"wallet_address": wallet_address, "mint": self.usdc_mint, "debug": self.debug, "agent_key": agent_key, "resource": pathname}),
186
216
  )
187
217
 
188
218
  return self.get_response(request)
@@ -193,7 +223,7 @@ class GateMiddleware:
193
223
  return self.get_response(request)
194
224
 
195
225
  # Rate-limit challenge page issuance to prevent unlimited nonce harvesting.
196
- if not _challenge_issue_limiter.check(client_ip):
226
+ if not self.challenge_issue_rate_limiter.check(client_ip):
197
227
  return JsonResponse({"error": "rate_limited", "message": "Too many requests. Please try again later."}, status=429)
198
228
 
199
229
  nonce = make_nonce(secret, client_ip)
@@ -208,7 +238,10 @@ class GateMiddleware:
208
238
  @require_POST
209
239
  def challenge_verify(request):
210
240
  client_ip = _client_ip(request)
211
- if not _challenge_limiter.check(client_ip):
241
+ # Optional: set AGENTPAYMENTS_CHALLENGE_VERIFY_RATE_LIMITER in settings.py
242
+ # to a Redis-backed limiter for multi-process deployments.
243
+ limiter = getattr(settings, "AGENTPAYMENTS_CHALLENGE_VERIFY_RATE_LIMITER", None) or _challenge_limiter
244
+ if not limiter.check(client_ip):
212
245
  return JsonResponse({"error": "rate_limited", "message": "Too many verification attempts. Please wait and try again."}, status=429)
213
246
  secret = settings.CHALLENGE_SECRET
214
247
  nonce = request.POST.get("nonce", "")[:MAX_NONCE_LENGTH]
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
2
4
 
3
5
  from starlette.middleware.base import BaseHTTPMiddleware
@@ -10,9 +12,11 @@ from .crypto import generate_agent_key, is_valid_agent_key
10
12
  from .detection import is_browser_from_headers, is_public_path
11
13
  from .ratelimit import _challenge_limiter, _agent_key_limiter, _challenge_issue_limiter
12
14
  from .crawler import is_verified_crawler
13
- from .solana import MIN_PAYMENT, RPC_DEVNET, RPC_MAINNET, USDC_MINT_DEVNET, USDC_MINT_MAINNET, is_valid_solana_address, verify_payment_on_chain
14
- from .x402 import build_payment_object, build_payment_requirements, enrich_402_body, payment_required_header
15
+ from .solana import MIN_PAYMENT, RPC_DEVNET, RPC_MAINNET, USDC_MINT_DEVNET, USDC_MINT_MAINNET, is_valid_solana_address, _scan_for_payment
16
+ from .x402 import build_payment_object, build_payment_requirements, enrich_402_body, payment_required_header, tier_x402_opts_list
15
17
  from .platform_client import HOSTED_KEY_PREFIX, PlatformClient, is_valid_hosted_key
18
+ from .pricing import normalize_price_config, build_routes_table, resolve_route_config, resolve_tier
19
+ import time as _time
16
20
 
17
21
  import json as _json
18
22
  from pathlib import Path as _Path
@@ -23,11 +27,12 @@ MAX_FP_LENGTH = _constants["MAX_FP_LENGTH"]
23
27
  MAX_POW_LENGTH = _constants["MAX_POW_LENGTH"]
24
28
 
25
29
 
26
- def _payment_required_response(body: dict, *, wallet_address: str, mint: str, min_payment: float, debug: bool, agent_key: str = "", resource: str = "") -> JSONResponse:
30
+ def _payment_required_response(body: dict, *, wallet_address: str, mint: str, min_payment: float, debug: bool, agent_key: str = "", resource: str = "", extra_tier_opts: list[dict] | None = None) -> JSONResponse:
27
31
  """Return a Starlette JSONResponse (402) enriched with x402-standard fields and header."""
28
32
  pay_req = build_payment_requirements(wallet_address=wallet_address, mint=mint, min_payment=min_payment, debug=debug, agent_key=agent_key, resource=resource)
33
+ extra_reqs = [build_payment_requirements(**opts) for opts in (extra_tier_opts or [])]
29
34
  return JSONResponse(
30
- content=enrich_402_body(body, pay_req),
35
+ content=enrich_402_body(body, pay_req, extra_reqs),
31
36
  status_code=402,
32
37
  headers={"X-PAYMENT-REQUIRED": payment_required_header(pay_req)},
33
38
  )
@@ -41,7 +46,7 @@ def _client_ip(request: Request) -> str:
41
46
 
42
47
 
43
48
  class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
44
- def __init__(self, app, *, challenge_secret: str, home_wallet_address: str, debug: bool = True, solana_rpc_url=None, usdc_mint: str = "", pow_difficulty: int = POW_DIFFICULTY, verify_crawlers: bool = True, grant_store=None, require_https: bool = None, api_key: str = None, platform_url: str = None):
49
+ def __init__(self, app, *, challenge_secret: str, home_wallet_address: str, debug: bool = True, solana_rpc_url=None, usdc_mint: str = "", min_payment: float = MIN_PAYMENT, access_duration: float | None = None, pricing_tiers: list[dict] | None = None, routes: list[dict] | None = None, pow_difficulty: int = POW_DIFFICULTY, verify_crawlers: bool = True, grant_store=None, require_https: bool = None, api_key: str = None, platform_url: str = None, agent_key_rate_limiter=None, challenge_issue_rate_limiter=None, payment_cache=None):
45
50
  super().__init__(app)
46
51
  if challenge_secret == "default-secret-change-me":
47
52
  import logging
@@ -58,14 +63,27 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
58
63
  raw_rpc = solana_rpc_url or (RPC_DEVNET if debug else RPC_MAINNET)
59
64
  self.solana_rpc_url = raw_rpc if isinstance(raw_rpc, list) else [raw_rpc]
60
65
  self.usdc_mint = usdc_mint or (USDC_MINT_DEVNET if debug else USDC_MINT_MAINNET)
66
+ # Price/duration/tier resolution is static for this middleware instance —
67
+ # precompute once rather than per-request.
68
+ self.base_price_config = normalize_price_config(min_payment, access_duration, pricing_tiers)
69
+ self.routes_table = build_routes_table(routes, min_payment, access_duration, pricing_tiers)
61
70
  self.pow_difficulty = pow_difficulty
62
71
  self.verify_crawlers = verify_crawlers
63
72
  self.grant_store = grant_store
64
73
  self.require_https = (not debug) if require_https is None else require_https
65
74
  self._platform_client = PlatformClient(api_key, platform_url) if api_key else None
75
+ # Pluggable rate limiter / payment cache — default to the built-in
76
+ # in-memory singletons (fine for single-process deployments). For
77
+ # multi-process (e.g. gunicorn -w 4), pass Redis-backed instances
78
+ # from agentpayments_python.redis_store so state is shared across
79
+ # workers instead of each worker enforcing its own independent limit.
80
+ self.agent_key_rate_limiter = agent_key_rate_limiter or _agent_key_limiter
81
+ self.challenge_issue_rate_limiter = challenge_issue_rate_limiter or _challenge_issue_limiter
82
+ self.payment_cache = payment_cache
66
83
 
67
84
  async def dispatch(self, request: Request, call_next):
68
85
  path = request.url.path
86
+ price_config = resolve_route_config(path, self.routes_table, self.base_price_config)
69
87
  if is_public_path(path):
70
88
  return await call_next(request)
71
89
 
@@ -112,21 +130,22 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
112
130
  new_key = generate_agent_key(self.challenge_secret)
113
131
  if fee_info:
114
132
  no_key_instructions = (
115
- f'Send {MIN_PAYMENT} USDC on Solana {network} to {self.home_wallet_address} with memo "{new_key}", '
133
+ f'Send {price_config["min_payment"]} USDC on Solana {network} to {self.home_wallet_address} with memo "{new_key}", '
116
134
  f'AND in the SAME transaction send the platform fee (see platform_fee below) to {fee_info["wallet"]}. '
117
135
  f'Then include the header X-Agent-Key: {new_key} on all subsequent requests.'
118
136
  )
119
137
  else:
120
- no_key_instructions = f'Send {MIN_PAYMENT} USDC on Solana {network} to {self.home_wallet_address} with memo "{new_key}". Then include the header X-Agent-Key: {new_key} on all subsequent requests.'
138
+ no_key_instructions = f'Send {price_config["min_payment"]} USDC on Solana {network} to {self.home_wallet_address} with memo "{new_key}". Then include the header X-Agent-Key: {new_key} on all subsequent requests.'
121
139
  return _payment_required_response(
122
140
  {
123
141
  "error": "payment_required",
124
142
  "message": "Access requires a paid API key. A key has been generated for you below. Send a USDC payment on Solana with this key as the memo to activate it, then retry your request with the X-Agent-Key header.",
125
143
  "your_key": new_key,
126
- "payment": build_payment_object(network=network, min_payment=MIN_PAYMENT, wallet_address=self.home_wallet_address, memo=new_key, fee_info=fee_info, instructions=no_key_instructions),
144
+ "payment": build_payment_object(network=network, min_payment=price_config["min_payment"], wallet_address=self.home_wallet_address, memo=new_key, fee_info=fee_info, instructions=no_key_instructions),
127
145
  },
128
- wallet_address=self.home_wallet_address, mint=self.usdc_mint, min_payment=MIN_PAYMENT,
146
+ wallet_address=self.home_wallet_address, mint=self.usdc_mint, min_payment=price_config["min_payment"],
129
147
  debug=self.debug, agent_key=new_key, resource=path,
148
+ extra_tier_opts=tier_x402_opts_list(price_config["pricing_tiers"], {"wallet_address": self.home_wallet_address, "mint": self.usdc_mint, "debug": self.debug, "agent_key": new_key, "resource": path}),
130
149
  )
131
150
 
132
151
  if agent_key.startswith(HOSTED_KEY_PREFIX):
@@ -143,7 +162,7 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
143
162
  elif not is_valid_agent_key(agent_key, self.challenge_secret):
144
163
  return JSONResponse({"error": "forbidden", "message": "Invalid API key."}, status_code=403)
145
164
 
146
- if not _agent_key_limiter.check(_client_ip(request)):
165
+ if not self.agent_key_rate_limiter.check(_client_ip(request)):
147
166
  return JSONResponse({"error": "rate_limited", "message": "Too many payment verification requests. Please wait and try again."}, status_code=429)
148
167
 
149
168
  if not self.home_wallet_address:
@@ -153,23 +172,28 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
153
172
  if self.grant_store and self.grant_store.has(agent_key):
154
173
  return await call_next(request)
155
174
 
156
- # verify_payment_on_chain is synchronous (uses requests). Run it in a
175
+ # _scan_for_payment is synchronous (uses requests). Run it in a
157
176
  # thread-pool executor so it doesn't block the async event loop.
158
- paid = await loop.run_in_executor(
159
- None, lambda: verify_payment_on_chain(agent_key, self.home_wallet_address, self.solana_rpc_url, self.usdc_mint, fee_info=fee_info)
177
+ scan_result = await loop.run_in_executor(
178
+ None, lambda: _scan_for_payment(agent_key, self.home_wallet_address, self.solana_rpc_url, self.usdc_mint, min_payment=price_config["min_payment"], fee_info=fee_info, payment_cache=self.payment_cache)
160
179
  )
180
+ paid = scan_result["paid"]
161
181
  if paid and self.grant_store:
162
- self.grant_store.add(agent_key)
182
+ tier = resolve_tier(scan_result["amount_paid"], price_config["pricing_tiers"])
183
+ duration_seconds = tier["duration_seconds"] if tier else price_config["access_duration"]
184
+ expires_at = (_time.time() + duration_seconds) if duration_seconds else None
185
+ self.grant_store.add(agent_key, expires_at=expires_at, tier=(tier["name"] if tier else None))
163
186
  if not paid:
164
187
  return _payment_required_response(
165
188
  {
166
189
  "error": "payment_required",
167
190
  "message": "Key is valid but payment has not been verified on-chain yet.",
168
191
  "your_key": agent_key,
169
- "payment": build_payment_object(network=network, min_payment=MIN_PAYMENT, wallet_address=self.home_wallet_address, memo=agent_key, fee_info=fee_info),
192
+ "payment": build_payment_object(network=network, min_payment=price_config["min_payment"], wallet_address=self.home_wallet_address, memo=agent_key, fee_info=fee_info),
170
193
  },
171
- wallet_address=self.home_wallet_address, mint=self.usdc_mint, min_payment=MIN_PAYMENT,
194
+ wallet_address=self.home_wallet_address, mint=self.usdc_mint, min_payment=price_config["min_payment"],
172
195
  debug=self.debug, agent_key=agent_key, resource=path,
196
+ extra_tier_opts=tier_x402_opts_list(price_config["pricing_tiers"], {"wallet_address": self.home_wallet_address, "mint": self.usdc_mint, "debug": self.debug, "agent_key": agent_key, "resource": path}),
173
197
  )
174
198
 
175
199
  return await call_next(request)
@@ -179,7 +203,7 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
179
203
  if is_valid_cookie_value(cookie_val, self.challenge_secret, client_ip):
180
204
  return await call_next(request)
181
205
 
182
- if not _challenge_issue_limiter.check(client_ip):
206
+ if not self.challenge_issue_rate_limiter.check(client_ip):
183
207
  return JSONResponse({"error": "rate_limited", "message": "Too many requests. Please try again later."}, status_code=429)
184
208
 
185
209
  nonce = make_nonce(self.challenge_secret, client_ip)
@@ -190,9 +214,10 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
190
214
  })
191
215
 
192
216
 
193
- async def challenge_verify_endpoint(request: Request, challenge_secret: str, pow_difficulty: int = POW_DIFFICULTY):
217
+ async def challenge_verify_endpoint(request: Request, challenge_secret: str, pow_difficulty: int = POW_DIFFICULTY, rate_limiter=None):
194
218
  client_ip = _client_ip(request)
195
- if not _challenge_limiter.check(client_ip):
219
+ limiter = rate_limiter or _challenge_limiter
220
+ if not limiter.check(client_ip):
196
221
  return JSONResponse({"error": "rate_limited", "message": "Too many verification attempts. Please wait and try again."}, status_code=429)
197
222
  form = await request.form()
198
223
  nonce = str(form.get("nonce", ""))[:MAX_NONCE_LENGTH]