agentpayments-python 0.2.0__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 (28) hide show
  1. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/PKG-INFO +37 -1
  2. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/README.md +36 -0
  3. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/django_adapter.py +15 -4
  4. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/fastapi_adapter.py +15 -6
  5. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/flask_adapter.py +13 -5
  6. agentpayments_python-0.3.0/agentpayments_python/redis_store.py +128 -0
  7. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/solana.py +13 -6
  8. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/PKG-INFO +37 -1
  9. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/SOURCES.txt +1 -0
  10. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/pyproject.toml +1 -1
  11. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/tests/test_core.py +216 -0
  12. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/LICENSE +0 -0
  13. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/__init__.py +0 -0
  14. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/challenge.py +0 -0
  15. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/constants.json +0 -0
  16. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/cookies.py +0 -0
  17. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/crawler.py +0 -0
  18. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/crypto.py +0 -0
  19. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/detection.py +0 -0
  20. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/grant_store.py +0 -0
  21. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/platform_client.py +0 -0
  22. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/pricing.py +0 -0
  23. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/ratelimit.py +0 -0
  24. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/x402.py +0 -0
  25. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/dependency_links.txt +0 -0
  26. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/requires.txt +0 -0
  27. {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/top_level.txt +0 -0
  28. {agentpayments_python-0.2.0 → 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.2.0
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
@@ -123,6 +123,9 @@ register_agentpayments(
123
123
  | `debug` | No | `True` | `True` = devnet. `False` = mainnet + strict mode. |
124
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. |
125
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). |
126
129
 
127
130
  Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`, `settings.AGENTPAYMENTS_API_KEY`). FastAPI and Flask accept them as constructor arguments.
128
131
 
@@ -164,6 +167,38 @@ AGENTPAYMENTS_ROUTES = [...]
164
167
 
165
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.
166
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
+
167
202
  ## Security Features
168
203
 
169
204
  - **Timing-safe HMAC comparison** — uses `hmac.compare_digest()` for all signature checks
@@ -190,6 +225,7 @@ agentpayments_python/
190
225
  ratelimit.py Shared IP-based rate limiter
191
226
  grant_store.py Durable paid-key persistence (expiry, revocation)
192
227
  pricing.py Pricing-tier / access-duration / per-route resolution
228
+ redis_store.py Redis-backed rate limiter + payment cache (multi-process)
193
229
  ```
194
230
 
195
231
  ## Notes
@@ -86,6 +86,9 @@ register_agentpayments(
86
86
  | `debug` | No | `True` | `True` = devnet. `False` = mainnet + strict mode. |
87
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
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). |
89
92
 
90
93
  Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`, `settings.AGENTPAYMENTS_API_KEY`). FastAPI and Flask accept them as constructor arguments.
91
94
 
@@ -127,6 +130,38 @@ AGENTPAYMENTS_ROUTES = [...]
127
130
 
128
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.
129
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
+
130
165
  ## Security Features
131
166
 
132
167
  - **Timing-safe HMAC comparison** — uses `hmac.compare_digest()` for all signature checks
@@ -153,6 +188,7 @@ agentpayments_python/
153
188
  ratelimit.py Shared IP-based rate limiter
154
189
  grant_store.py Durable paid-key persistence (expiry, revocation)
155
190
  pricing.py Pricing-tier / access-duration / per-route resolution
191
+ redis_store.py Redis-backed rate limiter + payment cache (multi-process)
156
192
  ```
157
193
 
158
194
  ## Notes
@@ -89,6 +89,14 @@ class GateMiddleware:
89
89
  # Optional grant store for durable paid-key persistence. Set
90
90
  # AGENTPAYMENTS_GRANT_STORE to a GrantStore instance in settings.py.
91
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)
92
100
 
93
101
  def __call__(self, request):
94
102
  secret = self.secret
@@ -177,7 +185,7 @@ class GateMiddleware:
177
185
  elif not is_valid_agent_key(agent_key, secret):
178
186
  return JsonResponse({"error": "forbidden", "message": "Invalid API key. Keys must be issued by this server."}, status=403)
179
187
 
180
- if not _agent_key_limiter.check(_client_ip(request)):
188
+ if not self.agent_key_rate_limiter.check(_client_ip(request)):
181
189
  return JsonResponse({"error": "rate_limited", "message": "Too many payment verification requests. Please wait and try again."}, status=429)
182
190
 
183
191
  if not wallet_address:
@@ -187,7 +195,7 @@ class GateMiddleware:
187
195
  if self.grant_store and self.grant_store.has(agent_key):
188
196
  return self.get_response(request)
189
197
 
190
- 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)
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)
191
199
  paid = scan_result["paid"]
