agentpayments-python 0.1.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/__init__.py +30 -0
- agentpayments_python/challenge.py +151 -0
- agentpayments_python/constants.json +27 -0
- agentpayments_python/cookies.py +37 -0
- agentpayments_python/crawler.py +74 -0
- agentpayments_python/crypto.py +43 -0
- agentpayments_python/detection.py +28 -0
- agentpayments_python/django_adapter.py +232 -0
- agentpayments_python/fastapi_adapter.py +198 -0
- agentpayments_python/flask_adapter.py +167 -0
- agentpayments_python/grant_store.py +86 -0
- agentpayments_python/platform_client.py +117 -0
- agentpayments_python/ratelimit.py +43 -0
- agentpayments_python/solana.py +196 -0
- agentpayments_python/x402.py +84 -0
- agentpayments_python-0.1.0.dist-info/METADATA +152 -0
- agentpayments_python-0.1.0.dist-info/RECORD +20 -0
- agentpayments_python-0.1.0.dist-info/WHEEL +5 -0
- agentpayments_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- agentpayments_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
4
|
+
from starlette.requests import Request
|
|
5
|
+
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
6
|
+
|
|
7
|
+
from .challenge import POW_DIFFICULTY, challenge_html, make_nonce, validate_challenge_submission
|
|
8
|
+
from .cookies import COOKIE_MAX_AGE, COOKIE_NAME, is_valid_cookie_value, make_cookie
|
|
9
|
+
from .crypto import generate_agent_key, is_valid_agent_key
|
|
10
|
+
from .detection import is_browser_from_headers, is_public_path
|
|
11
|
+
from .ratelimit import _challenge_limiter, _agent_key_limiter, _challenge_issue_limiter
|
|
12
|
+
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, verify_payment_on_chain
|
|
14
|
+
from .x402 import build_payment_requirements, enrich_402_body, payment_required_header
|
|
15
|
+
from .platform_client import HOSTED_KEY_PREFIX, PlatformClient, is_valid_hosted_key
|
|
16
|
+
|
|
17
|
+
import json as _json
|
|
18
|
+
from pathlib import Path as _Path
|
|
19
|
+
_constants = _json.loads((_Path(__file__).resolve().parent / "constants.json").read_text())
|
|
20
|
+
MAX_NONCE_LENGTH = _constants["MAX_NONCE_LENGTH"]
|
|
21
|
+
MAX_RETURN_TO_LENGTH = _constants["MAX_RETURN_TO_LENGTH"]
|
|
22
|
+
MAX_FP_LENGTH = _constants["MAX_FP_LENGTH"]
|
|
23
|
+
MAX_POW_LENGTH = _constants["MAX_POW_LENGTH"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _payment_required_response(body: dict, *, wallet_address: str, mint: str, min_payment: float, debug: bool, agent_key: str = "", resource: str = "") -> JSONResponse:
|
|
27
|
+
"""Return a Starlette JSONResponse (402) enriched with x402-standard fields and header."""
|
|
28
|
+
pay_req = build_payment_requirements(wallet_address=wallet_address, mint=mint, min_payment=min_payment, debug=debug, agent_key=agent_key, resource=resource)
|
|
29
|
+
return JSONResponse(
|
|
30
|
+
content=enrich_402_body(body, pay_req),
|
|
31
|
+
status_code=402,
|
|
32
|
+
headers={"X-PAYMENT-REQUIRED": payment_required_header(pay_req)},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _client_ip(request: Request) -> str:
|
|
37
|
+
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
|
38
|
+
if forwarded:
|
|
39
|
+
return forwarded
|
|
40
|
+
return request.client.host if request.client else "unknown"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
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):
|
|
45
|
+
super().__init__(app)
|
|
46
|
+
if challenge_secret == "default-secret-change-me":
|
|
47
|
+
import logging
|
|
48
|
+
logger = logging.getLogger("agentpayments")
|
|
49
|
+
if debug:
|
|
50
|
+
logger.warning("Using default CHALLENGE_SECRET. Set a strong secret before deploying to production.")
|
|
51
|
+
else:
|
|
52
|
+
raise RuntimeError("CHALLENGE_SECRET is set to the insecure default. Set a strong, unique secret for production.")
|
|
53
|
+
if home_wallet_address and not is_valid_solana_address(home_wallet_address):
|
|
54
|
+
raise ValueError(f"HOME_WALLET_ADDRESS '{home_wallet_address}' is not a valid Solana public key (expected 32-44 base58 characters).")
|
|
55
|
+
self.challenge_secret = challenge_secret
|
|
56
|
+
self.home_wallet_address = home_wallet_address
|
|
57
|
+
self.debug = debug
|
|
58
|
+
raw_rpc = solana_rpc_url or (RPC_DEVNET if debug else RPC_MAINNET)
|
|
59
|
+
self.solana_rpc_url = raw_rpc if isinstance(raw_rpc, list) else [raw_rpc]
|
|
60
|
+
self.usdc_mint = usdc_mint or (USDC_MINT_DEVNET if debug else USDC_MINT_MAINNET)
|
|
61
|
+
self.pow_difficulty = pow_difficulty
|
|
62
|
+
self.verify_crawlers = verify_crawlers
|
|
63
|
+
self.grant_store = grant_store
|
|
64
|
+
self.require_https = (not debug) if require_https is None else require_https
|
|
65
|
+
self._platform_client = PlatformClient(api_key, platform_url) if api_key else None
|
|
66
|
+
|
|
67
|
+
async def dispatch(self, request: Request, call_next):
|
|
68
|
+
path = request.url.path
|
|
69
|
+
if is_public_path(path):
|
|
70
|
+
return await call_next(request)
|
|
71
|
+
|
|
72
|
+
if path == "/__challenge/verify" and request.method == "POST":
|
|
73
|
+
return await call_next(request)
|
|
74
|
+
|
|
75
|
+
# Reject plaintext HTTP in production.
|
|
76
|
+
if self.require_https and request.url.scheme != "https":
|
|
77
|
+
return JSONResponse({"error": "https_required", "message": "This service requires a secure HTTPS connection."}, status_code=400)
|
|
78
|
+
|
|
79
|
+
# Verified search crawlers bypass the gate entirely.
|
|
80
|
+
# is_verified_crawler is blocking I/O — run in executor to avoid blocking the event loop.
|
|
81
|
+
if self.verify_crawlers:
|
|
82
|
+
client_ip_early = _client_ip(request)
|
|
83
|
+
ua = request.headers.get("user-agent", "")
|
|
84
|
+
loop = asyncio.get_event_loop()
|
|
85
|
+
if await loop.run_in_executor(None, is_verified_crawler, client_ip_early, ua):
|
|
86
|
+
return await call_next(request)
|
|
87
|
+
|
|
88
|
+
if not is_browser_from_headers(dict(request.headers)):
|
|
89
|
+
agent_key = request.headers.get("x-agent-key")
|
|
90
|
+
network = "devnet" if self.debug else "mainnet-beta"
|
|
91
|
+
if not agent_key:
|
|
92
|
+
if self._platform_client:
|
|
93
|
+
try:
|
|
94
|
+
new_key = await loop.run_in_executor(None, self._platform_client.issue_key)
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
import logging as _log
|
|
97
|
+
_log.getLogger("agentpayments").warning("Platform key issuance failed, falling back to local key: %s", exc)
|
|
98
|
+
new_key = generate_agent_key(self.challenge_secret)
|
|
99
|
+
else:
|
|
100
|
+
new_key = generate_agent_key(self.challenge_secret)
|
|
101
|
+
return _payment_required_response(
|
|
102
|
+
{
|
|
103
|
+
"error": "payment_required",
|
|
104
|
+
"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.",
|
|
105
|
+
"your_key": new_key,
|
|
106
|
+
"payment": {
|
|
107
|
+
"chain": "solana",
|
|
108
|
+
"network": network,
|
|
109
|
+
"token": "USDC",
|
|
110
|
+
"amount": str(MIN_PAYMENT),
|
|
111
|
+
"wallet_address": self.home_wallet_address,
|
|
112
|
+
"memo": new_key,
|
|
113
|
+
"instructions": f'Send {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.',
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
wallet_address=self.home_wallet_address, mint=self.usdc_mint, min_payment=MIN_PAYMENT,
|
|
117
|
+
debug=self.debug, agent_key=new_key, resource=path,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if agent_key.startswith(HOSTED_KEY_PREFIX):
|
|
121
|
+
if not self._platform_client:
|
|
122
|
+
return JSONResponse({"error": "forbidden", "message": "Platform-issued keys (agp_) require api_key to be configured."}, status_code=403)
|
|
123
|
+
try:
|
|
124
|
+
ver_sec = await loop.run_in_executor(None, lambda: self._platform_client.verification_secret)
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
import logging as _log
|
|
127
|
+
_log.getLogger("agentpayments").error("Failed to fetch verificationSecret: %s", exc)
|
|
128
|
+
return JSONResponse({"error": "service_unavailable", "message": "Key verification temporarily unavailable."}, status_code=503)
|
|
129
|
+
if not is_valid_hosted_key(agent_key, ver_sec):
|
|
130
|
+
return JSONResponse({"error": "forbidden", "message": "Invalid API key."}, status_code=403)
|
|
131
|
+
elif not is_valid_agent_key(agent_key, self.challenge_secret):
|
|
132
|
+
return JSONResponse({"error": "forbidden", "message": "Invalid API key."}, status_code=403)
|
|
133
|
+
|
|
134
|
+
if not _agent_key_limiter.check(_client_ip(request)):
|
|
135
|
+
return JSONResponse({"error": "rate_limited", "message": "Too many payment verification requests. Please wait and try again."}, status_code=429)
|
|
136
|
+
|
|
137
|
+
if not self.home_wallet_address:
|
|
138
|
+
return JSONResponse({"error": "server_error", "message": "Payment verification unavailable."}, status_code=500)
|
|
139
|
+
|
|
140
|
+
# Durable grant check — bypasses RPC scan entirely for known-paid keys.
|
|
141
|
+
if self.grant_store and self.grant_store.has(agent_key):
|
|
142
|
+
return await call_next(request)
|
|
143
|
+
|
|
144
|
+
# verify_payment_on_chain is synchronous (uses requests). Run it in a
|
|
145
|
+
# thread-pool executor so it doesn't block the async event loop.
|
|
146
|
+
loop = asyncio.get_event_loop()
|
|
147
|
+
paid = await loop.run_in_executor(
|
|
148
|
+
None, verify_payment_on_chain, agent_key, self.home_wallet_address, self.solana_rpc_url, self.usdc_mint
|
|
149
|
+
)
|
|
150
|
+
if paid and self.grant_store:
|
|
151
|
+
self.grant_store.add(agent_key)
|
|
152
|
+
if not paid:
|
|
153
|
+
return _payment_required_response(
|
|
154
|
+
{
|
|
155
|
+
"error": "payment_required",
|
|
156
|
+
"message": "Key is valid but payment has not been verified on-chain yet.",
|
|
157
|
+
"your_key": agent_key,
|
|
158
|
+
"payment": {"chain": "solana", "network": network, "token": "USDC", "amount": str(MIN_PAYMENT), "wallet_address": self.home_wallet_address, "memo": agent_key},
|
|
159
|
+
},
|
|
160
|
+
wallet_address=self.home_wallet_address, mint=self.usdc_mint, min_payment=MIN_PAYMENT,
|
|
161
|
+
debug=self.debug, agent_key=agent_key, resource=path,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return await call_next(request)
|
|
165
|
+
|
|
166
|
+
client_ip = _client_ip(request)
|
|
167
|
+
cookie_val = request.cookies.get(COOKIE_NAME, "")
|
|
168
|
+
if is_valid_cookie_value(cookie_val, self.challenge_secret, client_ip):
|
|
169
|
+
return await call_next(request)
|
|
170
|
+
|
|
171
|
+
if not _challenge_issue_limiter.check(client_ip):
|
|
172
|
+
return JSONResponse({"error": "rate_limited", "message": "Too many requests. Please try again later."}, status_code=429)
|
|
173
|
+
|
|
174
|
+
nonce = make_nonce(self.challenge_secret, client_ip)
|
|
175
|
+
return HTMLResponse(challenge_html(str(request.url.path), nonce, self.pow_difficulty), headers={
|
|
176
|
+
"Cache-Control": "no-store",
|
|
177
|
+
"X-Frame-Options": "DENY",
|
|
178
|
+
"Content-Security-Policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; form-action 'self'",
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def challenge_verify_endpoint(request: Request, challenge_secret: str, pow_difficulty: int = POW_DIFFICULTY):
|
|
183
|
+
client_ip = _client_ip(request)
|
|
184
|
+
if not _challenge_limiter.check(client_ip):
|
|
185
|
+
return JSONResponse({"error": "rate_limited", "message": "Too many verification attempts. Please wait and try again."}, status_code=429)
|
|
186
|
+
form = await request.form()
|
|
187
|
+
nonce = str(form.get("nonce", ""))[:MAX_NONCE_LENGTH]
|
|
188
|
+
return_to = str(form.get("return_to", "/"))[:MAX_RETURN_TO_LENGTH]
|
|
189
|
+
fp = str(form.get("fp", ""))[:MAX_FP_LENGTH]
|
|
190
|
+
pow_value = str(form.get("pow", ""))[:MAX_POW_LENGTH]
|
|
191
|
+
|
|
192
|
+
if not validate_challenge_submission(nonce, fp, pow_value, challenge_secret, client_ip, pow_difficulty):
|
|
193
|
+
return JSONResponse({"error": "forbidden", "message": "Challenge verification failed."}, status_code=403)
|
|
194
|
+
|
|
195
|
+
safe_path = return_to if (return_to.startswith("/") and not return_to.startswith("//")) else "/"
|
|
196
|
+
resp = RedirectResponse(url=safe_path, status_code=302)
|
|
197
|
+
resp.set_cookie(COOKIE_NAME, make_cookie(challenge_secret, client_ip), max_age=COOKIE_MAX_AGE, path="/", httponly=True, secure=True, samesite="lax")
|
|
198
|
+
return resp
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from flask import jsonify, make_response, redirect, request, Response as FlaskResponse
|
|
2
|
+
import json as _flask_json
|
|
3
|
+
|
|
4
|
+
from .challenge import POW_DIFFICULTY, challenge_html, make_nonce, validate_challenge_submission
|
|
5
|
+
from .cookies import COOKIE_MAX_AGE, COOKIE_NAME, is_valid_cookie_value, make_cookie
|
|
6
|
+
from .crypto import generate_agent_key, is_valid_agent_key
|
|
7
|
+
from .detection import is_browser_from_headers, is_public_path
|
|
8
|
+
from .ratelimit import _challenge_limiter, _agent_key_limiter, _challenge_issue_limiter
|
|
9
|
+
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, verify_payment_on_chain
|
|
11
|
+
from .x402 import build_payment_requirements, enrich_402_body, payment_required_header
|
|
12
|
+
from .platform_client import HOSTED_KEY_PREFIX, PlatformClient, is_valid_hosted_key
|
|
13
|
+
|
|
14
|
+
import json as _json
|
|
15
|
+
from pathlib import Path as _Path
|
|
16
|
+
_constants = _json.loads((_Path(__file__).resolve().parent / "constants.json").read_text())
|
|
17
|
+
MAX_NONCE_LENGTH = _constants["MAX_NONCE_LENGTH"]
|
|
18
|
+
MAX_RETURN_TO_LENGTH = _constants["MAX_RETURN_TO_LENGTH"]
|
|
19
|
+
MAX_FP_LENGTH = _constants["MAX_FP_LENGTH"]
|
|
20
|
+
MAX_POW_LENGTH = _constants["MAX_POW_LENGTH"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _payment_required_flask(body: dict, *, wallet_address: str, mint: str, min_payment: float, debug: bool, agent_key: str = "", resource: str = ""):
|
|
24
|
+
"""Return a Flask 402 response enriched with x402-standard fields and header."""
|
|
25
|
+
pay_req = build_payment_requirements(wallet_address=wallet_address, mint=mint, min_payment=min_payment, debug=debug, agent_key=agent_key, resource=resource)
|
|
26
|
+
enriched = enrich_402_body(body, pay_req)
|
|
27
|
+
resp = make_response(_flask_json.dumps(enriched, indent=2), 402)
|
|
28
|
+
resp.headers["Content-Type"] = "application/json"
|
|
29
|
+
resp.headers["X-PAYMENT-REQUIRED"] = payment_required_header(pay_req)
|
|
30
|
+
return resp
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _client_ip() -> str:
|
|
34
|
+
return request.headers.get("X-Forwarded-For", "").split(",")[0].strip() or request.remote_addr or "unknown"
|
|
35
|
+
|
|
36
|
+
|
|
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):
|
|
38
|
+
if challenge_secret == "default-secret-change-me":
|
|
39
|
+
import logging
|
|
40
|
+
logger = logging.getLogger("agentpayments")
|
|
41
|
+
if debug:
|
|
42
|
+
logger.warning("Using default CHALLENGE_SECRET. Set a strong secret before deploying to production.")
|
|
43
|
+
else:
|
|
44
|
+
raise RuntimeError("CHALLENGE_SECRET is set to the insecure default. Set a strong, unique secret for production.")
|
|
45
|
+
if home_wallet_address and not is_valid_solana_address(home_wallet_address):
|
|
46
|
+
raise ValueError(f"HOME_WALLET_ADDRESS '{home_wallet_address}' is not a valid Solana public key (expected 32-44 base58 characters).")
|
|
47
|
+
raw_rpc = solana_rpc_url or (RPC_DEVNET if debug else RPC_MAINNET)
|
|
48
|
+
rpc_url = raw_rpc if isinstance(raw_rpc, list) else [raw_rpc]
|
|
49
|
+
mint = usdc_mint or (USDC_MINT_DEVNET if debug else USDC_MINT_MAINNET)
|
|
50
|
+
_require_https = (not debug) if require_https is None else require_https
|
|
51
|
+
_platform_client = PlatformClient(api_key, platform_url) if api_key else None
|
|
52
|
+
|
|
53
|
+
@app.before_request
|
|
54
|
+
def _gate():
|
|
55
|
+
path = request.path
|
|
56
|
+
if is_public_path(path):
|
|
57
|
+
return None
|
|
58
|
+
if path == "/__challenge/verify" and request.method == "POST":
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
# Reject plaintext HTTP in production.
|
|
62
|
+
if _require_https and not request.is_secure:
|
|
63
|
+
return make_response(_flask_json.dumps({"error": "https_required", "message": "This service requires a secure HTTPS connection."}, indent=2), 400, {"Content-Type": "application/json"})
|
|
64
|
+
|
|
65
|
+
# Verified search crawlers bypass the gate entirely.
|
|
66
|
+
if verify_crawlers:
|
|
67
|
+
ua = request.headers.get("User-Agent", "")
|
|
68
|
+
if is_verified_crawler(_client_ip(), ua):
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
if not is_browser_from_headers(request.headers):
|
|
72
|
+
key = request.headers.get("X-Agent-Key")
|
|
73
|
+
network = "devnet" if debug else "mainnet-beta"
|
|
74
|
+
if not key:
|
|
75
|
+
if _platform_client:
|
|
76
|
+
try:
|
|
77
|
+
new_key = _platform_client.issue_key()
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
import logging as _log
|
|
80
|
+
_log.getLogger("agentpayments").warning("Platform key issuance failed, falling back to local key: %s", exc)
|
|
81
|
+
new_key = generate_agent_key(challenge_secret)
|
|
82
|
+
else:
|
|
83
|
+
new_key = generate_agent_key(challenge_secret)
|
|
84
|
+
return _payment_required_flask(
|
|
85
|
+
{
|
|
86
|
+
"error": "payment_required",
|
|
87
|
+
"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.",
|
|
88
|
+
"your_key": new_key,
|
|
89
|
+
"payment": {
|
|
90
|
+
"chain": "solana",
|
|
91
|
+
"network": network,
|
|
92
|
+
"token": "USDC",
|
|
93
|
+
"amount": str(MIN_PAYMENT),
|
|
94
|
+
"wallet_address": home_wallet_address,
|
|
95
|
+
"memo": new_key,
|
|
96
|
+
"instructions": f'Send {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.',
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
wallet_address=home_wallet_address, mint=mint, min_payment=MIN_PAYMENT,
|
|
100
|
+
debug=debug, agent_key=new_key, resource=path,
|
|
101
|
+
)
|
|
102
|
+
if key.startswith(HOSTED_KEY_PREFIX):
|
|
103
|
+
if not _platform_client:
|
|
104
|
+
return jsonify({"error": "forbidden", "message": "Platform-issued keys (agp_) require api_key to be configured."}), 403
|
|
105
|
+
try:
|
|
106
|
+
ver_sec = _platform_client.verification_secret
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
import logging as _log
|
|
109
|
+
_log.getLogger("agentpayments").error("Failed to fetch verificationSecret: %s", exc)
|
|
110
|
+
return jsonify({"error": "service_unavailable", "message": "Key verification temporarily unavailable."}), 503
|
|
111
|
+
if not is_valid_hosted_key(key, ver_sec):
|
|
112
|
+
return jsonify({"error": "forbidden", "message": "Invalid API key."}), 403
|
|
113
|
+
elif not is_valid_agent_key(key, challenge_secret):
|
|
114
|
+
return jsonify({"error": "forbidden", "message": "Invalid API key."}), 403
|
|
115
|
+
if not _agent_key_limiter.check(_client_ip()):
|
|
116
|
+
return jsonify({"error": "rate_limited", "message": "Too many payment verification requests. Please wait and try again."}), 429
|
|
117
|
+
if not home_wallet_address:
|
|
118
|
+
return jsonify({"error": "server_error", "message": "Payment verification unavailable."}), 500
|
|
119
|
+
if grant_store and grant_store.has(key):
|
|
120
|
+
return None
|
|
121
|
+
paid = verify_payment_on_chain(key, home_wallet_address, rpc_url, mint)
|
|
122
|
+
if paid and grant_store:
|
|
123
|
+
grant_store.add(key)
|
|
124
|
+
if not paid:
|
|
125
|
+
return _payment_required_flask(
|
|
126
|
+
{
|
|
127
|
+
"error": "payment_required",
|
|
128
|
+
"message": "Key is valid but payment has not been verified on-chain yet.",
|
|
129
|
+
"your_key": key,
|
|
130
|
+
"payment": {"chain": "solana", "network": network, "token": "USDC", "amount": str(MIN_PAYMENT), "wallet_address": home_wallet_address, "memo": key},
|
|
131
|
+
},
|
|
132
|
+
wallet_address=home_wallet_address, mint=mint, min_payment=MIN_PAYMENT,
|
|
133
|
+
debug=debug, agent_key=key, resource=path,
|
|
134
|
+
)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
client_ip = _client_ip()
|
|
138
|
+
cookie_val = request.cookies.get(COOKIE_NAME, "")
|
|
139
|
+
if is_valid_cookie_value(cookie_val, challenge_secret, client_ip):
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
if not _challenge_issue_limiter.check(client_ip):
|
|
143
|
+
return make_response(_flask_json.dumps({"error": "rate_limited", "message": "Too many requests. Please try again later."}, indent=2), 429, {"Content-Type": "application/json"})
|
|
144
|
+
|
|
145
|
+
nonce = make_nonce(challenge_secret, client_ip)
|
|
146
|
+
return make_response(challenge_html(request.full_path or request.path, nonce, pow_difficulty), 200, {
|
|
147
|
+
"Content-Type": "text/html",
|
|
148
|
+
"Cache-Control": "no-store",
|
|
149
|
+
"X-Frame-Options": "DENY",
|
|
150
|
+
"Content-Security-Policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; form-action 'self'",
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
@app.post("/__challenge/verify")
|
|
154
|
+
def _verify():
|
|
155
|
+
client_ip = _client_ip()
|
|
156
|
+
if not _challenge_limiter.check(client_ip):
|
|
157
|
+
return jsonify({"error": "rate_limited", "message": "Too many verification attempts. Please wait and try again."}), 429
|
|
158
|
+
nonce = request.form.get("nonce", "")[:MAX_NONCE_LENGTH]
|
|
159
|
+
return_to = request.form.get("return_to", "/")[:MAX_RETURN_TO_LENGTH]
|
|
160
|
+
fp = request.form.get("fp", "")[:MAX_FP_LENGTH]
|
|
161
|
+
pow_value = request.form.get("pow", "")[:MAX_POW_LENGTH]
|
|
162
|
+
if not validate_challenge_submission(nonce, fp, pow_value, challenge_secret, client_ip, pow_difficulty):
|
|
163
|
+
return jsonify({"error": "forbidden", "message": "Challenge verification failed."}), 403
|
|
164
|
+
safe = return_to if (return_to.startswith("/") and not return_to.startswith("//")) else "/"
|
|
165
|
+
resp = redirect(safe, code=302)
|
|
166
|
+
resp.set_cookie(COOKIE_NAME, make_cookie(challenge_secret, client_ip), max_age=COOKIE_MAX_AGE, path='/', httponly=True, secure=True, samesite='Lax')
|
|
167
|
+
return resp
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Grant stores for durable paid-key persistence (P0 #5).
|
|
3
|
+
|
|
4
|
+
Once a key is added to a grant store it is never re-scanned on-chain, making
|
|
5
|
+
paid access durable even after the vendor wallet accumulates 100+ newer
|
|
6
|
+
transactions that would push the original payment out of the scan window.
|
|
7
|
+
|
|
8
|
+
Usage — pass to any adapter as ``grant_store``:
|
|
9
|
+
|
|
10
|
+
from agentpayments_python.grant_store import FileGrantStore
|
|
11
|
+
|
|
12
|
+
# Django settings.py
|
|
13
|
+
AGENTPAYMENTS_GRANT_STORE = FileGrantStore("/var/data/agp_grants.json")
|
|
14
|
+
|
|
15
|
+
# FastAPI / Flask constructor arg
|
|
16
|
+
register_agentpayments(app, ..., grant_store=FileGrantStore("/var/data/agp_grants.json"))
|
|
17
|
+
|
|
18
|
+
Grant store interface (implement your own for Redis, Postgres, etc.):
|
|
19
|
+
|
|
20
|
+
class GrantStore(Protocol):
|
|
21
|
+
def has(self, agent_key: str) -> bool: ...
|
|
22
|
+
def add(self, agent_key: str) -> None: ...
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import threading
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MemoryGrantStore:
|
|
34
|
+
"""In-memory grant store. Does not survive restarts."""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self._grants: set[str] = set()
|
|
38
|
+
self._lock = threading.Lock()
|
|
39
|
+
|
|
40
|
+
def has(self, agent_key: str) -> bool:
|
|
41
|
+
with self._lock:
|
|
42
|
+
return agent_key in self._grants
|
|
43
|
+
|
|
44
|
+
def add(self, agent_key: str) -> None:
|
|
45
|
+
with self._lock:
|
|
46
|
+
self._grants.add(agent_key)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FileGrantStore:
|
|
50
|
+
"""
|
|
51
|
+
File-backed grant store. Persists grants to a JSON file so they survive
|
|
52
|
+
process restarts. Writes are atomic (write to temp file, os.replace).
|
|
53
|
+
|
|
54
|
+
Not suitable for multi-process deployments — use a database or Redis there.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, path: str | Path) -> None:
|
|
58
|
+
self._path = Path(path).resolve()
|
|
59
|
+
self._grants: set[str] = set()
|
|
60
|
+
self._lock = threading.Lock()
|
|
61
|
+
self._load()
|
|
62
|
+
|
|
63
|
+
def _load(self) -> None:
|
|
64
|
+
try:
|
|
65
|
+
keys = json.loads(self._path.read_text())
|
|
66
|
+
if isinstance(keys, list):
|
|
67
|
+
self._grants.update(keys)
|
|
68
|
+
except FileNotFoundError:
|
|
69
|
+
pass # will be created on first write
|
|
70
|
+
|
|
71
|
+
def _save(self) -> None:
|
|
72
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
tmp = self._path.with_suffix(".tmp")
|
|
74
|
+
tmp.write_text(json.dumps(sorted(self._grants), indent=2))
|
|
75
|
+
os.replace(tmp, self._path)
|
|
76
|
+
|
|
77
|
+
def has(self, agent_key: str) -> bool:
|
|
78
|
+
with self._lock:
|
|
79
|
+
return agent_key in self._grants
|
|
80
|
+
|
|
81
|
+
def add(self, agent_key: str) -> None:
|
|
82
|
+
with self._lock:
|
|
83
|
+
if agent_key in self._grants:
|
|
84
|
+
return
|
|
85
|
+
self._grants.add(agent_key)
|
|
86
|
+
self._save()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentPayments Platform Client
|
|
3
|
+
|
|
4
|
+
Thin HTTP client for the AgentPayments Platform API.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
client = PlatformClient(api_key="ap_live_...")
|
|
8
|
+
key = client.issue_key() # returns 'agp_...'
|
|
9
|
+
secret = client.verification_secret # cached after first call
|
|
10
|
+
|
|
11
|
+
The verificationSecret is fetched once per client instance and cached in memory.
|
|
12
|
+
Agent keys are verified locally using is_valid_hosted_key() — no per-request
|
|
13
|
+
platform round-trip is needed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import hmac as _hmac
|
|
20
|
+
import json
|
|
21
|
+
import threading
|
|
22
|
+
import urllib.request
|
|
23
|
+
import urllib.error
|
|
24
|
+
|
|
25
|
+
from pathlib import Path as _Path
|
|
26
|
+
|
|
27
|
+
_constants = json.loads((_Path(__file__).resolve().parent / "constants.json").read_text())
|
|
28
|
+
PLATFORM_API_URL: str = _constants["PLATFORM_API_URL"]
|
|
29
|
+
HOSTED_KEY_PREFIX: str = _constants["HOSTED_KEY_PREFIX"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _hmac_hex(data: str, key: str) -> str:
|
|
33
|
+
return _hmac.new(key.encode(), data.encode(), hashlib.sha256).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_valid_hosted_key(key: str, verification_secret: str) -> bool:
|
|
37
|
+
"""
|
|
38
|
+
Verify a platform-issued agent key locally.
|
|
39
|
+
|
|
40
|
+
Key format: agp_${vendorId8}_${nonce16}_${sig16}
|
|
41
|
+
sig = hmac('agp:vendorId:nonce', verificationSecret).slice(0,16)
|
|
42
|
+
"""
|
|
43
|
+
if not key or not key.startswith(HOSTED_KEY_PREFIX):
|
|
44
|
+
return False
|
|
45
|
+
parts = key.split("_")
|
|
46
|
+
# ['agp', vendorId(8), nonce(16), sig(16)]
|
|
47
|
+
if len(parts) != 4:
|
|
48
|
+
return False
|
|
49
|
+
_, vendor_id, nonce, sig = parts
|
|
50
|
+
if not vendor_id or not nonce or not sig or len(sig) != 16:
|
|
51
|
+
return False
|
|
52
|
+
expected = _hmac_hex(f"agp:{vendor_id}:{nonce}", verification_secret)[:16]
|
|
53
|
+
return _hmac.compare_digest(sig, expected)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PlatformClient:
|
|
57
|
+
"""
|
|
58
|
+
Manages communication with the AgentPayments Platform API.
|
|
59
|
+
|
|
60
|
+
Thread-safe: the verificationSecret is fetched once and cached for the
|
|
61
|
+
lifetime of the process.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, api_key: str, platform_url: str = PLATFORM_API_URL) -> None:
|
|
65
|
+
self.api_key = api_key
|
|
66
|
+
self.platform_url = platform_url.rstrip("/")
|
|
67
|
+
self._verification_secret: str | None = None
|
|
68
|
+
self._lock = threading.Lock()
|
|
69
|
+
|
|
70
|
+
def _auth_headers(self) -> dict:
|
|
71
|
+
return {
|
|
72
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
73
|
+
"Content-Type": "application/json",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
def _get(self, path: str) -> dict:
|
|
77
|
+
req = urllib.request.Request(
|
|
78
|
+
f"{self.platform_url}{path}",
|
|
79
|
+
headers=self._auth_headers(),
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
83
|
+
return json.loads(resp.read())
|
|
84
|
+
except urllib.error.HTTPError as exc:
|
|
85
|
+
raise RuntimeError(f"Platform API {path} returned {exc.code}") from exc
|
|
86
|
+
|
|
87
|
+
def _post(self, path: str, body: dict | None = None) -> dict:
|
|
88
|
+
data = json.dumps(body or {}).encode()
|
|
89
|
+
req = urllib.request.Request(
|
|
90
|
+
f"{self.platform_url}{path}",
|
|
91
|
+
data=data,
|
|
92
|
+
headers=self._auth_headers(),
|
|
93
|
+
method="POST",
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
97
|
+
return json.loads(resp.read())
|
|
98
|
+
except urllib.error.HTTPError as exc:
|
|
99
|
+
raise RuntimeError(f"Platform API {path} returned {exc.code}") from exc
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def verification_secret(self) -> str:
|
|
103
|
+
"""Fetch + cache verificationSecret from /v1/account (thread-safe)."""
|
|
104
|
+
if self._verification_secret:
|
|
105
|
+
return self._verification_secret
|
|
106
|
+
with self._lock:
|
|
107
|
+
# Double-checked locking
|
|
108
|
+
if self._verification_secret:
|
|
109
|
+
return self._verification_secret
|
|
110
|
+
data = self._get("/v1/account")
|
|
111
|
+
self._verification_secret = data["verificationSecret"]
|
|
112
|
+
return self._verification_secret
|
|
113
|
+
|
|
114
|
+
def issue_key(self) -> str:
|
|
115
|
+
"""Issue a single platform-signed agent key (agp_...). Metered."""
|
|
116
|
+
data = self._post("/v1/keys/issue")
|
|
117
|
+
return data["key"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
RATE_LIMIT_WINDOW = 60 # 1 minute in seconds
|
|
5
|
+
RATE_LIMIT_MAX = 20 # max attempts per window per key
|
|
6
|
+
# Probabilistic cleanup: purge all expired entries roughly 1-in-N calls.
|
|
7
|
+
_CLEANUP_PROBABILITY = 50
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RateLimiter:
|
|
11
|
+
def __init__(self, window: int = RATE_LIMIT_WINDOW, max_hits: int = RATE_LIMIT_MAX):
|
|
12
|
+
self.window = window
|
|
13
|
+
self.max_hits = max_hits
|
|
14
|
+
self._hits: dict[str, tuple[float, int]] = {}
|
|
15
|
+
self._lock = threading.Lock()
|
|
16
|
+
self._call_count = 0
|
|
17
|
+
|
|
18
|
+
def check(self, key: str) -> bool:
|
|
19
|
+
now = time.time()
|
|
20
|
+
with self._lock:
|
|
21
|
+
# Periodically purge stale entries to prevent unbounded growth.
|
|
22
|
+
self._call_count += 1
|
|
23
|
+
if self._call_count % _CLEANUP_PROBABILITY == 0:
|
|
24
|
+
expired = [k for k, v in self._hits.items() if now - v[0] > self.window]
|
|
25
|
+
for k in expired:
|
|
26
|
+
del self._hits[k]
|
|
27
|
+
|
|
28
|
+
entry = self._hits.get(key)
|
|
29
|
+
if entry is None or now - entry[0] > self.window:
|
|
30
|
+
self._hits[key] = (now, 1)
|
|
31
|
+
return True
|
|
32
|
+
start, count = entry
|
|
33
|
+
count += 1
|
|
34
|
+
self._hits[key] = (start, count)
|
|
35
|
+
return count <= self.max_hits
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_challenge_limiter = RateLimiter()
|
|
39
|
+
# Stricter limit for the agent-key payment verification path.
|
|
40
|
+
_agent_key_limiter = RateLimiter(max_hits=10)
|
|
41
|
+
# Rate-limit challenge page issuance (browser fallback) to prevent unlimited
|
|
42
|
+
# nonce harvesting for offline PoW mining.
|
|
43
|
+
_challenge_issue_limiter = RateLimiter(max_hits=30)
|