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.
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/PKG-INFO +37 -1
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/README.md +36 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/django_adapter.py +15 -4
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/fastapi_adapter.py +15 -6
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/flask_adapter.py +13 -5
- agentpayments_python-0.3.0/agentpayments_python/redis_store.py +128 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/solana.py +13 -6
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/PKG-INFO +37 -1
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/SOURCES.txt +1 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/pyproject.toml +1 -1
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/tests/test_core.py +216 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/LICENSE +0 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/__init__.py +0 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/challenge.py +0 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/constants.json +0 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/cookies.py +0 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/crawler.py +0 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/crypto.py +0 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/detection.py +0 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/grant_store.py +0 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/platform_client.py +0 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/pricing.py +0 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/ratelimit.py +0 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/x402.py +0 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/dependency_links.txt +0 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/requires.txt +0 -0
- {agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
{agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/django_adapter.py
RENAMED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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]
|
{agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/fastapi_adapter.py
RENAMED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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]
|
{agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/flask_adapter.py
RENAMED
|
@@ -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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
244
|
+
cache.set(agent_key, False, NEGATIVE_CACHE_TTL)
|
|
238
245
|
return not_paid
|
{agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentpayments-python
|
|
3
|
-
Version: 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
|
{agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/SOURCES.txt
RENAMED
|
@@ -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.
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/constants.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/grant_store.py
RENAMED
|
File without changes
|
{agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python/platform_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentpayments_python-0.2.0 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/requires.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|