192
200
  if paid and self.grant_store:
193
201
  tier = resolve_tier(scan_result["amount_paid"], price_config["pricing_tiers"])
@@ -215,7 +223,7 @@ class GateMiddleware:
215
223
  return self.get_response(request)
216
224
 
217
225
  # Rate-limit challenge page issuance to prevent unlimited nonce harvesting.
218
- if not _challenge_issue_limiter.check(client_ip):
226
+ if not self.challenge_issue_rate_limiter.check(client_ip):
219
227
  return JsonResponse({"error": "rate_limited", "message": "Too many requests. Please try again later."}, status=429)
220
228
 
221
229
  nonce = make_nonce(secret, client_ip)
@@ -230,7 +238,10 @@ class GateMiddleware:
230
238
  @require_POST
231
239
  def challenge_verify(request):
232
240
  client_ip = _client_ip(request)
233
- 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):
234
245
  return JsonResponse({"error": "rate_limited", "message": "Too many verification attempts. Please wait and try again."}, status=429)
235
246
  secret = settings.CHALLENGE_SECRET
236
247
  nonce = request.POST.get("nonce", "")[:MAX_NONCE_LENGTH]
@@ -46,7 +46,7 @@ def _client_ip(request: Request) -> str:
46
46
 
47
47
 
48
48
  class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
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):
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):
50
50
  super().__init__(app)
51
51
  if challenge_secret == "default-secret-change-me":
52
52
  import logging
@@ -72,6 +72,14 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
72
72
  self.grant_store = grant_store
73
73
  self.require_https = (not debug) if require_https is None else require_https
74
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
75
83
 
76
84
  async def dispatch(self, request: Request, call_next):
77
85
  path = request.url.path
@@ -154,7 +162,7 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
154
162
  elif not is_valid_agent_key(agent_key, self.challenge_secret):
155
163
  return JSONResponse({"error": "forbidden", "message": "Invalid API key."}, status_code=403)
156
164
 
157
- if not _agent_key_limiter.check(_client_ip(request)):
165
+ if not self.agent_key_rate_limiter.check(_client_ip(request)):
158
166
  return JSONResponse({"error": "rate_limited", "message": "Too many payment verification requests. Please wait and try again."}, status_code=429)
159
167
 
160
168
  if not self.home_wallet_address:
@@ -167,7 +175,7 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
167
175
  # _scan_for_payment is synchronous (uses requests). Run it in a
168
176
  # thread-pool executor so it doesn't block the async event loop.
169
177
  scan_result = await loop.run_in_executor(
170
- 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)
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)
171
179
  )
172
180
  paid = scan_result["paid"]
173
181
  if paid and self.grant_store:
@@ -195,7 +203,7 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
195
203
  if is_valid_cookie_value(cookie_val, self.challenge_secret, client_ip):
196
204
  return await call_next(request)
197
205
 
198
- if not _challenge_issue_limiter.check(client_ip):
206
+ if not self.challenge_issue_rate_limiter.check(client_ip):
199
207
  return JSONResponse({"error": "rate_limited", "message": "Too many requests. Please try again later."}, status_code=429)
200
208
 
201
209
  nonce = make_nonce(self.challenge_secret, client_ip)
@@ -206,9 +214,10 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
206
214
  })
207
215
 
208
216
 
209
- 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):
210
218
  client_ip = _client_ip(request)
211
- if not _challenge_limiter.check(client_ip):
219
+ limiter = rate_limiter or _challenge_limiter
220
+ if not limiter.check(client_ip):
212
221
  return JSONResponse({"error": "rate_limited", "message": "Too many verification attempts. Please wait and try again."}, status_code=429)
213
222
  form = await request.form()
214
223
  nonce = str(form.get("nonce", ""))[:MAX_NONCE_LENGTH]
@@ -39,7 +39,15 @@ def _client_ip() -> str:
39
39
  return request.headers.get("X-Forwarded-For", "").split(",")[0].strip() or request.remote_addr or "unknown"
40
40
 
41
41
 
