agentpayments-python 0.1.2__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/PKG-INFO +77 -1
- agentpayments_python-0.3.0/README.md +197 -0
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/django_adapter.py +48 -15
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/fastapi_adapter.py +44 -19
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/flask_adapter.py +39 -16
- agentpayments_python-0.3.0/agentpayments_python/grant_store.py +130 -0
- agentpayments_python-0.3.0/agentpayments_python/pricing.py +70 -0
- agentpayments_python-0.3.0/agentpayments_python/redis_store.py +128 -0
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/solana.py +39 -15
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/x402.py +24 -2
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/PKG-INFO +77 -1
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/SOURCES.txt +2 -0
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/pyproject.toml +1 -1
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/tests/test_core.py +534 -5
- agentpayments_python-0.1.2/README.md +0 -121
- agentpayments_python-0.1.2/agentpayments_python/grant_store.py +0 -86
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/LICENSE +0 -0
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/__init__.py +0 -0
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/challenge.py +0 -0
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/constants.json +0 -0
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/cookies.py +0 -0
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/crawler.py +0 -0
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/crypto.py +0 -0
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/detection.py +0 -0
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/platform_client.py +0 -0
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/ratelimit.py +0 -0
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/dependency_links.txt +0 -0
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/requires.txt +0 -0
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python.egg-info/top_level.txt +0 -0
- {agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentpayments-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: AgentPayments gate for Python web frameworks — charge AI agents USDC on Solana before they can access your API
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/adambrzosko/AgentPayments
|
|
@@ -116,9 +116,16 @@ register_agentpayments(
|
|
|
116
116
|
| `home_wallet_address` | Yes | `''` | Solana wallet address to receive USDC payments. |
|
|
117
117
|
| `solana_rpc_url` | No | Auto (devnet/mainnet) | Custom Solana RPC endpoint. |
|
|
118
118
|
| `usdc_mint` | No | Auto (devnet/mainnet) | Custom USDC mint address. |
|
|
119
|
+
| `min_payment` | No | `0.01` | Minimum USDC payment required. Ignored when `pricing_tiers` is set (the lowest tier's `min_amount` becomes the floor). |
|
|
120
|
+
| `access_duration` | No | `None` (forever) | Seconds a successful payment grants access for. Requires `grant_store` to actually persist. |
|
|
121
|
+
| `pricing_tiers` | No | `None` | Payment-amount → access mapping: `[{"min_amount": ..., "duration_seconds": ..., "name": ...}]`. Overrides `min_payment`/`access_duration`. |
|
|
122
|
+
| `routes` | No | `None` | Per-route overrides for a single middleware instance: `[{"path_prefix": ..., "min_payment": ..., "access_duration": ..., "pricing_tiers": ...}]`, matched by longest `path_prefix`. |
|
|
119
123
|
| `debug` | No | `True` | `True` = devnet. `False` = mainnet + strict mode. |
|
|
120
124
|
| `api_key` | No | `None` | AgentPayments hosted-platform API key (`ap_live_...`). When set, agent keys are issued and metered via the platform instead of self-signed locally. See **Hosted Platform Mode** below. |
|
|
121
125
|
| `platform_url` | No | AgentPayments-hosted URL | Override for a self-hosted platform API. |
|
|
126
|
+
| `agent_key_rate_limiter` | No | Built-in in-memory (10/min/IP) | Pluggable rate limiter for the agent-key payment-verification path. Pass a Redis-backed one for multi-process deployments — see **Multi-Process Deployments** below. |
|
|
127
|
+
| `challenge_issue_rate_limiter` | No | Built-in in-memory (30/min/IP) | Pluggable rate limiter for browser challenge-page issuance. |
|
|
128
|
+
| `payment_cache` | No | Module-level in-memory singleton | Pluggable payment-verification cache (10-min positive / 30s negative TTL by default). |
|
|
122
129
|
|
|
123
130
|
Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`, `settings.AGENTPAYMENTS_API_KEY`). FastAPI and Flask accept them as constructor arguments.
|
|
124
131
|
|
|
@@ -126,6 +133,72 @@ Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`, `settin
|
|
|
126
133
|
|
|
127
134
|
Setting `api_key` switches agent-key issuance from local (`ag_...`) to platform-issued (`agp_...`), and — when the platform account has an on-chain fee configured — every 402 response's `payment` dict gains a `platform_fee` field describing a second required USDC transfer, to be sent in the **same Solana transaction** as the vendor payment. Missing that second transfer is treated as an unpaid request, same as any other invalid payment. This is opt-in per vendor account and has no effect on self-hosted deployments (no `api_key`). The standards-compliant `accepts[]`/`X-PAYMENT-REQUIRED` x402 fields are untouched — they still describe only the vendor leg.
|
|
128
135
|
|
|
136
|
+
## Pricing & Access Model
|
|
137
|
+
|
|
138
|
+
By default one `min_payment` buys indefinite access. Three parameters layer on top, identical across Django/FastAPI/Flask (Django via `settings.AGENTPAYMENTS_*`, the others as constructor kwargs):
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
# FastAPI / Flask
|
|
142
|
+
register_agentpayments( # or AgentPaymentsASGIMiddleware(...)
|
|
143
|
+
app, ...,
|
|
144
|
+
grant_store=FileGrantStore("./data/grants.json"), # required for duration/tiers to persist
|
|
145
|
+
access_duration=86400, # seconds; a paid key is only good for 24h, then re-verifies
|
|
146
|
+
pricing_tiers=[
|
|
147
|
+
{"min_amount": 0.01, "duration_seconds": 3600, "name": "hourly"},
|
|
148
|
+
{"min_amount": 0.05, "duration_seconds": 86400, "name": "daily"},
|
|
149
|
+
{"min_amount": 0.50, "duration_seconds": None, "name": "lifetime"},
|
|
150
|
+
],
|
|
151
|
+
routes=[
|
|
152
|
+
{"path_prefix": "/api/premium", "min_payment": 0.05},
|
|
153
|
+
{"path_prefix": "/api/basic", "min_payment": 0.01},
|
|
154
|
+
],
|
|
155
|
+
)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
# Django settings.py
|
|
160
|
+
AGENTPAYMENTS_GRANT_STORE = FileGrantStore("/var/data/agp_grants.json")
|
|
161
|
+
AGENTPAYMENTS_ACCESS_DURATION = 86400
|
|
162
|
+
AGENTPAYMENTS_PRICING_TIERS = [...] # same shape as above
|
|
163
|
+
AGENTPAYMENTS_ROUTES = [...]
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`pricing_tiers` overrides `min_payment`/`access_duration` when set — the gate finds the highest tier the actual on-chain payment clears and grants that tier's duration; the lowest tier's `min_amount` becomes the floor price for any access at all. Every non-floor tier also appears as its own entry in the 402 response's x402 `accepts[]` array. `routes` is matched by longest `path_prefix` on a path-segment boundary; unmatched requests fall back to the top-level `min_payment`/`access_duration`/`pricing_tiers`.
|
|
167
|
+
|
|
168
|
+
**Revocation**: `MemoryGrantStore`/`FileGrantStore` (`agentpayments_python.grant_store`) both gained a `revoke(agent_key)` method — call it from your own admin view to cut off a specific paid key early. The adapters need no changes to respect this: `has()` already returns `False` for a revoked (or expired) grant. A grants file written by an older SDK version (a plain JSON array of key strings) is still read correctly as a set of permanent grants.
|
|
169
|
+
|
|
170
|
+
## Multi-Process Deployments (Redis)
|
|
171
|
+
|
|
172
|
+
The built-in rate limiters and payment cache are in-memory and **per-process** — with `gunicorn -w 4`, each of the 4 workers enforces its own independent 10-req/min agent-key limit (40/min in aggregate) and caches payment results separately, so a payment verified by one worker isn't recognized by another until its own cache/rate-limit state catches up. For any multi-process deployment, plug in a Redis-backed store instead:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
import redis
|
|
176
|
+
from agentpayments_python.redis_store import create_redis_store
|
|
177
|
+
|
|
178
|
+
r = redis.Redis.from_url(os.environ["REDIS_URL"])
|
|
179
|
+
store = create_redis_store(r)
|
|
180
|
+
|
|
181
|
+
# FastAPI / Flask
|
|
182
|
+
register_agentpayments(app, ..., # or AgentPaymentsASGIMiddleware(...)
|
|
183
|
+
agent_key_rate_limiter=store["agent_key_rate_limiter"],
|
|
184
|
+
challenge_issue_rate_limiter=store["challenge_issue_rate_limiter"],
|
|
185
|
+
payment_cache=store["payment_cache"],
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Django settings.py
|
|
189
|
+
AGENTPAYMENTS_AGENT_KEY_RATE_LIMITER = store["agent_key_rate_limiter"]
|
|
190
|
+
AGENTPAYMENTS_CHALLENGE_ISSUE_RATE_LIMITER = store["challenge_issue_rate_limiter"]
|
|
191
|
+
AGENTPAYMENTS_PAYMENT_CACHE = store["payment_cache"]
|
|
192
|
+
|
|
193
|
+
# The /__challenge/verify endpoint has its own rate limiter, passed separately
|
|
194
|
+
# since it's a standalone route/view rather than the main middleware:
|
|
195
|
+
# FastAPI: challenge_verify_endpoint(request, challenge_secret=..., rate_limiter=store["challenge_verify_rate_limiter"])
|
|
196
|
+
# Django: AGENTPAYMENTS_CHALLENGE_VERIFY_RATE_LIMITER = store["challenge_verify_rate_limiter"]
|
|
197
|
+
# Flask: register_agentpayments(app, ..., challenge_verify_rate_limiter=store["challenge_verify_rate_limiter"])
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`redis_store.py` has no hard dependency on the `redis` package — it's duck-typed against any client exposing `eval(script, numkeys, *args)`, `get(key)`, and `set(key, value, ex=ttl)` (redis-py's `Redis` client satisfies this directly), mirroring `sdk/node/redis-store.js`. Rate limiting fails open on a Redis error (never blocks legitimate traffic); the payment cache fails to a miss (falls through to a normal chain scan). All four are optional — anything left unset keeps using the default in-memory implementation.
|
|
201
|
+
|
|
129
202
|
## Security Features
|
|
130
203
|
|
|
131
204
|
- **Timing-safe HMAC comparison** — uses `hmac.compare_digest()` for all signature checks
|
|
@@ -150,6 +223,9 @@ agentpayments_python/
|
|
|
150
223
|
detection.py Browser detection and public path checks
|
|
151
224
|
solana.py On-chain payment verification + caching
|
|
152
225
|
ratelimit.py Shared IP-based rate limiter
|
|
226
|
+
grant_store.py Durable paid-key persistence (expiry, revocation)
|
|
227
|
+
pricing.py Pricing-tier / access-duration / per-route resolution
|
|
228
|
+
redis_store.py Redis-backed rate limiter + payment cache (multi-process)
|
|
153
229
|
```
|
|
154
230
|
|
|
155
231
|
## Notes
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# agentpayments-python
|
|
2
|
+
|
|
3
|
+
Python adapters for the AgentPayments gate. Supports Django, FastAPI/Starlette, and Flask.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install agentpayments-python
|
|
9
|
+
# or, in this monorepo:
|
|
10
|
+
# pip install -e sdk/python
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Django
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
# settings.py
|
|
17
|
+
MIDDLEWARE = [
|
|
18
|
+
"django.middleware.security.SecurityMiddleware",
|
|
19
|
+
"agentpayments_python.django_adapter.GateMiddleware",
|
|
20
|
+
# ... other middleware
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
# Required settings
|
|
24
|
+
CHALLENGE_SECRET = os.environ["CHALLENGE_SECRET"]
|
|
25
|
+
HOME_WALLET_ADDRESS = os.environ["HOME_WALLET_ADDRESS"]
|
|
26
|
+
|
|
27
|
+
# Optional settings
|
|
28
|
+
SOLANA_RPC_URL = os.environ.get("SOLANA_RPC_URL")
|
|
29
|
+
USDC_MINT = os.environ.get("USDC_MINT")
|
|
30
|
+
DEBUG = True # True = devnet, False = mainnet
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## FastAPI
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from fastapi import FastAPI, Request
|
|
37
|
+
from agentpayments_python.fastapi_adapter import (
|
|
38
|
+
AgentPaymentsASGIMiddleware,
|
|
39
|
+
challenge_verify_endpoint,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
app = FastAPI()
|
|
43
|
+
|
|
44
|
+
app.add_middleware(
|
|
45
|
+
AgentPaymentsASGIMiddleware,
|
|
46
|
+
challenge_secret=os.environ["CHALLENGE_SECRET"],
|
|
47
|
+
home_wallet_address=os.environ["HOME_WALLET_ADDRESS"],
|
|
48
|
+
debug=True,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@app.post("/__challenge/verify")
|
|
52
|
+
async def verify(request: Request):
|
|
53
|
+
return await challenge_verify_endpoint(
|
|
54
|
+
request,
|
|
55
|
+
challenge_secret=os.environ["CHALLENGE_SECRET"],
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Flask
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from flask import Flask
|
|
63
|
+
from agentpayments_python.flask_adapter import register_agentpayments
|
|
64
|
+
|
|
65
|
+
app = Flask(__name__)
|
|
66
|
+
register_agentpayments(
|
|
67
|
+
app,
|
|
68
|
+
challenge_secret=os.environ["CHALLENGE_SECRET"],
|
|
69
|
+
home_wallet_address=os.environ["HOME_WALLET_ADDRESS"],
|
|
70
|
+
debug=True,
|
|
71
|
+
)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Configuration
|
|
75
|
+
|
|
76
|
+
| Parameter | Required | Default | Description |
|
|
77
|
+
|---|---|---|---|
|
|
78
|
+
| `challenge_secret` | Yes (production) | `'default-secret-change-me'` | HMAC secret for signing cookies, nonces, and agent keys. |
|
|
79
|
+
| `home_wallet_address` | Yes | `''` | Solana wallet address to receive USDC payments. |
|
|
80
|
+
| `solana_rpc_url` | No | Auto (devnet/mainnet) | Custom Solana RPC endpoint. |
|
|
81
|
+
| `usdc_mint` | No | Auto (devnet/mainnet) | Custom USDC mint address. |
|
|
82
|
+
| `min_payment` | No | `0.01` | Minimum USDC payment required. Ignored when `pricing_tiers` is set (the lowest tier's `min_amount` becomes the floor). |
|
|
83
|
+
| `access_duration` | No | `None` (forever) | Seconds a successful payment grants access for. Requires `grant_store` to actually persist. |
|
|
84
|
+
| `pricing_tiers` | No | `None` | Payment-amount → access mapping: `[{"min_amount": ..., "duration_seconds": ..., "name": ...}]`. Overrides `min_payment`/`access_duration`. |
|
|
85
|
+
| `routes` | No | `None` | Per-route overrides for a single middleware instance: `[{"path_prefix": ..., "min_payment": ..., "access_duration": ..., "pricing_tiers": ...}]`, matched by longest `path_prefix`. |
|
|
86
|
+
| `debug` | No | `True` | `True` = devnet. `False` = mainnet + strict mode. |
|
|
87
|
+
| `api_key` | No | `None` | AgentPayments hosted-platform API key (`ap_live_...`). When set, agent keys are issued and metered via the platform instead of self-signed locally. See **Hosted Platform Mode** below. |
|
|
88
|
+
| `platform_url` | No | AgentPayments-hosted URL | Override for a self-hosted platform API. |
|
|
89
|
+
| `agent_key_rate_limiter` | No | Built-in in-memory (10/min/IP) | Pluggable rate limiter for the agent-key payment-verification path. Pass a Redis-backed one for multi-process deployments — see **Multi-Process Deployments** below. |
|
|
90
|
+
| `challenge_issue_rate_limiter` | No | Built-in in-memory (30/min/IP) | Pluggable rate limiter for browser challenge-page issuance. |
|
|
91
|
+
| `payment_cache` | No | Module-level in-memory singleton | Pluggable payment-verification cache (10-min positive / 30s negative TTL by default). |
|
|
92
|
+
|
|
93
|
+
Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`, `settings.AGENTPAYMENTS_API_KEY`). FastAPI and Flask accept them as constructor arguments.
|
|
94
|
+
|
|
95
|
+
## Hosted Platform Mode
|
|
96
|
+
|
|
97
|
+
Setting `api_key` switches agent-key issuance from local (`ag_...`) to platform-issued (`agp_...`), and — when the platform account has an on-chain fee configured — every 402 response's `payment` dict gains a `platform_fee` field describing a second required USDC transfer, to be sent in the **same Solana transaction** as the vendor payment. Missing that second transfer is treated as an unpaid request, same as any other invalid payment. This is opt-in per vendor account and has no effect on self-hosted deployments (no `api_key`). The standards-compliant `accepts[]`/`X-PAYMENT-REQUIRED` x402 fields are untouched — they still describe only the vendor leg.
|
|
98
|
+
|
|
99
|
+
## Pricing & Access Model
|
|
100
|
+
|
|
101
|
+
By default one `min_payment` buys indefinite access. Three parameters layer on top, identical across Django/FastAPI/Flask (Django via `settings.AGENTPAYMENTS_*`, the others as constructor kwargs):
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
# FastAPI / Flask
|
|
105
|
+
register_agentpayments( # or AgentPaymentsASGIMiddleware(...)
|
|
106
|
+
app, ...,
|
|
107
|
+
grant_store=FileGrantStore("./data/grants.json"), # required for duration/tiers to persist
|
|
108
|
+
access_duration=86400, # seconds; a paid key is only good for 24h, then re-verifies
|
|
109
|
+
pricing_tiers=[
|
|
110
|
+
{"min_amount": 0.01, "duration_seconds": 3600, "name": "hourly"},
|
|
111
|
+
{"min_amount": 0.05, "duration_seconds": 86400, "name": "daily"},
|
|
112
|
+
{"min_amount": 0.50, "duration_seconds": None, "name": "lifetime"},
|
|
113
|
+
],
|
|
114
|
+
routes=[
|
|
115
|
+
{"path_prefix": "/api/premium", "min_payment": 0.05},
|
|
116
|
+
{"path_prefix": "/api/basic", "min_payment": 0.01},
|
|
117
|
+
],
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
# Django settings.py
|
|
123
|
+
AGENTPAYMENTS_GRANT_STORE = FileGrantStore("/var/data/agp_grants.json")
|
|
124
|
+
AGENTPAYMENTS_ACCESS_DURATION = 86400
|
|
125
|
+
AGENTPAYMENTS_PRICING_TIERS = [...] # same shape as above
|
|
126
|
+
AGENTPAYMENTS_ROUTES = [...]
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`pricing_tiers` overrides `min_payment`/`access_duration` when set — the gate finds the highest tier the actual on-chain payment clears and grants that tier's duration; the lowest tier's `min_amount` becomes the floor price for any access at all. Every non-floor tier also appears as its own entry in the 402 response's x402 `accepts[]` array. `routes` is matched by longest `path_prefix` on a path-segment boundary; unmatched requests fall back to the top-level `min_payment`/`access_duration`/`pricing_tiers`.
|
|
130
|
+
|
|
131
|
+
**Revocation**: `MemoryGrantStore`/`FileGrantStore` (`agentpayments_python.grant_store`) both gained a `revoke(agent_key)` method — call it from your own admin view to cut off a specific paid key early. The adapters need no changes to respect this: `has()` already returns `False` for a revoked (or expired) grant. A grants file written by an older SDK version (a plain JSON array of key strings) is still read correctly as a set of permanent grants.
|
|
132
|
+
|
|
133
|
+
## Multi-Process Deployments (Redis)
|
|
134
|
+
|
|
135
|
+
The built-in rate limiters and payment cache are in-memory and **per-process** — with `gunicorn -w 4`, each of the 4 workers enforces its own independent 10-req/min agent-key limit (40/min in aggregate) and caches payment results separately, so a payment verified by one worker isn't recognized by another until its own cache/rate-limit state catches up. For any multi-process deployment, plug in a Redis-backed store instead:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
import redis
|
|
139
|
+
from agentpayments_python.redis_store import create_redis_store
|
|
140
|
+
|
|
141
|
+
r = redis.Redis.from_url(os.environ["REDIS_URL"])
|
|
142
|
+
store = create_redis_store(r)
|
|
143
|
+
|
|
144
|
+
# FastAPI / Flask
|
|
145
|
+
register_agentpayments(app, ..., # or AgentPaymentsASGIMiddleware(...)
|
|
146
|
+
agent_key_rate_limiter=store["agent_key_rate_limiter"],
|
|
147
|
+
challenge_issue_rate_limiter=store["challenge_issue_rate_limiter"],
|
|
148
|
+
payment_cache=store["payment_cache"],
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Django settings.py
|
|
152
|
+
AGENTPAYMENTS_AGENT_KEY_RATE_LIMITER = store["agent_key_rate_limiter"]
|
|
153
|
+
AGENTPAYMENTS_CHALLENGE_ISSUE_RATE_LIMITER = store["challenge_issue_rate_limiter"]
|
|
154
|
+
AGENTPAYMENTS_PAYMENT_CACHE = store["payment_cache"]
|
|
155
|
+
|
|
156
|
+
# The /__challenge/verify endpoint has its own rate limiter, passed separately
|
|
157
|
+
# since it's a standalone route/view rather than the main middleware:
|
|
158
|
+
# FastAPI: challenge_verify_endpoint(request, challenge_secret=..., rate_limiter=store["challenge_verify_rate_limiter"])
|
|
159
|
+
# Django: AGENTPAYMENTS_CHALLENGE_VERIFY_RATE_LIMITER = store["challenge_verify_rate_limiter"]
|
|
160
|
+
# Flask: register_agentpayments(app, ..., challenge_verify_rate_limiter=store["challenge_verify_rate_limiter"])
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`redis_store.py` has no hard dependency on the `redis` package — it's duck-typed against any client exposing `eval(script, numkeys, *args)`, `get(key)`, and `set(key, value, ex=ttl)` (redis-py's `Redis` client satisfies this directly), mirroring `sdk/node/redis-store.js`. Rate limiting fails open on a Redis error (never blocks legitimate traffic); the payment cache fails to a miss (falls through to a normal chain scan). All four are optional — anything left unset keeps using the default in-memory implementation.
|
|
164
|
+
|
|
165
|
+
## Security Features
|
|
166
|
+
|
|
167
|
+
- **Timing-safe HMAC comparison** — uses `hmac.compare_digest()` for all signature checks
|
|
168
|
+
- **Payment verification cache** — 10-minute TTL, 1000-entry max (thread-safe)
|
|
169
|
+
- **Rate limiting** — 20 challenge verifications per minute per IP (thread-safe)
|
|
170
|
+
- **Input size limits** — key (64 chars), nonce (128), return URL (2048), fingerprint (128)
|
|
171
|
+
- **Wallet address validation** — base58 format, 32-44 chars, validated at init
|
|
172
|
+
- **Default secret detection** — warns in debug, raises `RuntimeError` in production
|
|
173
|
+
- **Secure cookies** — Django auto-detects HTTPS via `request.is_secure()`
|
|
174
|
+
|
|
175
|
+
## Module Structure
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
agentpayments_python/
|
|
179
|
+
__init__.py
|
|
180
|
+
django_adapter.py Django middleware (GateMiddleware)
|
|
181
|
+
fastapi_adapter.py FastAPI/Starlette ASGI middleware
|
|
182
|
+
flask_adapter.py Flask integration (before_request hook)
|
|
183
|
+
challenge.py Shared challenge HTML generation
|
|
184
|
+
cookies.py Cookie creation and validation
|
|
185
|
+
crypto.py HMAC signing and agent key management
|
|
186
|
+
detection.py Browser detection and public path checks
|
|
187
|
+
solana.py On-chain payment verification + caching
|
|
188
|
+
ratelimit.py Shared IP-based rate limiter
|
|
189
|
+
grant_store.py Durable paid-key persistence (expiry, revocation)
|
|
190
|
+
pricing.py Pricing-tier / access-duration / per-route resolution
|
|
191
|
+
redis_store.py Redis-backed rate limiter + payment cache (multi-process)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Notes
|
|
195
|
+
- Constants loaded from `sdk/constants.json` via `pathlib`.
|
|
196
|
+
- Logging uses Python stdlib `logging` module.
|
|
197
|
+
- All shared modules are framework-agnostic; adapters are thin wiring.
|
{agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/django_adapter.py
RENAMED
|
@@ -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]
|
{agentpayments_python-0.1.2 → agentpayments_python-0.3.0}/agentpayments_python/fastapi_adapter.py
RENAMED
|
@@ -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]
|