agentpayments-python 0.1.2__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 +48 -15
- agentpayments_python/fastapi_adapter.py +44 -19
- agentpayments_python/flask_adapter.py +39 -16
- agentpayments_python/grant_store.py +59 -15
- agentpayments_python/pricing.py +70 -0
- agentpayments_python/redis_store.py +128 -0
- agentpayments_python/solana.py +39 -15
- agentpayments_python/x402.py +24 -2
- {agentpayments_python-0.1.2.dist-info → agentpayments_python-0.3.0.dist-info}/METADATA +77 -1
- agentpayments_python-0.3.0.dist-info/RECORD +22 -0
- agentpayments_python-0.1.2.dist-info/RECORD +0 -20
- {agentpayments_python-0.1.2.dist-info → agentpayments_python-0.3.0.dist-info}/WHEEL +0 -0
- {agentpayments_python-0.1.2.dist-info → agentpayments_python-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {agentpayments_python-0.1.2.dist-info → agentpayments_python-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import logging
|
|
4
|
+
import time as _time
|
|
2
5
|
|
|
3
6
|
from django.conf import settings
|
|
4
7
|
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
|
|
@@ -11,9 +14,10 @@ from .crypto import generate_agent_key, is_valid_agent_key
|
|
|
11
14
|
from .detection import is_browser_from_headers, is_public_path
|
|
12
15
|
from .ratelimit import _challenge_limiter, _agent_key_limiter, _challenge_issue_limiter
|
|
13
16
|
from .crawler import is_verified_crawler
|
|
14
|
-
from .solana import MIN_PAYMENT, RPC_DEVNET, RPC_MAINNET, USDC_MINT_DEVNET, USDC_MINT_MAINNET, is_valid_solana_address,
|
|
15
|
-
from .x402 import build_payment_object, build_payment_requirements, enrich_402_body, payment_required_header
|
|
17
|
+
from .solana import MIN_PAYMENT, RPC_DEVNET, RPC_MAINNET, USDC_MINT_DEVNET, USDC_MINT_MAINNET, is_valid_solana_address, _scan_for_payment
|
|
18
|
+
from .x402 import build_payment_object, build_payment_requirements, enrich_402_body, payment_required_header, tier_x402_opts_list
|
|
16
19
|
from .platform_client import HOSTED_KEY_PREFIX, PlatformClient, is_valid_hosted_key
|
|
20
|
+
from .pricing import normalize_price_config, build_routes_table, resolve_route_config, resolve_tier
|
|
17
21
|
|
|
18
22
|
import json as _json
|
|
19
23
|
from pathlib import Path as _Path
|
|
@@ -26,10 +30,11 @@ MAX_POW_LENGTH = _constants["MAX_POW_LENGTH"]
|
|
|
26
30
|
logger = logging.getLogger("agentpayments")
|
|
27
31
|
|
|
28
32
|
|
|
29
|
-
def _payment_required_response(body: dict, *, wallet_address: str, mint: str, min_payment: float, debug: bool, agent_key: str = "", resource: str = "") -> JsonResponse:
|
|
33
|
+
def _payment_required_response(body: dict, *, wallet_address: str, mint: str, min_payment: float, debug: bool, agent_key: str = "", resource: str = "", extra_tier_opts: list[dict] | None = None) -> JsonResponse:
|
|
30
34
|
"""Return a 402 JsonResponse enriched with x402-standard fields and header."""
|
|
31
35
|
pay_req = build_payment_requirements(wallet_address=wallet_address, mint=mint, min_payment=min_payment, debug=debug, agent_key=agent_key, resource=resource)
|
|
32
|
-
|
|
36
|
+
extra_reqs = [build_payment_requirements(**opts) for opts in (extra_tier_opts or [])]
|
|
37
|
+
resp = JsonResponse(enrich_402_body(body, pay_req, extra_reqs), status=402, json_dumps_params={"indent": 2})
|
|
33
38
|
resp["X-PAYMENT-REQUIRED"] = payment_required_header(pay_req)
|
|
34
39
|
return resp
|
|
35
40
|
|
|
@@ -69,11 +74,29 @@ class GateMiddleware:
|
|
|
69
74
|
self.rpc_url = rpc_url
|
|
70
75
|
self.usdc_mint = usdc_mint
|
|
71
76
|
self.network = "devnet" if debug else "mainnet-beta"
|
|
77
|
+
# Price/duration/tier resolution is static for this server — precompute
|
|
78
|
+
# once rather than per-request. Configure via settings.py:
|
|
79
|
+
# AGENTPAYMENTS_MIN_PAYMENT, AGENTPAYMENTS_ACCESS_DURATION,
|
|
80
|
+
# AGENTPAYMENTS_PRICING_TIERS, AGENTPAYMENTS_ROUTES
|
|
81
|
+
min_payment = getattr(settings, "AGENTPAYMENTS_MIN_PAYMENT", MIN_PAYMENT)
|
|
82
|
+
access_duration = getattr(settings, "AGENTPAYMENTS_ACCESS_DURATION", None)
|
|
83
|
+
pricing_tiers = getattr(settings, "AGENTPAYMENTS_PRICING_TIERS", None)
|
|
84
|
+
routes = getattr(settings, "AGENTPAYMENTS_ROUTES", None)
|
|
85
|
+
self.base_price_config = normalize_price_config(min_payment, access_duration, pricing_tiers)
|
|
86
|
+
self.routes_table = build_routes_table(routes, min_payment, access_duration, pricing_tiers)
|
|
72
87
|
self.pow_difficulty = getattr(settings, "POW_DIFFICULTY", POW_DIFFICULTY)
|
|
73
88
|
self.verify_crawlers = getattr(settings, "AGENTPAYMENTS_VERIFY_CRAWLERS", True)
|
|
74
89
|
# Optional grant store for durable paid-key persistence. Set
|
|
75
90
|
# AGENTPAYMENTS_GRANT_STORE to a GrantStore instance in settings.py.
|
|
76
91
|
self.grant_store = getattr(settings, "AGENTPAYMENTS_GRANT_STORE", None)
|
|
92
|
+
# Pluggable rate limiters / payment cache — default to the built-in
|
|
93
|
+
# in-memory singletons (fine for single-process deployments). For
|
|
94
|
+
# multi-process (e.g. gunicorn -w 4), set these to Redis-backed
|
|
95
|
+
# instances from agentpayments_python.redis_store so state is shared
|
|
96
|
+
# across workers instead of each worker enforcing its own limit.
|
|
97
|
+
self.agent_key_rate_limiter = getattr(settings, "AGENTPAYMENTS_AGENT_KEY_RATE_LIMITER", None) or _agent_key_limiter
|
|
98
|
+
self.challenge_issue_rate_limiter = getattr(settings, "AGENTPAYMENTS_CHALLENGE_ISSUE_RATE_LIMITER", None) or _challenge_issue_limiter
|
|
99
|
+
self.payment_cache = getattr(settings, "AGENTPAYMENTS_PAYMENT_CACHE", None)
|
|
77
100
|
|
|
78
101
|
def __call__(self, request):
|
|
79
102
|
secret = self.secret
|
|
@@ -81,6 +104,7 @@ class GateMiddleware:
|
|
|
81
104
|
network = self.network
|
|
82
105
|
|
|
83
106
|
pathname = request.path
|
|
107
|
+
price_config = resolve_route_config(pathname, self.routes_table, self.base_price_config)
|
|
84
108
|
if is_public_path(pathname):
|
|
85
109
|
return self.get_response(request)
|
|
86
110
|
|
|
@@ -128,21 +152,22 @@ class GateMiddleware:
|
|
|
128
152
|
new_key = generate_agent_key(secret)
|
|
129
153
|
if fee_info:
|
|
130
154
|
no_key_instructions = (
|
|
131
|
-
f'Send {
|
|
155
|
+
f'Send {price_config["min_payment"]} USDC on Solana {network} to {wallet_address} with memo "{new_key}", '
|
|
132
156
|
f'AND in the SAME transaction send the platform fee (see platform_fee below) to {fee_info["wallet"]}. '
|
|
133
157
|
f'Then include the header X-Agent-Key: {new_key} on all subsequent requests.'
|
|
134
158
|
)
|
|
135
159
|
else:
|
|
136
|
-
no_key_instructions = f'Send {
|
|
160
|
+
no_key_instructions = f'Send {price_config["min_payment"]} USDC on Solana {network} to {wallet_address} with memo "{new_key}". Then include the header X-Agent-Key: {new_key} on all subsequent requests.'
|
|
137
161
|
return _payment_required_response(
|
|
138
162
|
{
|
|
139
163
|
"error": "payment_required",
|
|
140
164
|
"message": "Access requires a paid API key. A key has been generated for you below. Send a USDC payment on Solana with this key as the memo to activate it, then retry your request with the X-Agent-Key header.",
|
|
141
165
|
"your_key": new_key,
|
|
142
|
-
"payment": build_payment_object(network=network, min_payment=
|
|
166
|
+
"payment": build_payment_object(network=network, min_payment=price_config["min_payment"], wallet_address=wallet_address, memo=new_key, fee_info=fee_info, instructions=no_key_instructions),
|
|
143
167
|
},
|
|
144
|
-
wallet_address=wallet_address, mint=self.usdc_mint, min_payment=
|
|
168
|
+
wallet_address=wallet_address, mint=self.usdc_mint, min_payment=price_config["min_payment"],
|
|
145
169
|
debug=self.debug, agent_key=new_key, resource=pathname,
|
|
170
|
+
extra_tier_opts=tier_x402_opts_list(price_config["pricing_tiers"], {"wallet_address": wallet_address, "mint": self.usdc_mint, "debug": self.debug, "agent_key": new_key, "resource": pathname}),
|
|
146
171
|
)
|
|
147
172
|
|
|
148
173
|
# Validate the key. Platform-issued (agp_) use verificationSecret;
|
|
@@ -160,7 +185,7 @@ class GateMiddleware:
|
|
|
160
185
|
elif not is_valid_agent_key(agent_key, secret):
|
|
161
186
|
return JsonResponse({"error": "forbidden", "message": "Invalid API key. Keys must be issued by this server."}, status=403)
|
|
162
187
|
|
|
163
|
-
if not
|
|
188
|
+
if not self.agent_key_rate_limiter.check(_client_ip(request)):
|
|
164
189
|
return JsonResponse({"error": "rate_limited", "message": "Too many payment verification requests. Please wait and try again."}, status=429)
|
|
165
190
|
|
|
166
191
|
if not wallet_address:
|
|
@@ -170,19 +195,24 @@ class GateMiddleware:
|
|
|
170
195
|
if self.grant_store and self.grant_store.has(agent_key):
|
|
171
196
|
return self.get_response(request)
|
|
172
197
|
|
|
173
|
-
|
|
198
|
+
scan_result = _scan_for_payment(agent_key, wallet_address, self.rpc_url, self.usdc_mint, min_payment=price_config["min_payment"], fee_info=fee_info, payment_cache=self.payment_cache)
|
|
199
|
+
paid = scan_result["paid"]
|
|
174
200
|
if paid and self.grant_store:
|
|
175
|
-
|
|
201
|
+
tier = resolve_tier(scan_result["amount_paid"], price_config["pricing_tiers"])
|
|
202
|
+
duration_seconds = tier["duration_seconds"] if tier else price_config["access_duration"]
|
|
203
|
+
expires_at = (_time.time() + duration_seconds) if duration_seconds else None
|
|
204
|
+
self.grant_store.add(agent_key, expires_at=expires_at, tier=(tier["name"] if tier else None))
|
|
176
205
|
if not paid:
|
|
177
206
|
return _payment_required_response(
|
|
178
207
|
{
|
|
179
208
|
"error": "payment_required",
|
|
180
209
|
"message": "Key is valid but payment has not been verified on-chain yet.",
|
|
181
210
|
"your_key": agent_key,
|
|
182
|
-
"payment": build_payment_object(network=network, min_payment=
|
|
211
|
+
"payment": build_payment_object(network=network, min_payment=price_config["min_payment"], wallet_address=wallet_address, memo=agent_key, fee_info=fee_info),
|
|
183
212
|
},
|
|
184
|
-
wallet_address=wallet_address, mint=self.usdc_mint, min_payment=
|
|
213
|
+
wallet_address=wallet_address, mint=self.usdc_mint, min_payment=price_config["min_payment"],
|
|
185
214
|
debug=self.debug, agent_key=agent_key, resource=pathname,
|
|
215
|
+
extra_tier_opts=tier_x402_opts_list(price_config["pricing_tiers"], {"wallet_address": wallet_address, "mint": self.usdc_mint, "debug": self.debug, "agent_key": agent_key, "resource": pathname}),
|
|
186
216
|
)
|
|
187
217
|
|
|
188
218
|
return self.get_response(request)
|
|
@@ -193,7 +223,7 @@ class GateMiddleware:
|
|
|
193
223
|
return self.get_response(request)
|
|
194
224
|
|
|
195
225
|
# Rate-limit challenge page issuance to prevent unlimited nonce harvesting.
|
|
196
|
-
if not
|
|
226
|
+
if not self.challenge_issue_rate_limiter.check(client_ip):
|
|
197
227
|
return JsonResponse({"error": "rate_limited", "message": "Too many requests. Please try again later."}, status=429)
|
|
198
228
|
|
|
199
229
|
nonce = make_nonce(secret, client_ip)
|
|
@@ -208,7 +238,10 @@ class GateMiddleware:
|
|
|
208
238
|
@require_POST
|
|
209
239
|
def challenge_verify(request):
|
|
210
240
|
client_ip = _client_ip(request)
|
|
211
|
-
|
|
241
|
+
# Optional: set AGENTPAYMENTS_CHALLENGE_VERIFY_RATE_LIMITER in settings.py
|
|
242
|
+
# to a Redis-backed limiter for multi-process deployments.
|
|
243
|
+
limiter = getattr(settings, "AGENTPAYMENTS_CHALLENGE_VERIFY_RATE_LIMITER", None) or _challenge_limiter
|
|
244
|
+
if not limiter.check(client_ip):
|
|
212
245
|
return JsonResponse({"error": "rate_limited", "message": "Too many verification attempts. Please wait and try again."}, status=429)
|
|
213
246
|
secret = settings.CHALLENGE_SECRET
|
|
214
247
|
nonce = request.POST.get("nonce", "")[:MAX_NONCE_LENGTH]
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import asyncio
|
|
2
4
|
|
|
3
5
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
@@ -10,9 +12,11 @@ from .crypto import generate_agent_key, is_valid_agent_key
|
|
|
10
12
|
from .detection import is_browser_from_headers, is_public_path
|
|
11
13
|
from .ratelimit import _challenge_limiter, _agent_key_limiter, _challenge_issue_limiter
|
|
12
14
|
from .crawler import is_verified_crawler
|
|
13
|
-
from .solana import MIN_PAYMENT, RPC_DEVNET, RPC_MAINNET, USDC_MINT_DEVNET, USDC_MINT_MAINNET, is_valid_solana_address,
|
|
14
|
-
from .x402 import build_payment_object, build_payment_requirements, enrich_402_body, payment_required_header
|
|
15
|
+
from .solana import MIN_PAYMENT, RPC_DEVNET, RPC_MAINNET, USDC_MINT_DEVNET, USDC_MINT_MAINNET, is_valid_solana_address, _scan_for_payment
|
|
16
|
+
from .x402 import build_payment_object, build_payment_requirements, enrich_402_body, payment_required_header, tier_x402_opts_list
|
|
15
17
|
from .platform_client import HOSTED_KEY_PREFIX, PlatformClient, is_valid_hosted_key
|
|
18
|
+
from .pricing import normalize_price_config, build_routes_table, resolve_route_config, resolve_tier
|
|
19
|
+
import time as _time
|
|
16
20
|
|
|
17
21
|
import json as _json
|
|
18
22
|
from pathlib import Path as _Path
|
|
@@ -23,11 +27,12 @@ MAX_FP_LENGTH = _constants["MAX_FP_LENGTH"]
|
|
|
23
27
|
MAX_POW_LENGTH = _constants["MAX_POW_LENGTH"]
|
|
24
28
|
|
|
25
29
|
|
|
26
|
-
def _payment_required_response(body: dict, *, wallet_address: str, mint: str, min_payment: float, debug: bool, agent_key: str = "", resource: str = "") -> JSONResponse:
|
|
30
|
+
def _payment_required_response(body: dict, *, wallet_address: str, mint: str, min_payment: float, debug: bool, agent_key: str = "", resource: str = "", extra_tier_opts: list[dict] | None = None) -> JSONResponse:
|
|
27
31
|
"""Return a Starlette JSONResponse (402) enriched with x402-standard fields and header."""
|
|
28
32
|
pay_req = build_payment_requirements(wallet_address=wallet_address, mint=mint, min_payment=min_payment, debug=debug, agent_key=agent_key, resource=resource)
|
|
33
|
+
extra_reqs = [build_payment_requirements(**opts) for opts in (extra_tier_opts or [])]
|
|
29
34
|
return JSONResponse(
|
|
30
|
-
content=enrich_402_body(body, pay_req),
|
|
35
|
+
content=enrich_402_body(body, pay_req, extra_reqs),
|
|
31
36
|
status_code=402,
|
|
32
37
|
headers={"X-PAYMENT-REQUIRED": payment_required_header(pay_req)},
|
|
33
38
|
)
|
|
@@ -41,7 +46,7 @@ def _client_ip(request: Request) -> str:
|
|
|
41
46
|
|
|
42
47
|
|
|
43
48
|
class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
|
|
44
|
-
def __init__(self, app, *, challenge_secret: str, home_wallet_address: str, debug: bool = True, solana_rpc_url=None, usdc_mint: str = "", pow_difficulty: int = POW_DIFFICULTY, verify_crawlers: bool = True, grant_store=None, require_https: bool = None, api_key: str = None, platform_url: str = None):
|
|
49
|
+
def __init__(self, app, *, challenge_secret: str, home_wallet_address: str, debug: bool = True, solana_rpc_url=None, usdc_mint: str = "", min_payment: float = MIN_PAYMENT, access_duration: float | None = None, pricing_tiers: list[dict] | None = None, routes: list[dict] | None = None, pow_difficulty: int = POW_DIFFICULTY, verify_crawlers: bool = True, grant_store=None, require_https: bool = None, api_key: str = None, platform_url: str = None, agent_key_rate_limiter=None, challenge_issue_rate_limiter=None, payment_cache=None):
|
|
45
50
|
super().__init__(app)
|
|
46
51
|
if challenge_secret == "default-secret-change-me":
|
|
47
52
|
import logging
|
|
@@ -58,14 +63,27 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
|
|
|
58
63
|
raw_rpc = solana_rpc_url or (RPC_DEVNET if debug else RPC_MAINNET)
|
|
59
64
|
self.solana_rpc_url = raw_rpc if isinstance(raw_rpc, list) else [raw_rpc]
|
|
60
65
|
self.usdc_mint = usdc_mint or (USDC_MINT_DEVNET if debug else USDC_MINT_MAINNET)
|
|
66
|
+
# Price/duration/tier resolution is static for this middleware instance —
|
|
67
|
+
# precompute once rather than per-request.
|
|
68
|
+
self.base_price_config = normalize_price_config(min_payment, access_duration, pricing_tiers)
|
|
69
|
+
self.routes_table = build_routes_table(routes, min_payment, access_duration, pricing_tiers)
|
|
61
70
|
self.pow_difficulty = pow_difficulty
|
|
62
71
|
self.verify_crawlers = verify_crawlers
|
|
63
72
|
self.grant_store = grant_store
|
|
64
73
|
self.require_https = (not debug) if require_https is None else require_https
|
|
65
74
|
self._platform_client = PlatformClient(api_key, platform_url) if api_key else None
|
|
75
|
+
# Pluggable rate limiter / payment cache — default to the built-in
|
|
76
|
+
# in-memory singletons (fine for single-process deployments). For
|
|
77
|
+
# multi-process (e.g. gunicorn -w 4), pass Redis-backed instances
|
|
78
|
+
# from agentpayments_python.redis_store so state is shared across
|
|
79
|
+
# workers instead of each worker enforcing its own independent limit.
|
|
80
|
+
self.agent_key_rate_limiter = agent_key_rate_limiter or _agent_key_limiter
|
|
81
|
+
self.challenge_issue_rate_limiter = challenge_issue_rate_limiter or _challenge_issue_limiter
|
|
82
|
+
self.payment_cache = payment_cache
|
|
66
83
|
|
|
67
84
|
async def dispatch(self, request: Request, call_next):
|
|
68
85
|
path = request.url.path
|
|
86
|
+
price_config = resolve_route_config(path, self.routes_table, self.base_price_config)
|
|
69
87
|
if is_public_path(path):
|
|
70
88
|
return await call_next(request)
|
|
71
89
|
|
|
@@ -112,21 +130,22 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
|
|
|
112
130
|
new_key = generate_agent_key(self.challenge_secret)
|
|
113
131
|
if fee_info:
|
|
114
132
|
no_key_instructions = (
|
|
115
|
-
f'Send {
|
|
133
|
+
f'Send {price_config["min_payment"]} USDC on Solana {network} to {self.home_wallet_address} with memo "{new_key}", '
|
|
116
134
|
f'AND in the SAME transaction send the platform fee (see platform_fee below) to {fee_info["wallet"]}. '
|
|
117
135
|
f'Then include the header X-Agent-Key: {new_key} on all subsequent requests.'
|
|
118
136
|
)
|
|
119
137
|
else:
|
|
120
|
-
no_key_instructions = f'Send {
|
|
138
|
+
no_key_instructions = f'Send {price_config["min_payment"]} USDC on Solana {network} to {self.home_wallet_address} with memo "{new_key}". Then include the header X-Agent-Key: {new_key} on all subsequent requests.'
|
|
121
139
|
return _payment_required_response(
|
|
122
140
|
{
|
|
123
141
|
"error": "payment_required",
|
|
124
142
|
"message": "Access requires a paid API key. A key has been generated for you below. Send a USDC payment on Solana with this key as the memo to activate it, then retry your request with the X-Agent-Key header.",
|
|
125
143
|
"your_key": new_key,
|
|
126
|
-
"payment": build_payment_object(network=network, min_payment=
|
|
144
|
+
"payment": build_payment_object(network=network, min_payment=price_config["min_payment"], wallet_address=self.home_wallet_address, memo=new_key, fee_info=fee_info, instructions=no_key_instructions),
|
|
127
145
|
},
|
|
128
|
-
wallet_address=self.home_wallet_address, mint=self.usdc_mint, min_payment=
|
|
146
|
+
wallet_address=self.home_wallet_address, mint=self.usdc_mint, min_payment=price_config["min_payment"],
|
|
129
147
|
debug=self.debug, agent_key=new_key, resource=path,
|
|
148
|
+
extra_tier_opts=tier_x402_opts_list(price_config["pricing_tiers"], {"wallet_address": self.home_wallet_address, "mint": self.usdc_mint, "debug": self.debug, "agent_key": new_key, "resource": path}),
|
|
130
149
|
)
|
|
131
150
|
|
|
132
151
|
if agent_key.startswith(HOSTED_KEY_PREFIX):
|
|
@@ -143,7 +162,7 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
|
|
|
143
162
|
elif not is_valid_agent_key(agent_key, self.challenge_secret):
|
|
144
163
|
return JSONResponse({"error": "forbidden", "message": "Invalid API key."}, status_code=403)
|
|
145
164
|
|
|
146
|
-
if not
|
|
165
|
+
if not self.agent_key_rate_limiter.check(_client_ip(request)):
|
|
147
166
|
return JSONResponse({"error": "rate_limited", "message": "Too many payment verification requests. Please wait and try again."}, status_code=429)
|
|
148
167
|
|
|
149
168
|
if not self.home_wallet_address:
|
|
@@ -153,23 +172,28 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
|
|
|
153
172
|
if self.grant_store and self.grant_store.has(agent_key):
|
|
154
173
|
return await call_next(request)
|
|
155
174
|
|
|
156
|
-
#
|
|
175
|
+
# _scan_for_payment is synchronous (uses requests). Run it in a
|
|
157
176
|
# thread-pool executor so it doesn't block the async event loop.
|
|
158
|
-
|
|
159
|
-
None, lambda:
|
|
177
|
+
scan_result = await loop.run_in_executor(
|
|
178
|
+
None, lambda: _scan_for_payment(agent_key, self.home_wallet_address, self.solana_rpc_url, self.usdc_mint, min_payment=price_config["min_payment"], fee_info=fee_info, payment_cache=self.payment_cache)
|
|
160
179
|
)
|
|
180
|
+
paid = scan_result["paid"]
|
|
161
181
|
if paid and self.grant_store:
|
|
162
|
-
|
|
182
|
+
tier = resolve_tier(scan_result["amount_paid"], price_config["pricing_tiers"])
|
|
183
|
+
duration_seconds = tier["duration_seconds"] if tier else price_config["access_duration"]
|
|
184
|
+
expires_at = (_time.time() + duration_seconds) if duration_seconds else None
|
|
185
|
+
self.grant_store.add(agent_key, expires_at=expires_at, tier=(tier["name"] if tier else None))
|
|
163
186
|
if not paid:
|
|
164
187
|
return _payment_required_response(
|
|
165
188
|
{
|
|
166
189
|
"error": "payment_required",
|
|
167
190
|
"message": "Key is valid but payment has not been verified on-chain yet.",
|
|
168
191
|
"your_key": agent_key,
|
|
169
|
-
"payment": build_payment_object(network=network, min_payment=
|
|
192
|
+
"payment": build_payment_object(network=network, min_payment=price_config["min_payment"], wallet_address=self.home_wallet_address, memo=agent_key, fee_info=fee_info),
|
|
170
193
|
},
|
|
171
|
-
wallet_address=self.home_wallet_address, mint=self.usdc_mint, min_payment=
|
|
194
|
+
wallet_address=self.home_wallet_address, mint=self.usdc_mint, min_payment=price_config["min_payment"],
|
|
172
195
|
debug=self.debug, agent_key=agent_key, resource=path,
|
|
196
|
+
extra_tier_opts=tier_x402_opts_list(price_config["pricing_tiers"], {"wallet_address": self.home_wallet_address, "mint": self.usdc_mint, "debug": self.debug, "agent_key": agent_key, "resource": path}),
|
|
173
197
|
)
|
|
174
198
|
|
|
175
199
|
return await call_next(request)
|
|
@@ -179,7 +203,7 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
|
|
|
179
203
|
if is_valid_cookie_value(cookie_val, self.challenge_secret, client_ip):
|
|
180
204
|
return await call_next(request)
|
|
181
205
|
|
|
182
|
-
if not
|
|
206
|
+
if not self.challenge_issue_rate_limiter.check(client_ip):
|
|
183
207
|
return JSONResponse({"error": "rate_limited", "message": "Too many requests. Please try again later."}, status_code=429)
|
|
184
208
|
|
|
185
209
|
nonce = make_nonce(self.challenge_secret, client_ip)
|
|
@@ -190,9 +214,10 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
|
|
|
190
214
|
})
|
|
191
215
|
|
|
192
216
|
|
|
193
|
-
async def challenge_verify_endpoint(request: Request, challenge_secret: str, pow_difficulty: int = POW_DIFFICULTY):
|
|
217
|
+
async def challenge_verify_endpoint(request: Request, challenge_secret: str, pow_difficulty: int = POW_DIFFICULTY, rate_limiter=None):
|
|
194
218
|
client_ip = _client_ip(request)
|
|
195
|
-
|
|
219
|
+
limiter = rate_limiter or _challenge_limiter
|
|
220
|
+
if not limiter.check(client_ip):
|
|
196
221
|
return JSONResponse({"error": "rate_limited", "message": "Too many verification attempts. Please wait and try again."}, status_code=429)
|
|
197
222
|
form = await request.form()
|
|
198
223
|
nonce = str(form.get("nonce", ""))[:MAX_NONCE_LENGTH]
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from flask import jsonify, make_response, redirect, request, Response as FlaskResponse
|
|
2
4
|
import json as _flask_json
|
|
5
|
+
import time as _time
|
|
3
6
|
|
|
4
7
|
from .challenge import POW_DIFFICULTY, challenge_html, make_nonce, validate_challenge_submission
|
|
5
8
|
from .cookies import COOKIE_MAX_AGE, COOKIE_NAME, is_valid_cookie_value, make_cookie
|
|
@@ -7,9 +10,10 @@ from .crypto import generate_agent_key, is_valid_agent_key
|
|
|
7
10
|
from .detection import is_browser_from_headers, is_public_path
|
|
8
11
|
from .ratelimit import _challenge_limiter, _agent_key_limiter, _challenge_issue_limiter
|
|
9
12
|
from .crawler import is_verified_crawler
|
|
10
|
-
from .solana import MIN_PAYMENT, RPC_DEVNET, RPC_MAINNET, USDC_MINT_DEVNET, USDC_MINT_MAINNET, is_valid_solana_address,
|
|
11
|
-
from .x402 import build_payment_object, build_payment_requirements, enrich_402_body, payment_required_header
|
|
13
|
+
from .solana import MIN_PAYMENT, RPC_DEVNET, RPC_MAINNET, USDC_MINT_DEVNET, USDC_MINT_MAINNET, is_valid_solana_address, _scan_for_payment
|
|
14
|
+
from .x402 import build_payment_object, build_payment_requirements, enrich_402_body, payment_required_header, tier_x402_opts_list
|
|
12
15
|
from .platform_client import HOSTED_KEY_PREFIX, PlatformClient, is_valid_hosted_key
|
|
16
|
+
from .pricing import normalize_price_config, build_routes_table, resolve_route_config, resolve_tier
|
|
13
17
|
|
|
14
18
|
import json as _json
|
|
15
19
|
from pathlib import Path as _Path
|
|
@@ -20,10 +24,11 @@ MAX_FP_LENGTH = _constants["MAX_FP_LENGTH"]
|
|
|
20
24
|
MAX_POW_LENGTH = _constants["MAX_POW_LENGTH"]
|
|
21
25
|
|
|
22
26
|
|
|
23
|
-
def _payment_required_flask(body: dict, *, wallet_address: str, mint: str, min_payment: float, debug: bool, agent_key: str = "", resource: str = ""):
|
|
27
|
+
def _payment_required_flask(body: dict, *, wallet_address: str, mint: str, min_payment: float, debug: bool, agent_key: str = "", resource: str = "", extra_tier_opts: list[dict] | None = None):
|
|
24
28
|
"""Return a Flask 402 response enriched with x402-standard fields and header."""
|
|
25
29
|
pay_req = build_payment_requirements(wallet_address=wallet_address, mint=mint, min_payment=min_payment, debug=debug, agent_key=agent_key, resource=resource)
|
|
26
|
-
|
|
30
|
+
extra_reqs = [build_payment_requirements(**opts) for opts in (extra_tier_opts or [])]
|
|
31
|
+
enriched = enrich_402_body(body, pay_req, extra_reqs)
|
|
27
32
|
resp = make_response(_flask_json.dumps(enriched, indent=2), 402)
|
|
28
33
|
resp.headers["Content-Type"] = "application/json"
|
|
29
34
|
resp.headers["X-PAYMENT-REQUIRED"] = payment_required_header(pay_req)
|
|
@@ -34,7 +39,15 @@ def _client_ip() -> str:
|
|
|
34
39
|
return request.headers.get("X-Forwarded-For", "").split(",")[0].strip() or request.remote_addr or "unknown"
|
|
35
40
|
|
|
36
41
|
|
|
37
|
-
def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: str, debug: bool = True, solana_rpc_url=None, usdc_mint: str = "", pow_difficulty: int = POW_DIFFICULTY, verify_crawlers: bool = True, grant_store=None, require_https: bool = None, api_key: str = None, platform_url: str = None):
|
|
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
|
|
38
51
|
if challenge_secret == "default-secret-change-me":
|
|
39
52
|
import logging
|
|
40
53
|
logger = logging.getLogger("agentpayments")
|
|
@@ -49,10 +62,14 @@ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: s
|
|
|
49
62
|
mint = usdc_mint or (USDC_MINT_DEVNET if debug else USDC_MINT_MAINNET)
|
|
50
63
|
_require_https = (not debug) if require_https is None else require_https
|
|
51
64
|
_platform_client = PlatformClient(api_key, platform_url) if api_key else None
|
|
65
|
+
# Price/duration/tier resolution is static for this app — precompute once.
|
|
66
|
+
base_price_config = normalize_price_config(min_payment, access_duration, pricing_tiers)
|
|
67
|
+
routes_table = build_routes_table(routes, min_payment, access_duration, pricing_tiers)
|
|
52
68
|
|
|
53
69
|
@app.before_request
|
|
54
70
|
def _gate():
|
|
55
71
|
path = request.path
|
|
72
|
+
price_config = resolve_route_config(path, routes_table, base_price_config)
|
|
56
73
|
if is_public_path(path):
|
|
57
74
|
return None
|
|
58
75
|
if path == "/__challenge/verify" and request.method == "POST":
|
|
@@ -94,21 +111,22 @@ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: s
|
|
|
94
111
|
new_key = generate_agent_key(challenge_secret)
|
|
95
112
|
if fee_info:
|
|
96
113
|
no_key_instructions = (
|
|
97
|
-
f'Send {
|
|
114
|
+
f'Send {price_config["min_payment"]} USDC on Solana {network} to {home_wallet_address} with memo "{new_key}", '
|
|
98
115
|
f'AND in the SAME transaction send the platform fee (see platform_fee below) to {fee_info["wallet"]}. '
|
|
99
116
|
f'Then include the header X-Agent-Key: {new_key} on all subsequent requests.'
|
|
100
117
|
)
|
|
101
118
|
else:
|
|
102
|
-
no_key_instructions = f'Send {
|
|
119
|
+
no_key_instructions = f'Send {price_config["min_payment"]} USDC on Solana {network} to {home_wallet_address} with memo "{new_key}". Then include the header X-Agent-Key: {new_key} on all subsequent requests.'
|
|
103
120
|
return _payment_required_flask(
|
|
104
121
|
{
|
|
105
122
|
"error": "payment_required",
|
|
106
123
|
"message": "Access requires a paid API key. A key has been generated for you below. Send a USDC payment on Solana with this key as the memo to activate it, then retry your request with the X-Agent-Key header.",
|
|
107
124
|
"your_key": new_key,
|
|
108
|
-
"payment": build_payment_object(network=network, min_payment=
|
|
125
|
+
"payment": build_payment_object(network=network, min_payment=price_config["min_payment"], wallet_address=home_wallet_address, memo=new_key, fee_info=fee_info, instructions=no_key_instructions),
|
|
109
126
|
},
|
|
110
|
-
wallet_address=home_wallet_address, mint=mint, min_payment=
|
|
127
|
+
wallet_address=home_wallet_address, mint=mint, min_payment=price_config["min_payment"],
|
|
111
128
|
debug=debug, agent_key=new_key, resource=path,
|
|
129
|
+
extra_tier_opts=tier_x402_opts_list(price_config["pricing_tiers"], {"wallet_address": home_wallet_address, "mint": mint, "debug": debug, "agent_key": new_key, "resource": path}),
|
|
112
130
|
)
|
|
113
131
|
if key.startswith(HOSTED_KEY_PREFIX):
|
|
114
132
|
if not _platform_client:
|
|
@@ -123,25 +141,30 @@ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: s
|
|
|
123
141
|
return jsonify({"error": "forbidden", "message": "Invalid API key."}), 403
|
|
124
142
|
elif not is_valid_agent_key(key, challenge_secret):
|
|
125
143
|
return jsonify({"error": "forbidden", "message": "Invalid API key."}), 403
|
|
126
|
-
if not
|
|
144
|
+
if not _agent_key_rate_limiter.check(_client_ip()):
|
|
127
145
|
return jsonify({"error": "rate_limited", "message": "Too many payment verification requests. Please wait and try again."}), 429
|
|
128
146
|
if not home_wallet_address:
|
|
129
147
|
return jsonify({"error": "server_error", "message": "Payment verification unavailable."}), 500
|
|
130
148
|
if grant_store and grant_store.has(key):
|
|
131
149
|
return None
|
|
132
|
-
|
|
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)
|
|
151
|
+
paid = scan_result["paid"]
|
|
133
152
|
if paid and grant_store:
|
|
134
|
-
|
|
153
|
+
tier = resolve_tier(scan_result["amount_paid"], price_config["pricing_tiers"])
|
|
154
|
+
duration_seconds = tier["duration_seconds"] if tier else price_config["access_duration"]
|
|
155
|
+
expires_at = (_time.time() + duration_seconds) if duration_seconds else None
|
|
156
|
+
grant_store.add(key, expires_at=expires_at, tier=(tier["name"] if tier else None))
|
|
135
157
|
if not paid:
|
|
136
158
|
return _payment_required_flask(
|
|
137
159
|
{
|
|
138
160
|
"error": "payment_required",
|
|
139
161
|
"message": "Key is valid but payment has not been verified on-chain yet.",
|
|
140
162
|
"your_key": key,
|
|
141
|
-
"payment": build_payment_object(network=network, min_payment=
|
|
163
|
+
"payment": build_payment_object(network=network, min_payment=price_config["min_payment"], wallet_address=home_wallet_address, memo=key, fee_info=fee_info),
|
|
142
164
|
},
|
|
143
|
-
wallet_address=home_wallet_address, mint=mint, min_payment=
|
|
165
|
+
wallet_address=home_wallet_address, mint=mint, min_payment=price_config["min_payment"],
|
|
144
166
|
debug=debug, agent_key=key, resource=path,
|
|
167
|
+
extra_tier_opts=tier_x402_opts_list(price_config["pricing_tiers"], {"wallet_address": home_wallet_address, "mint": mint, "debug": debug, "agent_key": key, "resource": path}),
|
|
145
168
|
)
|
|
146
169
|
return None
|
|
147
170
|
|
|
@@ -150,7 +173,7 @@ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: s
|
|
|
150
173
|
if is_valid_cookie_value(cookie_val, challenge_secret, client_ip):
|
|
151
174
|
return None
|
|
152
175
|
|
|
153
|
-
if not
|
|
176
|
+
if not _challenge_issue_rate_limiter.check(client_ip):
|
|
154
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"})
|
|
155
178
|
|
|
156
179
|
nonce = make_nonce(challenge_secret, client_ip)
|
|
@@ -164,7 +187,7 @@ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: s
|
|
|
164
187
|
@app.post("/__challenge/verify")
|
|
165
188
|
def _verify():
|
|
166
189
|
client_ip = _client_ip()
|
|
167
|
-
if not
|
|
190
|
+
if not _challenge_verify_limiter.check(client_ip):
|
|
168
191
|
return jsonify({"error": "rate_limited", "message": "Too many verification attempts. Please wait and try again."}), 429
|
|
169
192
|
nonce = request.form.get("nonce", "")[:MAX_NONCE_LENGTH]
|
|
170
193
|
return_to = request.form.get("return_to", "/")[:MAX_RETURN_TO_LENGTH]
|
|
@@ -19,7 +19,16 @@ Grant store interface (implement your own for Redis, Postgres, etc.):
|
|
|
19
19
|
|
|
20
20
|
class GrantStore(Protocol):
|
|
21
21
|
def has(self, agent_key: str) -> bool: ...
|
|
22
|
-
def add(self, agent_key: str) -> None: ...
|
|
22
|
+
def add(self, agent_key: str, expires_at: float | None = None, tier: str | None = None) -> None: ...
|
|
23
|
+
def revoke(self, agent_key: str) -> None: ...
|
|
24
|
+
|
|
25
|
+
``add``'s ``expires_at``/``tier`` arguments are optional — a custom store
|
|
26
|
+
whose ``add`` only accepts ``agent_key`` will break if a caller passes them
|
|
27
|
+
as keywords; this is the one Python-side interface change to be aware of
|
|
28
|
+
when upgrading a custom store (see CHANGELOG). ``revoke`` is called by the
|
|
29
|
+
vendor's own code (e.g. an admin route) to cut off a specific key early; the
|
|
30
|
+
adapters never call it — ``has()`` on the built-in stores already returns
|
|
31
|
+
False for a revoked or expired grant.
|
|
23
32
|
"""
|
|
24
33
|
|
|
25
34
|
from __future__ import annotations
|
|
@@ -27,23 +36,37 @@ from __future__ import annotations
|
|
|
27
36
|
import json
|
|
28
37
|
import os
|
|
29
38
|
import threading
|
|
39
|
+
import time
|
|
30
40
|
from pathlib import Path
|
|
31
41
|
|
|
32
42
|
|
|
43
|
+
def _is_expired(grant: dict) -> bool:
|
|
44
|
+
expires_at = grant.get("expires_at")
|
|
45
|
+
return expires_at is not None and time.time() > expires_at
|
|
46
|
+
|
|
47
|
+
|
|
33
48
|
class MemoryGrantStore:
|
|
34
49
|
"""In-memory grant store. Does not survive restarts."""
|
|
35
50
|
|
|
36
51
|
def __init__(self) -> None:
|
|
37
|
-
self._grants:
|
|
52
|
+
self._grants: dict[str, dict] = {}
|
|
38
53
|
self._lock = threading.Lock()
|
|
39
54
|
|
|
40
55
|
def has(self, agent_key: str) -> bool:
|
|
41
56
|
with self._lock:
|
|
42
|
-
|
|
57
|
+
grant = self._grants.get(agent_key)
|
|
58
|
+
if not grant or grant.get("revoked") or _is_expired(grant):
|
|
59
|
+
return False
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
def add(self, agent_key: str, expires_at: float | None = None, tier: str | None = None) -> None:
|
|
63
|
+
with self._lock:
|
|
64
|
+
self._grants[agent_key] = {"expires_at": expires_at, "tier": tier, "revoked": False}
|
|
43
65
|
|
|
44
|
-
def
|
|
66
|
+
def revoke(self, agent_key: str) -> None:
|
|
45
67
|
with self._lock:
|
|
46
|
-
self._grants.
|
|
68
|
+
existing = self._grants.get(agent_key, {"expires_at": None, "tier": None})
|
|
69
|
+
self._grants[agent_key] = {**existing, "revoked": True}
|
|
47
70
|
|
|
48
71
|
|
|
49
72
|
class FileGrantStore:
|
|
@@ -51,36 +74,57 @@ class FileGrantStore:
|
|
|
51
74
|
File-backed grant store. Persists grants to a JSON file so they survive
|
|
52
75
|
process restarts. Writes are atomic (write to temp file, os.replace).
|
|
53
76
|
|
|
77
|
+
Reads the legacy on-disk format (a plain JSON array of key strings) as
|
|
78
|
+
pre-existing permanent grants, so upgrading does not invalidate a
|
|
79
|
+
vendor's existing grants file. Writes the newer object-map format going
|
|
80
|
+
forward.
|
|
81
|
+
|
|
54
82
|
Not suitable for multi-process deployments — use a database or Redis there.
|
|
55
83
|
"""
|
|
56
84
|
|
|
57
85
|
def __init__(self, path: str | Path) -> None:
|
|
58
86
|
self._path = Path(path).resolve()
|
|
59
|
-
self._grants:
|
|
87
|
+
self._grants: dict[str, dict] = {}
|
|
60
88
|
self._lock = threading.Lock()
|
|
61
89
|
self._load()
|
|
62
90
|
|
|
63
91
|
def _load(self) -> None:
|
|
64
92
|
try:
|
|
65
|
-
|
|
66
|
-
if isinstance(
|
|
67
|
-
|
|
93
|
+
parsed = json.loads(self._path.read_text())
|
|
94
|
+
if isinstance(parsed, list):
|
|
95
|
+
# Legacy format: array of key strings, each a permanent grant.
|
|
96
|
+
for key in parsed:
|
|
97
|
+
self._grants[key] = {"expires_at": None, "tier": None, "revoked": False}
|
|
98
|
+
elif isinstance(parsed, dict):
|
|
99
|
+
for key, grant in parsed.items():
|
|
100
|
+
self._grants[key] = {
|
|
101
|
+
"expires_at": grant.get("expires_at"),
|
|
102
|
+
"tier": grant.get("tier"),
|
|
103
|
+
"revoked": bool(grant.get("revoked")),
|
|
104
|
+
}
|
|
68
105
|
except FileNotFoundError:
|
|
69
106
|
pass # will be created on first write
|
|
70
107
|
|
|
71
108
|
def _save(self) -> None:
|
|
72
109
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
110
|
tmp = self._path.with_suffix(".tmp")
|
|
74
|
-
tmp.write_text(json.dumps(
|
|
111
|
+
tmp.write_text(json.dumps(self._grants, indent=2, sort_keys=True))
|
|
75
112
|
os.replace(tmp, self._path)
|
|
76
113
|
|
|
77
114
|
def has(self, agent_key: str) -> bool:
|
|
78
115
|
with self._lock:
|
|
79
|
-
|
|
116
|
+
grant = self._grants.get(agent_key)
|
|
117
|
+
if not grant or grant.get("revoked") or _is_expired(grant):
|
|
118
|
+
return False
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
def add(self, agent_key: str, expires_at: float | None = None, tier: str | None = None) -> None:
|
|
122
|
+
with self._lock:
|
|
123
|
+
self._grants[agent_key] = {"expires_at": expires_at, "tier": tier, "revoked": False}
|
|
124
|
+
self._save()
|
|
80
125
|
|
|
81
|
-
def
|
|
126
|
+
def revoke(self, agent_key: str) -> None:
|
|
82
127
|
with self._lock:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
self._grants.add(agent_key)
|
|
128
|
+
existing = self._grants.get(agent_key, {"expires_at": None, "tier": None})
|
|
129
|
+
self._grants[agent_key] = {**existing, "revoked": True}
|
|
86
130
|
self._save()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pricing/access-model resolution shared by all three adapters (Django,
|
|
3
|
+
FastAPI, Flask): payment-amount -> access-duration tiers, and per-route
|
|
4
|
+
price/duration/tier overrides for a single middleware instance.
|
|
5
|
+
|
|
6
|
+
Config shapes (plain dicts, so they're trivial to declare in settings.py or
|
|
7
|
+
pass as constructor kwargs):
|
|
8
|
+
|
|
9
|
+
pricing_tier = {"min_amount": 0.01, "duration_seconds": 3600, "name": "hourly"}
|
|
10
|
+
route_config = {"path_prefix": "/premium", "min_payment": 0.05,
|
|
11
|
+
"access_duration": None, "pricing_tiers": None}
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def sort_tiers(tiers: list[dict]) -> list[dict]:
|
|
18
|
+
return sorted(tiers, key=lambda t: t["min_amount"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_tier(amount_paid: float | None, pricing_tiers: list[dict] | None) -> dict | None:
|
|
22
|
+
"""Highest tier whose min_amount the actual paid amount satisfies, or None."""
|
|
23
|
+
if not pricing_tiers or amount_paid is None:
|
|
24
|
+
return None
|
|
25
|
+
matched = None
|
|
26
|
+
for tier in sort_tiers(pricing_tiers):
|
|
27
|
+
if amount_paid >= tier["min_amount"]:
|
|
28
|
+
matched = tier
|
|
29
|
+
else:
|
|
30
|
+
break
|
|
31
|
+
return matched
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def normalize_price_config(min_payment: float, access_duration: float | None, pricing_tiers: list[dict] | None) -> dict:
|
|
35
|
+
"""Collapses a (min_payment, access_duration, pricing_tiers) config into
|
|
36
|
+
its effective floor price (lowest tier's min_amount when tiers are set)."""
|
|
37
|
+
if pricing_tiers:
|
|
38
|
+
sorted_tiers = sort_tiers(pricing_tiers)
|
|
39
|
+
return {"min_payment": sorted_tiers[0]["min_amount"], "access_duration": None, "pricing_tiers": sorted_tiers}
|
|
40
|
+
return {"min_payment": min_payment, "access_duration": access_duration, "pricing_tiers": None}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def path_matches_prefix(pathname: str, prefix: str) -> bool:
|
|
44
|
+
if pathname == prefix:
|
|
45
|
+
return True
|
|
46
|
+
boundary = prefix if prefix.endswith("/") else prefix + "/"
|
|
47
|
+
return pathname.startswith(boundary)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_routes_table(routes: list[dict] | None, min_payment: float, access_duration: float | None, pricing_tiers: list[dict] | None) -> list[dict]:
|
|
51
|
+
"""Precomputes normalized route entries once at middleware-construction time."""
|
|
52
|
+
table = []
|
|
53
|
+
for r in routes or []:
|
|
54
|
+
normalized = normalize_price_config(
|
|
55
|
+
r.get("min_payment", min_payment),
|
|
56
|
+
r.get("access_duration", access_duration),
|
|
57
|
+
r.get("pricing_tiers", pricing_tiers),
|
|
58
|
+
)
|
|
59
|
+
table.append({"path_prefix": r["path_prefix"], **normalized})
|
|
60
|
+
return table
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_route_config(pathname: str, routes_table: list[dict], base_config: dict) -> dict:
|
|
64
|
+
"""Longest-prefix match against routes_table, falling back to base_config."""
|
|
65
|
+
best = None
|
|
66
|
+
for route in routes_table:
|
|
67
|
+
if path_matches_prefix(pathname, route["path_prefix"]):
|
|
68
|
+
if best is None or len(route["path_prefix"]) > len(best["path_prefix"]):
|
|
69
|
+
best = route
|
|
70
|
+
return best or base_config
|
|
@@ -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,26 +98,48 @@ 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, 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
|
-
Verify payment on-chain.
|
|
103
|
+
Verify payment on-chain. Returns true/false only — the stable, tested
|
|
104
|
+
public API. See _scan_for_payment below for the amount-returning variant
|
|
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.
|
|
112
|
+
"""
|
|
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)
|
|
114
|
+
return result["paid"]
|
|
115
|
+
|
|
116
|
+
|
|
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:
|
|
118
|
+
"""
|
|
119
|
+
Scans the chain for a matching payment, same as verify_payment_on_chain,
|
|
120
|
+
but also returns the actual amount paid (decimal USDC) so callers can
|
|
121
|
+
resolve pricing tiers. rpc_url may be a string or list of strings
|
|
122
|
+
(fallback URLs).
|
|
104
123
|
|
|
105
124
|
fee_info, when set (hosted-platform mode with an on-chain fee configured), is
|
|
106
125
|
{"wallet": str, "rate_pct": float}. When set, the SAME transaction that carries
|
|
107
126
|
the vendor payment must also carry a USDC transfer to fee_info["wallet"] of at
|
|
108
|
-
least
|
|
127
|
+
least min_payment * rate_pct / 100, or the payment is treated as unverified.
|
|
109
128
|
"""
|
|
129
|
+
cache = payment_cache if payment_cache is not None else _payment_cache
|
|
110
130
|
# Normalise to list so _rpc_call_with_fallback always gets a list.
|
|
111
131
|
rpc_urls: list[str] = rpc_url if isinstance(rpc_url, list) else [rpc_url]
|
|
132
|
+
min_payment_micro = round(min_payment * 1_000_000)
|
|
133
|
+
not_paid = {"paid": False, "amount_paid": None}
|
|
112
134
|
|
|
113
|
-
cached =
|
|
135
|
+
cached = cache.get(agent_key)
|
|
114
136
|
if cached is True:
|
|
115
|
-
return True
|
|
137
|
+
return {"paid": True, "amount_paid": None} # cache doesn't retain the amount
|
|
116
138
|
if cached is False:
|
|
117
|
-
return
|
|
139
|
+
return not_paid # negative cached — skip RPC until TTL expires
|
|
118
140
|
if not is_valid_solana_address(wallet_address):
|
|
119
141
|
logger.error("[gate] Invalid wallet address: %s", wallet_address)
|
|
120
|
-
return
|
|
142
|
+
return not_paid
|
|
121
143
|
try:
|
|
122
144
|
# commitment: 'finalized' — confirmed blocks can be rolled back (rare but possible).
|
|
123
145
|
# Finalized adds ~10-20 s latency vs confirmed but guarantees irreversibility.
|
|
@@ -128,16 +150,16 @@ def verify_payment_on_chain(agent_key: str, wallet_address: str, rpc_url, usdc_m
|
|
|
128
150
|
# token is USDC for plain `transfer` instructions (which carry no mint field).
|
|
129
151
|
vendor_usdc_accounts = set(token_accounts)
|
|
130
152
|
if not vendor_usdc_accounts:
|
|
131
|
-
return
|
|
153
|
+
return not_paid # vendor has no USDC account yet — no payment possible
|
|
132
154
|
|
|
133
155
|
fee_usdc_accounts = None
|
|
134
156
|
fee_amount_micro = 0
|
|
135
157
|
if fee_info:
|
|
136
158
|
fee_ata_data = _rpc_call_with_fallback(rpc_urls, "getTokenAccountsByOwner", [fee_info["wallet"], {"mint": usdc_mint}, {"encoding": "jsonParsed", "commitment": "finalized"}])
|
|
137
159
|
fee_usdc_accounts = {a["pubkey"] for a in fee_ata_data.get("result", {}).get("value", [])}
|
|
138
|
-
fee_amount_micro = round(
|
|
160
|
+
fee_amount_micro = round(min_payment_micro * fee_info["rate_pct"] / 100)
|
|
139
161
|
if not fee_usdc_accounts:
|
|
140
|
-
return
|
|
162
|
+
return not_paid # fee wallet has no USDC account — fee can never be satisfied
|
|
141
163
|
|
|
142
164
|
addresses_to_scan = [wallet_address] + token_accounts
|
|
143
165
|
seen = set()
|
|
@@ -173,6 +195,7 @@ def verify_payment_on_chain(agent_key: str, wallet_address: str, rpc_url, usdc_m
|
|
|
173
195
|
has_memo = False
|
|
174
196
|
has_payment = False
|
|
175
197
|
has_fee_payment = fee_info is None # vacuously satisfied when no fee is required
|
|
198
|
+
matched_amount_micro = None
|
|
176
199
|
|
|
177
200
|
for ix in all_ix:
|
|
178
201
|
program = ix.get("program", "")
|
|
@@ -206,16 +229,17 @@ def verify_payment_on_chain(agent_key: str, wallet_address: str, rpc_url, usdc_m
|
|
|
206
229
|
amount_micro = int(amount_str)
|
|
207
230
|
except (ValueError, TypeError):
|
|
208
231
|
amount_micro = 0
|
|
209
|
-
if is_vendor_dest and amount_micro >=
|
|
232
|
+
if is_vendor_dest and amount_micro >= min_payment_micro:
|
|
210
233
|
has_payment = True
|
|
234
|
+
matched_amount_micro = amount_micro
|
|
211
235
|
elif is_fee_dest and amount_micro >= fee_amount_micro:
|
|
212
236
|
has_fee_payment = True
|
|
213
237
|
|
|
214
238
|
if has_memo and has_payment and has_fee_payment:
|
|
215
|
-
|
|
216
|
-
return True
|
|
239
|
+
cache.set(agent_key, True, PAYMENT_CACHE_TTL)
|
|
240
|
+
return {"paid": True, "amount_paid": matched_amount_micro / 1_000_000}
|
|
217
241
|
except Exception:
|
|
218
242
|
logger.exception("[gate] Solana RPC error")
|
|
219
243
|
|
|
220
|
-
|
|
221
|
-
return
|
|
244
|
+
cache.set(agent_key, False, NEGATIVE_CACHE_TTL)
|
|
245
|
+
return not_paid
|
agentpayments_python/x402.py
CHANGED
|
@@ -30,6 +30,7 @@ def build_payment_requirements(
|
|
|
30
30
|
debug: bool,
|
|
31
31
|
agent_key: str = "",
|
|
32
32
|
resource: str = "",
|
|
33
|
+
tier: dict | None = None,
|
|
33
34
|
) -> dict:
|
|
34
35
|
"""
|
|
35
36
|
Build an x402-standard PaymentRequirements dict for the Solana exact scheme.
|
|
@@ -42,6 +43,9 @@ def build_payment_requirements(
|
|
|
42
43
|
agent_key: If set, included as extra.memo so x402 clients know
|
|
43
44
|
which key to reference in their transaction memo.
|
|
44
45
|
resource: URL path of the gated resource (optional).
|
|
46
|
+
tier: Optional {"name": str, "duration_seconds": int|None} —
|
|
47
|
+
non-standard x402 extension surfacing pricing-tier
|
|
48
|
+
duration so an agent can compare tiers upfront.
|
|
45
49
|
|
|
46
50
|
Returns:
|
|
47
51
|
PaymentRequirements dict per the x402 SVM exact scheme spec.
|
|
@@ -59,6 +63,7 @@ def build_payment_requirements(
|
|
|
59
63
|
"name": "USDC",
|
|
60
64
|
"decimals": USDC_DECIMALS,
|
|
61
65
|
**({"memo": agent_key} if agent_key else {}),
|
|
66
|
+
**({"tier": tier.get("name"), "durationSeconds": tier.get("duration_seconds")} if tier else {}),
|
|
62
67
|
},
|
|
63
68
|
}
|
|
64
69
|
if resource:
|
|
@@ -66,6 +71,23 @@ def build_payment_requirements(
|
|
|
66
71
|
return req
|
|
67
72
|
|
|
68
73
|
|
|
74
|
+
def tier_x402_opts_list(pricing_tiers: list[dict] | None, base_opts: dict) -> list[dict]:
|
|
75
|
+
"""
|
|
76
|
+
Builds one build_payment_requirements()-kwargs dict per non-floor pricing
|
|
77
|
+
tier, so the 402 response's accepts[] array lets an agent compare
|
|
78
|
+
price/duration tradeoffs upfront. The floor tier is already covered by
|
|
79
|
+
the primary entry built from the resolved base min_payment.
|
|
80
|
+
"""
|
|
81
|
+
if not pricing_tiers or len(pricing_tiers) < 2:
|
|
82
|
+
return []
|
|
83
|
+
from .pricing import sort_tiers
|
|
84
|
+
sorted_tiers = sort_tiers(pricing_tiers)
|
|
85
|
+
return [
|
|
86
|
+
{**base_opts, "min_payment": t["min_amount"], "tier": {"name": t.get("name"), "duration_seconds": t.get("duration_seconds")}}
|
|
87
|
+
for t in sorted_tiers[1:]
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
69
91
|
def build_payment_object(
|
|
70
92
|
*,
|
|
71
93
|
network: str,
|
|
@@ -116,13 +138,13 @@ def payment_required_header(payment_requirements: dict) -> str:
|
|
|
116
138
|
return base64.b64encode(_json.dumps(payment_requirements).encode()).decode()
|
|
117
139
|
|
|
118
140
|
|
|
119
|
-
def enrich_402_body(body: dict, payment_requirements: dict) -> dict:
|
|
141
|
+
def enrich_402_body(body: dict, payment_requirements: dict, extra_payment_requirements: list[dict] | None = None) -> dict:
|
|
120
142
|
"""
|
|
121
143
|
Prepend x402Version and accepts[] to a 402 response body dict,
|
|
122
144
|
keeping all existing fields for backward compatibility.
|
|
123
145
|
"""
|
|
124
146
|
return {
|
|
125
147
|
"x402Version": X402_VERSION,
|
|
126
|
-
"accepts": [payment_requirements],
|
|
148
|
+
"accepts": [payment_requirements, *(extra_payment_requirements or [])],
|
|
127
149
|
**body,
|
|
128
150
|
}
|
|
@@ -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
|
|
@@ -116,9 +116,16 @@ register_agentpayments(
|
|
|
116
116
|
| `home_wallet_address` | Yes | `''` | Solana wallet address to receive USDC payments. |
|
|
117
117
|
| `solana_rpc_url` | No | Auto (devnet/mainnet) | Custom Solana RPC endpoint. |
|
|
118
118
|
| `usdc_mint` | No | Auto (devnet/mainnet) | Custom USDC mint address. |
|
|
119
|
+
| `min_payment` | No | `0.01` | Minimum USDC payment required. Ignored when `pricing_tiers` is set (the lowest tier's `min_amount` becomes the floor). |
|
|
120
|
+
| `access_duration` | No | `None` (forever) | Seconds a successful payment grants access for. Requires `grant_store` to actually persist. |
|
|
121
|
+
| `pricing_tiers` | No | `None` | Payment-amount → access mapping: `[{"min_amount": ..., "duration_seconds": ..., "name": ...}]`. Overrides `min_payment`/`access_duration`. |
|
|
122
|
+
| `routes` | No | `None` | Per-route overrides for a single middleware instance: `[{"path_prefix": ..., "min_payment": ..., "access_duration": ..., "pricing_tiers": ...}]`, matched by longest `path_prefix`. |
|
|
119
123
|
| `debug` | No | `True` | `True` = devnet. `False` = mainnet + strict mode. |
|
|
120
124
|
| `api_key` | No | `None` | AgentPayments hosted-platform API key (`ap_live_...`). When set, agent keys are issued and metered via the platform instead of self-signed locally. See **Hosted Platform Mode** below. |
|
|
121
125
|
| `platform_url` | No | AgentPayments-hosted URL | Override for a self-hosted platform API. |
|
|
126
|
+
| `agent_key_rate_limiter` | No | Built-in in-memory (10/min/IP) | Pluggable rate limiter for the agent-key payment-verification path. Pass a Redis-backed one for multi-process deployments — see **Multi-Process Deployments** below. |
|
|
127
|
+
| `challenge_issue_rate_limiter` | No | Built-in in-memory (30/min/IP) | Pluggable rate limiter for browser challenge-page issuance. |
|
|
128
|
+
| `payment_cache` | No | Module-level in-memory singleton | Pluggable payment-verification cache (10-min positive / 30s negative TTL by default). |
|
|
122
129
|
|
|
123
130
|
Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`, `settings.AGENTPAYMENTS_API_KEY`). FastAPI and Flask accept them as constructor arguments.
|
|
124
131
|
|
|
@@ -126,6 +133,72 @@ Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`, `settin
|
|
|
126
133
|
|
|
127
134
|
Setting `api_key` switches agent-key issuance from local (`ag_...`) to platform-issued (`agp_...`), and — when the platform account has an on-chain fee configured — every 402 response's `payment` dict gains a `platform_fee` field describing a second required USDC transfer, to be sent in the **same Solana transaction** as the vendor payment. Missing that second transfer is treated as an unpaid request, same as any other invalid payment. This is opt-in per vendor account and has no effect on self-hosted deployments (no `api_key`). The standards-compliant `accepts[]`/`X-PAYMENT-REQUIRED` x402 fields are untouched — they still describe only the vendor leg.
|
|
128
135
|
|
|
136
|
+
## Pricing & Access Model
|
|
137
|
+
|
|
138
|
+
By default one `min_payment` buys indefinite access. Three parameters layer on top, identical across Django/FastAPI/Flask (Django via `settings.AGENTPAYMENTS_*`, the others as constructor kwargs):
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
# FastAPI / Flask
|
|
142
|
+
register_agentpayments( # or AgentPaymentsASGIMiddleware(...)
|
|
143
|
+
app, ...,
|
|
144
|
+
grant_store=FileGrantStore("./data/grants.json"), # required for duration/tiers to persist
|
|
145
|
+
access_duration=86400, # seconds; a paid key is only good for 24h, then re-verifies
|
|
146
|
+
pricing_tiers=[
|
|
147
|
+
{"min_amount": 0.01, "duration_seconds": 3600, "name": "hourly"},
|
|
148
|
+
{"min_amount": 0.05, "duration_seconds": 86400, "name": "daily"},
|
|
149
|
+
{"min_amount": 0.50, "duration_seconds": None, "name": "lifetime"},
|
|
150
|
+
],
|
|
151
|
+
routes=[
|
|
152
|
+
{"path_prefix": "/api/premium", "min_payment": 0.05},
|
|
153
|
+
{"path_prefix": "/api/basic", "min_payment": 0.01},
|
|
154
|
+
],
|
|
155
|
+
)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
# Django settings.py
|
|
160
|
+
AGENTPAYMENTS_GRANT_STORE = FileGrantStore("/var/data/agp_grants.json")
|
|
161
|
+
AGENTPAYMENTS_ACCESS_DURATION = 86400
|
|
162
|
+
AGENTPAYMENTS_PRICING_TIERS = [...] # same shape as above
|
|
163
|
+
AGENTPAYMENTS_ROUTES = [...]
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`pricing_tiers` overrides `min_payment`/`access_duration` when set — the gate finds the highest tier the actual on-chain payment clears and grants that tier's duration; the lowest tier's `min_amount` becomes the floor price for any access at all. Every non-floor tier also appears as its own entry in the 402 response's x402 `accepts[]` array. `routes` is matched by longest `path_prefix` on a path-segment boundary; unmatched requests fall back to the top-level `min_payment`/`access_duration`/`pricing_tiers`.
|
|
167
|
+
|
|
168
|
+
**Revocation**: `MemoryGrantStore`/`FileGrantStore` (`agentpayments_python.grant_store`) both gained a `revoke(agent_key)` method — call it from your own admin view to cut off a specific paid key early. The adapters need no changes to respect this: `has()` already returns `False` for a revoked (or expired) grant. A grants file written by an older SDK version (a plain JSON array of key strings) is still read correctly as a set of permanent grants.
|
|
169
|
+
|
|
170
|
+
## Multi-Process Deployments (Redis)
|
|
171
|
+
|
|
172
|
+
The built-in rate limiters and payment cache are in-memory and **per-process** — with `gunicorn -w 4`, each of the 4 workers enforces its own independent 10-req/min agent-key limit (40/min in aggregate) and caches payment results separately, so a payment verified by one worker isn't recognized by another until its own cache/rate-limit state catches up. For any multi-process deployment, plug in a Redis-backed store instead:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
import redis
|
|
176
|
+
from agentpayments_python.redis_store import create_redis_store
|
|
177
|
+
|
|
178
|
+
r = redis.Redis.from_url(os.environ["REDIS_URL"])
|
|
179
|
+
store = create_redis_store(r)
|
|
180
|
+
|
|
181
|
+
# FastAPI / Flask
|
|
182
|
+
register_agentpayments(app, ..., # or AgentPaymentsASGIMiddleware(...)
|
|
183
|
+
agent_key_rate_limiter=store["agent_key_rate_limiter"],
|
|
184
|
+
challenge_issue_rate_limiter=store["challenge_issue_rate_limiter"],
|
|
185
|
+
payment_cache=store["payment_cache"],
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Django settings.py
|
|
189
|
+
AGENTPAYMENTS_AGENT_KEY_RATE_LIMITER = store["agent_key_rate_limiter"]
|
|
190
|
+
AGENTPAYMENTS_CHALLENGE_ISSUE_RATE_LIMITER = store["challenge_issue_rate_limiter"]
|
|
191
|
+
AGENTPAYMENTS_PAYMENT_CACHE = store["payment_cache"]
|
|
192
|
+
|
|
193
|
+
# The /__challenge/verify endpoint has its own rate limiter, passed separately
|
|
194
|
+
# since it's a standalone route/view rather than the main middleware:
|
|
195
|
+
# FastAPI: challenge_verify_endpoint(request, challenge_secret=..., rate_limiter=store["challenge_verify_rate_limiter"])
|
|
196
|
+
# Django: AGENTPAYMENTS_CHALLENGE_VERIFY_RATE_LIMITER = store["challenge_verify_rate_limiter"]
|
|
197
|
+
# Flask: register_agentpayments(app, ..., challenge_verify_rate_limiter=store["challenge_verify_rate_limiter"])
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`redis_store.py` has no hard dependency on the `redis` package — it's duck-typed against any client exposing `eval(script, numkeys, *args)`, `get(key)`, and `set(key, value, ex=ttl)` (redis-py's `Redis` client satisfies this directly), mirroring `sdk/node/redis-store.js`. Rate limiting fails open on a Redis error (never blocks legitimate traffic); the payment cache fails to a miss (falls through to a normal chain scan). All four are optional — anything left unset keeps using the default in-memory implementation.
|
|
201
|
+
|
|
129
202
|
## Security Features
|
|
130
203
|
|
|
131
204
|
- **Timing-safe HMAC comparison** — uses `hmac.compare_digest()` for all signature checks
|
|
@@ -150,6 +223,9 @@ agentpayments_python/
|
|
|
150
223
|
detection.py Browser detection and public path checks
|
|
151
224
|
solana.py On-chain payment verification + caching
|
|
152
225
|
ratelimit.py Shared IP-based rate limiter
|
|
226
|
+
grant_store.py Durable paid-key persistence (expiry, revocation)
|
|
227
|
+
pricing.py Pricing-tier / access-duration / per-route resolution
|
|
228
|
+
redis_store.py Redis-backed rate limiter + payment cache (multi-process)
|
|
153
229
|
```
|
|
154
230
|
|
|
155
231
|
## Notes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
agentpayments_python/__init__.py,sha256=-_ZaZGRDrd1kueJ5wvc4A4P_wt6QOchSG9gLK_BKlH4,952
|
|
2
|
+
agentpayments_python/challenge.py,sha256=feWqlTcQ1LMONuwFBUOQBQOw5tsO1f7PirZZN_S3e3A,6386
|
|
3
|
+
agentpayments_python/constants.json,sha256=GbrHSYfHwCGq8kgdNAiOdyqhc3-s-oX2qWvjRsbhjVo,981
|
|
4
|
+
agentpayments_python/cookies.py,sha256=0xO2_e4R4FpOprBcGWqY84E_eJppgJCkMGLCElZOQ90,1244
|
|
5
|
+
agentpayments_python/crawler.py,sha256=G06nF6qFmyA5nDvMW18rfh44JxxJMKc2GCO0DXiZuoY,2514
|
|
6
|
+
agentpayments_python/crypto.py,sha256=81xNcH1E468v26BimEQpK6n42bkqyz4q6eXhLLqQ6QY,1315
|
|
7
|
+
agentpayments_python/detection.py,sha256=5KRaVdlMjOUaE167WTQyDXWIwF6sRFZ1DGZ2eqtoBAQ,1938
|
|
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
|
+
agentpayments_python/grant_store.py,sha256=ewYAxKocgfJE_txZp1SDnML066QP4BBB6V9vXIo47eE,5057
|
|
12
|
+
agentpayments_python/platform_client.py,sha256=T-fSzxsABXVJ9nfrqL370GboV2-hdJaA-ploOw0uOd8,5108
|
|
13
|
+
agentpayments_python/pricing.py,sha256=3AujxsDlP5pMBals4FnoPdfOYBe1VPLREo_IJtJVxwg,2910
|
|
14
|
+
agentpayments_python/ratelimit.py,sha256=Q6uxFRbYbf7D8qykZ9ieVaSCU8FkXFYPS59XmkrnE_k,1580
|
|
15
|
+
agentpayments_python/redis_store.py,sha256=2WSPBJYi8qp7B_yhqYX1bemfjbYn8OrH_ww9qpRw7Bc,5145
|
|
16
|
+
agentpayments_python/solana.py,sha256=SE6psJlveTZJEsY6I2Q1Dttp1cqdzuzsqM_yeer5toU,11802
|
|
17
|
+
agentpayments_python/x402.py,sha256=PS5U_dWX1CyRMTxxAYnyuzb-q4mRWvXe1Pys3y-CvEg,5527
|
|
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,,
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
agentpayments_python/__init__.py,sha256=-_ZaZGRDrd1kueJ5wvc4A4P_wt6QOchSG9gLK_BKlH4,952
|
|
2
|
-
agentpayments_python/challenge.py,sha256=feWqlTcQ1LMONuwFBUOQBQOw5tsO1f7PirZZN_S3e3A,6386
|
|
3
|
-
agentpayments_python/constants.json,sha256=GbrHSYfHwCGq8kgdNAiOdyqhc3-s-oX2qWvjRsbhjVo,981
|
|
4
|
-
agentpayments_python/cookies.py,sha256=0xO2_e4R4FpOprBcGWqY84E_eJppgJCkMGLCElZOQ90,1244
|
|
5
|
-
agentpayments_python/crawler.py,sha256=G06nF6qFmyA5nDvMW18rfh44JxxJMKc2GCO0DXiZuoY,2514
|
|
6
|
-
agentpayments_python/crypto.py,sha256=81xNcH1E468v26BimEQpK6n42bkqyz4q6eXhLLqQ6QY,1315
|
|
7
|
-
agentpayments_python/detection.py,sha256=5KRaVdlMjOUaE167WTQyDXWIwF6sRFZ1DGZ2eqtoBAQ,1938
|
|
8
|
-
agentpayments_python/django_adapter.py,sha256=vmFLt0d-IliHsIi7zpGxSegS2dmSAapaIg9chfXvlgY,13138
|
|
9
|
-
agentpayments_python/fastapi_adapter.py,sha256=3rhe-uGoJ9vNMcyAr5M0rloZQFTDZ18-45BpWYTnxfs,12714
|
|
10
|
-
agentpayments_python/flask_adapter.py,sha256=ns2dgoOWrrvNgJ4oZDVV68vBwxzGcIINOBY6Sk0ht6A,10996
|
|
11
|
-
agentpayments_python/grant_store.py,sha256=wqRsSdbpzd_TGy6a6LJRtNOMbjVBJQm7H3qpIVHEeuw,2643
|
|
12
|
-
agentpayments_python/platform_client.py,sha256=T-fSzxsABXVJ9nfrqL370GboV2-hdJaA-ploOw0uOd8,5108
|
|
13
|
-
agentpayments_python/ratelimit.py,sha256=Q6uxFRbYbf7D8qykZ9ieVaSCU8FkXFYPS59XmkrnE_k,1580
|
|
14
|
-
agentpayments_python/solana.py,sha256=Mlc0Ig4fGJCyuN-cClsSqIgTqXBgArTDix3jAnyCieg,10272
|
|
15
|
-
agentpayments_python/x402.py,sha256=8UUG7dpYXOiLjhXfAbbBSyBuoiMuO6oVobqFk17hIM8,4336
|
|
16
|
-
agentpayments_python-0.1.2.dist-info/licenses/LICENSE,sha256=PMKDe4kanHRVI7QQezG4GeluZkMljhVO-tFSFNUiYS4,1069
|
|
17
|
-
agentpayments_python-0.1.2.dist-info/METADATA,sha256=h2z4XBoxpw6lzG2ljHdBbQlyyHeY9Lrhq2Zs3SkyCVM,6362
|
|
18
|
-
agentpayments_python-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
19
|
-
agentpayments_python-0.1.2.dist-info/top_level.txt,sha256=DxGMGvTYQVdoBQ6pBbbsJXy5BZhbSjNGF_uSO3git4w,21
|
|
20
|
-
agentpayments_python-0.1.2.dist-info/RECORD,,
|
|
File without changes
|
{agentpayments_python-0.1.2.dist-info → agentpayments_python-0.3.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|