42
- def register_agentpayments(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):
42
+ def register_agentpayments(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, challenge_verify_rate_limiter=None, agent_key_rate_limiter=None, challenge_issue_rate_limiter=None, payment_cache=None):
43
+ # Pluggable rate limiters / payment cache — default to the built-in
44
+ # in-memory singletons (fine for single-process deployments). For
45
+ # multi-process (e.g. gunicorn -w 4), pass Redis-backed instances from
46
+ # agentpayments_python.redis_store so state is shared across workers
47
+ # instead of each worker enforcing its own independent limit.
48
+ _challenge_verify_limiter = challenge_verify_rate_limiter or _challenge_limiter
49
+ _agent_key_rate_limiter = agent_key_rate_limiter or _agent_key_limiter
50
+ _challenge_issue_rate_limiter = challenge_issue_rate_limiter or _challenge_issue_limiter
43
51
  if challenge_secret == "default-secret-change-me":
44
52
  import logging
45
53
  logger = logging.getLogger("agentpayments")
@@ -133,13 +141,13 @@ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: s
133
141
  return jsonify({"error": "forbidden", "message": "Invalid API key."}), 403
134
142
  elif not is_valid_agent_key(key, challenge_secret):
135
143
  return jsonify({"error": "forbidden", "message": "Invalid API key."}), 403
136
- if not _agent_key_limiter.check(_client_ip()):
144
+ if not _agent_key_rate_limiter.check(_client_ip()):
137
145
  return jsonify({"error": "rate_limited", "message": "Too many payment verification requests. Please wait and try again."}), 429
138
146
  if not home_wallet_address:
139
147
  return jsonify({"error": "server_error", "message": "Payment verification unavailable."}), 500
140
148
  if grant_store and grant_store.has(key):
141
149
  return None
142
- scan_result = _scan_for_payment(key, home_wallet_address, rpc_url, mint, min_payment=price_config["min_payment"], fee_info=fee_info)
150
+ scan_result = _scan_for_payment(key, home_wallet_address, rpc_url, mint, min_payment=price_config["min_payment"], fee_info=fee_info, payment_cache=payment_cache)
143
151
  paid = scan_result["paid"]
144
152
  if paid and grant_store:
145
153
  tier = resolve_tier(scan_result["amount_paid"], price_config["pricing_tiers"])
@@ -165,7 +173,7 @@ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: s
165
173
  if is_valid_cookie_value(cookie_val, challenge_secret, client_ip):
166
174
  return None
167
175
 
168
- if not _challenge_issue_limiter.check(client_ip):
176
+ if not _challenge_issue_rate_limiter.check(client_ip):
169
177
  return make_response(_flask_json.dumps({"error": "rate_limited", "message": "Too many requests. Please try again later."}, indent=2), 429, {"Content-Type": "application/json"})
170
178
 
171
179
  nonce = make_nonce(challenge_secret, client_ip)
@@ -179,7 +187,7 @@ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: s
179
187
  @app.post("/__challenge/verify")
180
188
  def _verify():
181
189
  client_ip = _client_ip()
182
- if not _challenge_limiter.check(client_ip):
190
+ if not _challenge_verify_limiter.check(client_ip):
183
191
  return jsonify({"error": "rate_limited", "message": "Too many verification attempts. Please wait and try again."}), 429
184
192
  nonce = request.form.get("nonce", "")[:MAX_NONCE_LENGTH]
185
193
  return_to = request.form.get("return_to", "/")[:MAX_RETURN_TO_LENGTH]
