agentpayments-python 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl
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/django_adapter.py +15 -4
- agentpayments_python/fastapi_adapter.py +15 -6
- agentpayments_python/flask_adapter.py +13 -5
- agentpayments_python/redis_store.py +128 -0
- agentpayments_python/solana.py +13 -6
- {agentpayments_python-0.2.0.dist-info → agentpayments_python-0.3.0.dist-info}/METADATA +37 -1
- {agentpayments_python-0.2.0.dist-info → agentpayments_python-0.3.0.dist-info}/RECORD +10 -9
- {agentpayments_python-0.2.0.dist-info → agentpayments_python-0.3.0.dist-info}/WHEEL +0 -0
- {agentpayments_python-0.2.0.dist-info → agentpayments_python-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {agentpayments_python-0.2.0.dist-info → agentpayments_python-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -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]
|
|
@@ -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]
|
|
@@ -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
|
+
}
|
agentpayments_python/solana.py
CHANGED
|
@@ -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
|
|
@@ -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
|
|
@@ -5,17 +5,18 @@ agentpayments_python/cookies.py,sha256=0xO2_e4R4FpOprBcGWqY84E_eJppgJCkMGLCElZOQ
|
|
|
5
5
|
agentpayments_python/crawler.py,sha256=G06nF6qFmyA5nDvMW18rfh44JxxJMKc2GCO0DXiZuoY,2514
|
|
6
6
|
agentpayments_python/crypto.py,sha256=81xNcH1E468v26BimEQpK6n42bkqyz4q6eXhLLqQ6QY,1315
|
|
7
7
|
agentpayments_python/detection.py,sha256=5KRaVdlMjOUaE167WTQyDXWIwF6sRFZ1DGZ2eqtoBAQ,1938
|
|
8
|
-
agentpayments_python/django_adapter.py,sha256=
|
|
9
|
-
agentpayments_python/fastapi_adapter.py,sha256=
|
|
10
|
-
agentpayments_python/flask_adapter.py,sha256=
|
|
8
|
+
agentpayments_python/django_adapter.py,sha256=pZIq62sC_MYcfP_fgKvg5Fs_F4ismTct5I7HmfWxZVQ,16317
|
|
9
|
+
agentpayments_python/fastapi_adapter.py,sha256=3dvPg_btZCxAws5xBIJ_hgbXFYuef8H2PGo5aXtCRwg,15370
|
|
10
|
+
agentpayments_python/flask_adapter.py,sha256=s6IOKE5gldgPXIlRwvT2fN_etPZr43NpGaSSZtDBOMg,13509
|
|
11
11
|
agentpayments_python/grant_store.py,sha256=ewYAxKocgfJE_txZp1SDnML066QP4BBB6V9vXIo47eE,5057
|
|
12
12
|
agentpayments_python/platform_client.py,sha256=T-fSzxsABXVJ9nfrqL370GboV2-hdJaA-ploOw0uOd8,5108
|
|
13
13
|
agentpayments_python/pricing.py,sha256=3AujxsDlP5pMBals4FnoPdfOYBe1VPLREo_IJtJVxwg,2910
|
|
14
14
|
agentpayments_python/ratelimit.py,sha256=Q6uxFRbYbf7D8qykZ9ieVaSCU8FkXFYPS59XmkrnE_k,1580
|
|
15
|
-
agentpayments_python/
|
|
15
|
+
agentpayments_python/redis_store.py,sha256=2WSPBJYi8qp7B_yhqYX1bemfjbYn8OrH_ww9qpRw7Bc,5145
|
|
16
|
+
agentpayments_python/solana.py,sha256=SE6psJlveTZJEsY6I2Q1Dttp1cqdzuzsqM_yeer5toU,11802
|
|
16
17
|
agentpayments_python/x402.py,sha256=PS5U_dWX1CyRMTxxAYnyuzb-q4mRWvXe1Pys3y-CvEg,5527
|
|
17
|
-
agentpayments_python-0.
|
|
18
|
-
agentpayments_python-0.
|
|
19
|
-
agentpayments_python-0.
|
|
20
|
-
agentpayments_python-0.
|
|
21
|
-
agentpayments_python-0.
|
|
18
|
+
agentpayments_python-0.3.0.dist-info/licenses/LICENSE,sha256=PMKDe4kanHRVI7QQezG4GeluZkMljhVO-tFSFNUiYS4,1069
|
|
19
|
+
agentpayments_python-0.3.0.dist-info/METADATA,sha256=OXACM_sGdSQctXJuB-0hqhQziHG5TsA7jBQvE_zJTvc,12066
|
|
20
|
+
agentpayments_python-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
21
|
+
agentpayments_python-0.3.0.dist-info/top_level.txt,sha256=DxGMGvTYQVdoBQ6pBbbsJXy5BZhbSjNGF_uSO3git4w,21
|
|
22
|
+
agentpayments_python-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
{agentpayments_python-0.2.0.dist-info → agentpayments_python-0.3.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|