@@ -0,0 +1,128 @@
1
+ """
2
+ RedisStore -- pluggable state backend for the Python SDK using Redis.
3
+
4
+ Designed for multi-process Django/FastAPI/Flask deployments (e.g.
5
+ `gunicorn -w 4`) where the built-in in-memory rate limiters and payment
6
+ cache are per-process and therefore far less effective than intended: a
7
+ paid key's positive/negative cache result, and each IP's rate-limit
8
+ count, only apply within the one worker process that happened to handle
9
+ that request.
10
+
11
+ Usage:
12
+
13
+ import redis
14
+ from agentpayments_python.redis_store import create_redis_store
15
+
16
+ r = redis.Redis.from_url(os.environ["REDIS_URL"])
17
+ store = create_redis_store(r)
18
+
19
+ # FastAPI / Flask constructor kwargs
20
+ register_agentpayments(app, ...,
21
+ agent_key_rate_limiter=store["agent_key_rate_limiter"],
22
+ challenge_verify_rate_limiter=store["challenge_verify_rate_limiter"],
23
+ challenge_issue_rate_limiter=store["challenge_issue_rate_limiter"],
24
+ payment_cache=store["payment_cache"],
25
+ )
26
+
27
+ # Django settings.py
28
+ AGENTPAYMENTS_AGENT_KEY_RATE_LIMITER = store["agent_key_rate_limiter"]
29
+ AGENTPAYMENTS_CHALLENGE_VERIFY_RATE_LIMITER = store["challenge_verify_rate_limiter"]
30
+ AGENTPAYMENTS_CHALLENGE_ISSUE_RATE_LIMITER = store["challenge_issue_rate_limiter"]
31
+ AGENTPAYMENTS_PAYMENT_CACHE = store["payment_cache"]
32
+
33
+ Duck-typed against any client exposing `eval(script, numkeys, *keys_and_args)`,
34
+ `get(key)`, and `set(key, value, ex=ttl_seconds)` -- redis-py's `Redis`
35
+ client satisfies this directly. This module has no hard dependency on the
36
+ `redis` package itself (mirrors sdk/node/redis-store.js) -- bring your own
37
+ client.
38
+
39
+ Atomicity: RateLimiter uses a Lua INCR+EXPIRE script executed atomically
40
+ server-side, so there is no read-modify-write race under concurrent
41
+ requests. PaymentCache uses plain SET EX.
42
+
43
+ Fails open on Redis errors -- a Redis outage must not block legitimate
44
+ traffic (rate limiter allows the request through) or brick payment
45
+ verification (a cache error just falls through to a normal chain scan).
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import logging
51
+
52
+ logger = logging.getLogger("agentpayments")
53
+
54
+ RATE_LIMIT_WINDOW = 60 # seconds
55
+ RATE_LIMIT_MAX = 20
56
+ AGENT_KEY_RATE_LIMIT_MAX = 10
57
+ CHALLENGE_ISSUE_RATE_LIMIT_MAX = 30
58
+
59
+ # Atomically increment and set a TTL only on the first hit in the window.
60
+ # Returns the current count after increment.
61
+ _INCR_SCRIPT = """
62
+ local current = redis.call('INCR', KEYS[1])
63
+ if current == 1 then
64
+ redis.call('EXPIRE', KEYS[1], ARGV[1])
65
+ end
66
+ return current
67
+ """
68
+
69
+
70
+ class RateLimiter:
71
+ """Drop-in replacement for agentpayments_python.ratelimit.RateLimiter."""
72
+
73
+ def __init__(self, redis_client, window: int = RATE_LIMIT_WINDOW, max_hits: int = RATE_LIMIT_MAX, key_prefix: str = "agp:rl:"):
74
+ self._redis = redis_client
75
+ self._window = window
76
+ self._max = max_hits
77
+ self._prefix = key_prefix
78
+
79
+ def check(self, key: str) -> bool:
80
+ """Returns True if the request should be allowed, False if rate-limited."""
81
+ try:
82
+ count = self._redis.eval(_INCR_SCRIPT, 1, f"{self._prefix}{key}", self._window)
83
+ return int(count) <= self._max
84
+ except Exception as exc:
85
+ logger.error("[agentpayments] RedisStore.RateLimiter error: %s", exc)
86
+ return True # fail open — don't block legitimate traffic
87
+
88
+
89
+ class PaymentCache:
90
+ """Drop-in replacement for the module-level _payment_cache in solana.py."""
91
+
92
+ def __init__(self, redis_client, key_prefix: str = "agp:pay:"):
93
+ self._redis = redis_client
94
+ self._prefix = key_prefix
95
+
96
+ def get(self, agent_key: str):
97
+ """Returns True, False, or None (not cached / expired / error)."""
98
+ try:
99
+ val = self._redis.get(f"{self._prefix}{agent_key}")
100
+ if val is None:
101
+ return None
102
+ if isinstance(val, bytes):
103
+ val = val.decode()
104
+ return val == "1"
105
+ except Exception as exc:
106
+ logger.error("[agentpayments] RedisStore.PaymentCache.get error: %s", exc)
107
+ return None
108
+
109
+ def set(self, agent_key: str, value: bool, ttl: int) -> None:
110
+ """ttl is in seconds, matching solana.py's _PaymentCache.set signature."""
111
+ try:
112
+ self._redis.set(f"{self._prefix}{agent_key}", "1" if value else "0", ex=max(1, int(ttl)))
113
+ except Exception as exc:
114
+ logger.error("[agentpayments] RedisStore.PaymentCache.set error: %s", exc)
115
+
116
+
117
+ def create_redis_store(redis_client) -> dict:
118
+ """
119
+ Convenience factory: create pre-configured rate limiters matching the
120
+ built-in defaults (challenge verify 20/min, agent-key 10/min, challenge
121
+ issuance 30/min), plus a shared payment cache.
122
+ """
123
+ return {
124
+ "challenge_verify_rate_limiter": RateLimiter(redis_client, max_hits=RATE_LIMIT_MAX, key_prefix="agp:rl:cv:"),
125
+ "agent_key_rate_limiter": RateLimiter(redis_client, max_hits=AGENT_KEY_RATE_LIMIT_MAX, key_prefix="agp:rl:ak:"),
126
+ "challenge_issue_rate_limiter": RateLimiter(redis_client, max_hits=CHALLENGE_ISSUE_RATE_LIMIT_MAX, key_prefix="agp:rl:ci:"),
127
+ "payment_cache": PaymentCache(redis_client),
128
+ }
@@ -98,17 +98,23 @@ def is_valid_solana_address(address: str) -> bool:
98
98
  return bool(address and BASE58_RE.match(address))
99
99
 
100
100
 
101
- def verify_payment_on_chain(agent_key: str, wallet_address: str, rpc_url, usdc_mint: str, min_payment: float = MIN_PAYMENT, fee_info: dict | None = None) -> bool:
101
+ def verify_payment_on_chain(agent_key: str, wallet_address: str, rpc_url, usdc_mint: str, min_payment: float = MIN_PAYMENT, fee_info: dict | None = None, payment_cache=None) -> bool:
102
102
  """
103
103
  Verify payment on-chain. Returns true/false only — the stable, tested
104
104
  public API. See _scan_for_payment below for the amount-returning variant
105
105
  used internally for pricing-tier resolution.
106
+
107
+ payment_cache: optional cache object with the same interface as the
108
+ module-level default (get(key) -> True/False/None, set(key, value, ttl)).
109
+ Pass a agentpayments_python.redis_store.PaymentCache for multi-process
110
+ deployments where the default in-memory cache (per-process) is
111
+ ineffective. Defaults to the module-level singleton.
106
112
  """
107
- result = _scan_for_payment(agent_key, wallet_address, rpc_url, usdc_mint, min_payment=min_payment, fee_info=fee_info)
113
+ result = _scan_for_payment(agent_key, wallet_address, rpc_url, usdc_mint, min_payment=min_payment, fee_info=fee_info, payment_cache=payment_cache)
108
114
  return result["paid"]
109
115
 
110
116
 
111
- def _scan_for_payment(agent_key: str, wallet_address: str, rpc_url, usdc_mint: str, min_payment: float = MIN_PAYMENT, fee_info: dict | None = None) -> dict:
117
+ def _scan_for_payment(agent_key: str, wallet_address: str, rpc_url, usdc_mint: str, min_payment: float = MIN_PAYMENT, fee_info: dict | None = None, payment_cache=None) -> dict:
112
118
  """
113
119
  Scans the chain for a matching payment, same as verify_payment_on_chain,
114
120
  but also returns the actual amount paid (decimal USDC) so callers can
@@ -120,12 +126,13 @@ def _scan_for_payment(agent_key: str, wallet_address: str, rpc_url, usdc_mint: s
120
126
  the vendor payment must also carry a USDC transfer to fee_info["wallet"] of at
121
127
  least min_payment * rate_pct / 100, or the payment is treated as unverified.
122
128
  """
129
+ cache = payment_cache if payment_cache is not None else _payment_cache
123
130
  # Normalise to list so _rpc_call_with_fallback always gets a list.
124
131
  rpc_urls: list[str] = rpc_url if isinstance(rpc_url, list) else [rpc_url]
125
132
  min_payment_micro = round(min_payment * 1_000_000)
126
133
  not_paid = {"paid": False, "amount_paid": None}
127
134
 
128
- cached = _payment_cache.get(agent_key)
135
+ cached = cache.get(agent_key)
129
136
  if cached is True:
130
137
  return {"paid": True, "amount_paid": None} # cache doesn't retain the amount
131
138
  if cached is False:
@@ -229,10 +236,10 @@ def _scan_for_payment(agent_key: str, wallet_address: str, rpc_url, usdc_mint: s
229
236
  has_fee_payment = True
230
237
 
231
238
  if has_memo and has_payment and has_fee_payment:
232
- _payment_cache.set(agent_key, True, PAYMENT_CACHE_TTL)
239
+ cache.set(agent_key, True, PAYMENT_CACHE_TTL)
233
240
  return {"paid": True, "amount_paid": matched_amount_micro / 1_000_000}
234
241
  except Exception:
235
242
  logger.exception("[gate] Solana RPC error")
236
243
 
237
- _payment_cache.set(agent_key, False, NEGATIVE_CACHE_TTL)
244
+ cache.set(agent_key, False, NEGATIVE_CACHE_TTL)
238
245
  return not_paid
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentpayments-python
3
- Version: 0.2.0
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
@@ -123,6 +123,9 @@ register_agentpayments(
123
123
  | `debug` | No | `True` | `True` = devnet. `False` = mainnet + strict mode. |
124
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. |
125
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). |
126
129
 
127
130
  Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`, `settings.AGENTPAYMENTS_API_KEY`). FastAPI and Flask accept them as constructor arguments.
128
131
 
@@ -164,6 +167,38 @@ AGENTPAYMENTS_ROUTES = [...]
164
167
 
165
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.
166
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
+
167
202
  ## Security Features
168
203
 
169
204
  - **Timing-safe HMAC comparison** — uses `hmac.compare_digest()` for all signature checks
@@ -190,6 +225,7 @@ agentpayments_python/
190
225
  ratelimit.py Shared IP-based rate limiter
191
226
  grant_store.py Durable paid-key persistence (expiry, revocation)
192
227
  pricing.py Pricing-tier / access-duration / per-route resolution
228
+ redis_store.py Redis-backed rate limiter + payment cache (multi-process)
193
229
  ```
194
230
 
195
231
  ## Notes
@@ -15,6 +15,7 @@ agentpayments_python/grant_store.py
15
15
  agentpayments_python/platform_client.py
16
16
  agentpayments_python/pricing.py
17
17
  agentpayments_python/ratelimit.py
18
+ agentpayments_python/redis_store.py
18
19
  agentpayments_python/solana.py
19
20
  agentpayments_python/x402.py
20
21
  agentpayments_python.egg-info/PKG-INFO
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentpayments-python"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "AgentPayments gate for Python web frameworks — charge AI agents USDC on Solana before they can access your API"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -38,6 +38,11 @@ from agentpayments_python.challenge import (
38
38
  from agentpayments_python.detection import is_browser_from_headers, is_public_path
39
39
  from agentpayments_python.ratelimit import RateLimiter
40
40
  from agentpayments_python.grant_store import FileGrantStore, MemoryGrantStore
41
+ from agentpayments_python.redis_store import (
42
+ RateLimiter as RedisRateLimiter,
43
+ PaymentCache as RedisPaymentCache,
44
+ create_redis_store,
45
+ )
41
46
  from agentpayments_python.solana import (
42
47
  NEGATIVE_CACHE_TTL,
43
48
  PAYMENT_CACHE_TTL,
@@ -474,6 +479,108 @@ class TestPaymentCache:
474
479
  assert cache.get("d") is True
475
480
 
476
481
 
482
+ # ─── redis_store: pluggable state backend for multi-process deployments ────
483
+
484
+ class FakeRedis:
485
+ """Minimal fake standing in for a redis-py client: eval/get/set only."""
486
+
487
+ def __init__(self):
488
+ self._counters = {}
489
+ self._values = {}
490
+ self.fail = False
491
+
492
+ def eval(self, script, numkeys, key, ttl):
493
+ if self.fail:
494
+ raise ConnectionError("redis unavailable")
495
+ self._counters[key] = self._counters.get(key, 0) + 1
496
+ return self._counters[key]
497
+
498
+ def get(self, key):
499
+ if self.fail:
500
+ raise ConnectionError("redis unavailable")
501
+ return self._values.get(key)
502
+
503
+ def set(self, key, value, ex=None):
504
+ if self.fail:
505
+ raise ConnectionError("redis unavailable")
506
+ self._values[key] = value.encode() if isinstance(value, str) else value
507
+
508
+
509
+ class TestRedisRateLimiter:
510
+ def test_allows_under_the_limit(self):
511
+ limiter = RedisRateLimiter(FakeRedis(), max_hits=3)
512
+ assert limiter.check("1.2.3.4")
513
+ assert limiter.check("1.2.3.4")
514
+ assert limiter.check("1.2.3.4")
515
+
516
+ def test_denies_over_the_limit(self):
517
+ limiter = RedisRateLimiter(FakeRedis(), max_hits=3)
518
+ for _ in range(3):
519
+ assert limiter.check("1.2.3.4")
520
+ assert not limiter.check("1.2.3.4")
521
+
522
+ def test_different_keys_independent(self):
523
+ redis = FakeRedis()
524
+ limiter = RedisRateLimiter(redis, max_hits=1)
525
+ assert limiter.check("a")
526
+ assert not limiter.check("a")
527
+ assert limiter.check("b") # separate key, separate budget
528
+
529
+ def test_fails_open_on_redis_error(self):
530
+ redis = FakeRedis()
531
+ redis.fail = True
532
+ limiter = RedisRateLimiter(redis, max_hits=1)
533
+ assert limiter.check("1.2.3.4"), "a Redis outage must not block legitimate traffic"
534
+
535
+
536
+ class TestRedisPaymentCache:
537
+ def test_miss_returns_none(self):
538
+ cache = RedisPaymentCache(FakeRedis())
539
+ assert cache.get("ag_unknown") is None
540
+
541
+ def test_positive_and_negative_roundtrip(self):
542
+ redis = FakeRedis()
543
+ cache = RedisPaymentCache(redis)
544
+ cache.set("ag_paid", True, 600)
545
+ cache.set("ag_unpaid", False, 30)
546
+ assert cache.get("ag_paid") is True
547
+ assert cache.get("ag_unpaid") is False
548
+
549
+ def test_fails_open_on_redis_error(self):
550
+ redis = FakeRedis()
551
+ redis.fail = True
552
+ cache = RedisPaymentCache(redis)
553
+ assert cache.get("ag_key") is None # treated as a cache miss, not a crash
554
+ cache.set("ag_key", True, 600) # must not raise
555
+
556
+
557
+ class TestCreateRedisStore:
558
+ def test_returns_expected_keys(self):
559
+ store = create_redis_store(FakeRedis())
560
+ assert set(store.keys()) == {
561
+ "challenge_verify_rate_limiter",
562
+ "agent_key_rate_limiter",
563
+ "challenge_issue_rate_limiter",
564
+ "payment_cache",
565
+ }
566
+
567
+ def test_limiters_use_distinct_keyspaces(self):
568
+ # Same IP hitting two different limiters from the same store must not
569
+ # share a rate-limit budget with each other.
570
+ redis = FakeRedis()
571
+ store = create_redis_store(redis)
572
+ for _ in range(10):
573
+ store["agent_key_rate_limiter"].check("1.2.3.4")
574
+ assert store["challenge_verify_rate_limiter"].check("1.2.3.4")
575
+
576
+ def test_default_max_hits_match_built_in_limiters(self):
577
+ store = create_redis_store(FakeRedis())
578
+ # agent_key: 10/min, challenge_verify: 20/min, challenge_issue: 30/min
579
+ for _ in range(10):
580
+ assert store["agent_key_rate_limiter"].check("k")
581
+ assert not store["agent_key_rate_limiter"].check("k")
582
+
583
+
477
584
  # ─── grant stores ────────────────────────────────────────────────────────────
478
585
 
479
586
  class TestMemoryGrantStore:
@@ -1168,6 +1275,115 @@ class TestAdapterPricingWiring:
1168
1275
  assert len(store.grants) == 1
1169
1276
  assert store.grants[0]["expires_at"] > before + 86000 # ~24h out, allowing test slack
1170
1277
 
1278
+ class _AlwaysDenyLimiter:
1279
+ def check(self, key):
1280
+ return False
1281
+
1282
+ class _PreSeededPaymentCache:
1283
+ """Reports the given key as already paid, no matter what — used to
1284
+ prove the gate consults the injected cache instead of a hardcoded
1285
+ singleton, without needing a real chain scan."""
1286
+ def __init__(self, key):
1287
+ self._key = key
1288
+
1289
+ def get(self, key):
1290
+ return True if key == self._key else None
1291
+
1292
+ def set(self, key, value, ttl):
1293
+ pass
1294
+
1295
+ def test_fastapi_custom_agent_key_rate_limiter_is_used(self):
1296
+ pytest.importorskip("fastapi")
1297
+ import asyncio
1298
+ from starlette.requests import Request
1299
+ from starlette.responses import Response
1300
+ from agentpayments_python.fastapi_adapter import AgentPaymentsASGIMiddleware
1301
+ from agentpayments_python.crypto import generate_agent_key
1302
+
1303
+ key = generate_agent_key(self.SECRET)
1304
+ mw = AgentPaymentsASGIMiddleware(
1305
+ app=None,
1306
+ challenge_secret=self.SECRET,
1307
+ home_wallet_address=self.WALLET,
1308
+ debug=True,
1309
+ usdc_mint=self.MINT,
1310
+ agent_key_rate_limiter=self._AlwaysDenyLimiter(),
1311
+ )
1312
+
1313
+ async def call_next(_req):
1314
+ return Response("ok", status_code=200)
1315
+
1316
+ async def run():
1317
+ req = Request({
1318
+ "type": "http", "method": "GET", "path": "/data",
1319
+ "headers": [(b"x-agent-key", key.encode())],
1320
+ "query_string": b"", "scheme": "https", "client": ("127.0.0.1", 1234),
1321
+ })
1322
+ return await mw.dispatch(req, call_next)
1323
+
1324
+ resp = asyncio.run(run())
1325
+ assert resp.status_code == 429
1326
+
1327
+ def test_flask_custom_payment_cache_short_circuits_chain_scan(self):
1328
+ pytest.importorskip("flask")
1329
+ from flask import Flask
1330
+ from agentpayments_python.flask_adapter import register_agentpayments
1331
+ from agentpayments_python.crypto import generate_agent_key
1332
+
1333
+ key = generate_agent_key(self.SECRET)
1334
+ app = Flask(__name__)
1335
+ register_agentpayments(
1336
+ app,
1337
+ challenge_secret=self.SECRET,
1338
+ home_wallet_address=self.WALLET,
1339
+ debug=True,
1340
+ usdc_mint=self.MINT,
1341
+ payment_cache=self._PreSeededPaymentCache(key),
1342
+ )
1343
+
1344
+ @app.route("/data")
1345
+ def data():
1346
+ return "ok"
1347
+
1348
+ client = app.test_client()
1349
+ # No RPC mock at all -- if the gate ignored the injected cache and
1350
+ # fell through to a real chain scan, this would hit the live network
1351
+ # (and almost certainly fail/timeout in CI) instead of the assertion below.
1352
+ with patch("requests.post", side_effect=AssertionError("should not hit the network — payment_cache should have short-circuited this")):
1353
+ resp = client.get("/data", headers={"X-Agent-Key": key})
1354
+ assert resp.status_code == 200
1355
+
1356
+ def test_django_custom_challenge_issue_rate_limiter_is_used(self):
1357
+ pytest.importorskip("django")
1358
+ import django
1359
+ from django.conf import settings
1360
+
1361
+ if not settings.configured:
1362
+ settings.configure(
1363
+ DEBUG=True,
1364
+ CHALLENGE_SECRET=self.SECRET,
1365
+ HOME_WALLET_ADDRESS=self.WALLET,
1366
+ USDC_MINT=self.MINT,
1367
+ ALLOWED_HOSTS=["*"],
1368
+ )
1369
+ django.setup()
1370
+
1371
+ from django.test import RequestFactory
1372
+ from django.http import HttpResponse
1373
+ from agentpayments_python.django_adapter import GateMiddleware
1374
+
1375
+ with patch.object(settings, "AGENTPAYMENTS_CHALLENGE_ISSUE_RATE_LIMITER", self._AlwaysDenyLimiter(), create=True):
1376
+ mw = GateMiddleware(lambda req: HttpResponse("ok"))
1377
+ rf = RequestFactory()
1378
+ # A plain browser-shaped request (no agent key, no Sec-Fetch/UA —
1379
+ # still non-browser per is_browser_from_headers) won't reach the
1380
+ # challenge-issuance path; use a UA that resolves to "browser" so
1381
+ # the request falls through to the rate-limited challenge page.
1382
+ req = rf.get("/", HTTP_SEC_FETCH_MODE="navigate", HTTP_SEC_FETCH_DEST="document")
1383
+ resp = mw(req)
1384
+
1385
+ assert resp.status_code == 429
1386
+
1171
1387
 
1172
1388
  # ─── pricing.py: pure helper unit tests ──────────────────────────────────────
1173
1389