agentpayments-python 0.1.2__py3-none-any.whl → 0.2.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.
@@ -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, verify_payment_on_chain
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
- resp = JsonResponse(enrich_402_body(body, pay_req), status=402, json_dumps_params={"indent": 2})
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,6 +74,16 @@ 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
@@ -81,6 +96,7 @@ class GateMiddleware:
81
96
  network = self.network
82
97
 
83
98
  pathname = request.path
99
+ price_config = resolve_route_config(pathname, self.routes_table, self.base_price_config)
84
100
  if is_public_path(pathname):
85
101
  return self.get_response(request)
86
102
 
@@ -128,21 +144,22 @@ class GateMiddleware:
128
144
  new_key = generate_agent_key(secret)
129
145
  if fee_info:
130
146
  no_key_instructions = (
131
- f'Send {MIN_PAYMENT} USDC on Solana {network} to {wallet_address} with memo "{new_key}", '
147
+ f'Send {price_config["min_payment"]} USDC on Solana {network} to {wallet_address} with memo "{new_key}", '
132
148
  f'AND in the SAME transaction send the platform fee (see platform_fee below) to {fee_info["wallet"]}. '
133
149
  f'Then include the header X-Agent-Key: {new_key} on all subsequent requests.'
134
150
  )
135
151
  else:
136
- no_key_instructions = f'Send {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.'
152
+ 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
153
  return _payment_required_response(
138
154
  {
139
155
  "error": "payment_required",
140
156
  "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
157
  "your_key": new_key,
142
- "payment": build_payment_object(network=network, min_payment=MIN_PAYMENT, wallet_address=wallet_address, memo=new_key, fee_info=fee_info, instructions=no_key_instructions),
158
+ "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
159
  },
144
- wallet_address=wallet_address, mint=self.usdc_mint, min_payment=MIN_PAYMENT,
160
+ wallet_address=wallet_address, mint=self.usdc_mint, min_payment=price_config["min_payment"],
145
161
  debug=self.debug, agent_key=new_key, resource=pathname,
162
+ 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
163
  )
147
164
 
148
165
  # Validate the key. Platform-issued (agp_) use verificationSecret;
@@ -170,19 +187,24 @@ class GateMiddleware:
170
187
  if self.grant_store and self.grant_store.has(agent_key):
171
188
  return self.get_response(request)
172
189
 
173
- paid = verify_payment_on_chain(agent_key, wallet_address, self.rpc_url, self.usdc_mint, fee_info=fee_info)
190
+ scan_result = _scan_for_payment(agent_key, wallet_address, self.rpc_url, self.usdc_mint, min_payment=price_config["min_payment"], fee_info=fee_info)
191
+ paid = scan_result["paid"]
174
192
  if paid and self.grant_store:
175
- self.grant_store.add(agent_key)
193
+ tier = resolve_tier(scan_result["amount_paid"], price_config["pricing_tiers"])
194
+ duration_seconds = tier["duration_seconds"] if tier else price_config["access_duration"]
195
+ expires_at = (_time.time() + duration_seconds) if duration_seconds else None
196
+ self.grant_store.add(agent_key, expires_at=expires_at, tier=(tier["name"] if tier else None))
176
197
  if not paid:
177
198
  return _payment_required_response(
178
199
  {
179
200
  "error": "payment_required",
180
201
  "message": "Key is valid but payment has not been verified on-chain yet.",
181
202
  "your_key": agent_key,
182
- "payment": build_payment_object(network=network, min_payment=MIN_PAYMENT, wallet_address=wallet_address, memo=agent_key, fee_info=fee_info),
203
+ "payment": build_payment_object(network=network, min_payment=price_config["min_payment"], wallet_address=wallet_address, memo=agent_key, fee_info=fee_info),
183
204
  },
184
- wallet_address=wallet_address, mint=self.usdc_mint, min_payment=MIN_PAYMENT,
205
+ wallet_address=wallet_address, mint=self.usdc_mint, min_payment=price_config["min_payment"],
185
206
  debug=self.debug, agent_key=agent_key, resource=pathname,
207
+ 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
208
  )
187
209
 
188
210
  return self.get_response(request)
@@ -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, verify_payment_on_chain
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):
45
50
  super().__init__(app)
46
51
  if challenge_secret == "default-secret-change-me":
47
52
  import logging
@@ -58,6 +63,10 @@ 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
@@ -66,6 +75,7 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
66
75
 
67
76
  async def dispatch(self, request: Request, call_next):
68
77
  path = request.url.path
78
+ price_config = resolve_route_config(path, self.routes_table, self.base_price_config)
69
79
  if is_public_path(path):
70
80
  return await call_next(request)
71
81
 
@@ -112,21 +122,22 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
112
122
  new_key = generate_agent_key(self.challenge_secret)
113
123
  if fee_info:
114
124
  no_key_instructions = (
115
- f'Send {MIN_PAYMENT} USDC on Solana {network} to {self.home_wallet_address} with memo "{new_key}", '
125
+ f'Send {price_config["min_payment"]} USDC on Solana {network} to {self.home_wallet_address} with memo "{new_key}", '
116
126
  f'AND in the SAME transaction send the platform fee (see platform_fee below) to {fee_info["wallet"]}. '
117
127
  f'Then include the header X-Agent-Key: {new_key} on all subsequent requests.'
118
128
  )
119
129
  else:
120
- no_key_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.'
130
+ 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
131
  return _payment_required_response(
122
132
  {
123
133
  "error": "payment_required",
124
134
  "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
135
  "your_key": new_key,
126
- "payment": build_payment_object(network=network, min_payment=MIN_PAYMENT, wallet_address=self.home_wallet_address, memo=new_key, fee_info=fee_info, instructions=no_key_instructions),
136
+ "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
137
  },
128
- wallet_address=self.home_wallet_address, mint=self.usdc_mint, min_payment=MIN_PAYMENT,
138
+ wallet_address=self.home_wallet_address, mint=self.usdc_mint, min_payment=price_config["min_payment"],
129
139
  debug=self.debug, agent_key=new_key, resource=path,
140
+ 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
141
  )
131
142
 
132
143
  if agent_key.startswith(HOSTED_KEY_PREFIX):
@@ -153,23 +164,28 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
153
164
  if self.grant_store and self.grant_store.has(agent_key):
154
165
  return await call_next(request)
155
166
 
156
- # verify_payment_on_chain is synchronous (uses requests). Run it in a
167
+ # _scan_for_payment is synchronous (uses requests). Run it in a
157
168
  # thread-pool executor so it doesn't block the async event loop.
158
- paid = await loop.run_in_executor(
159
- None, lambda: verify_payment_on_chain(agent_key, self.home_wallet_address, self.solana_rpc_url, self.usdc_mint, fee_info=fee_info)
169
+ scan_result = await loop.run_in_executor(
170
+ None, lambda: _scan_for_payment(agent_key, self.home_wallet_address, self.solana_rpc_url, self.usdc_mint, min_payment=price_config["min_payment"], fee_info=fee_info)
160
171
  )
172
+ paid = scan_result["paid"]
161
173
  if paid and self.grant_store:
162
- self.grant_store.add(agent_key)
174
+ tier = resolve_tier(scan_result["amount_paid"], price_config["pricing_tiers"])
175
+ duration_seconds = tier["duration_seconds"] if tier else price_config["access_duration"]
176
+ expires_at = (_time.time() + duration_seconds) if duration_seconds else None
177
+ self.grant_store.add(agent_key, expires_at=expires_at, tier=(tier["name"] if tier else None))
163
178
  if not paid:
164
179
  return _payment_required_response(
165
180
  {
166
181
  "error": "payment_required",
167
182
  "message": "Key is valid but payment has not been verified on-chain yet.",
168
183
  "your_key": agent_key,
169
- "payment": build_payment_object(network=network, min_payment=MIN_PAYMENT, wallet_address=self.home_wallet_address, memo=agent_key, fee_info=fee_info),
184
+ "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
185
  },
171
- wallet_address=self.home_wallet_address, mint=self.usdc_mint, min_payment=MIN_PAYMENT,
186
+ wallet_address=self.home_wallet_address, mint=self.usdc_mint, min_payment=price_config["min_payment"],
172
187
  debug=self.debug, agent_key=agent_key, resource=path,
188
+ 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
189
  )
174
190
 
175
191
  return await call_next(request)
@@ -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, verify_payment_on_chain
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
- enriched = enrich_402_body(body, pay_req)
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,7 @@ 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):
38
43
  if challenge_secret == "default-secret-change-me":
39
44
  import logging
40
45
  logger = logging.getLogger("agentpayments")
@@ -49,10 +54,14 @@ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: s
49
54
  mint = usdc_mint or (USDC_MINT_DEVNET if debug else USDC_MINT_MAINNET)
50
55
  _require_https = (not debug) if require_https is None else require_https
51
56
  _platform_client = PlatformClient(api_key, platform_url) if api_key else None
57
+ # Price/duration/tier resolution is static for this app — precompute once.
58
+ base_price_config = normalize_price_config(min_payment, access_duration, pricing_tiers)
59
+ routes_table = build_routes_table(routes, min_payment, access_duration, pricing_tiers)
52
60
 
53
61
  @app.before_request
54
62
  def _gate():
55
63
  path = request.path
64
+ price_config = resolve_route_config(path, routes_table, base_price_config)
56
65
  if is_public_path(path):
57
66
  return None
58
67
  if path == "/__challenge/verify" and request.method == "POST":
@@ -94,21 +103,22 @@ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: s
94
103
  new_key = generate_agent_key(challenge_secret)
95
104
  if fee_info:
96
105
  no_key_instructions = (
97
- f'Send {MIN_PAYMENT} USDC on Solana {network} to {home_wallet_address} with memo "{new_key}", '
106
+ f'Send {price_config["min_payment"]} USDC on Solana {network} to {home_wallet_address} with memo "{new_key}", '
98
107
  f'AND in the SAME transaction send the platform fee (see platform_fee below) to {fee_info["wallet"]}. '
99
108
  f'Then include the header X-Agent-Key: {new_key} on all subsequent requests.'
100
109
  )
101
110
  else:
102
- no_key_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.'
111
+ 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
112
  return _payment_required_flask(
104
113
  {
105
114
  "error": "payment_required",
106
115
  "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
116
  "your_key": new_key,
108
- "payment": build_payment_object(network=network, min_payment=MIN_PAYMENT, wallet_address=home_wallet_address, memo=new_key, fee_info=fee_info, instructions=no_key_instructions),
117
+ "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
118
  },
110
- wallet_address=home_wallet_address, mint=mint, min_payment=MIN_PAYMENT,
119
+ wallet_address=home_wallet_address, mint=mint, min_payment=price_config["min_payment"],
111
120
  debug=debug, agent_key=new_key, resource=path,
121
+ 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
122
  )
113
123
  if key.startswith(HOSTED_KEY_PREFIX):
114
124
  if not _platform_client:
@@ -129,19 +139,24 @@ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: s
129
139
  return jsonify({"error": "server_error", "message": "Payment verification unavailable."}), 500
130
140
  if grant_store and grant_store.has(key):
131
141
  return None
132
- paid = verify_payment_on_chain(key, home_wallet_address, rpc_url, mint, fee_info=fee_info)
142
+ scan_result = _scan_for_payment(key, home_wallet_address, rpc_url, mint, min_payment=price_config["min_payment"], fee_info=fee_info)
143
+ paid = scan_result["paid"]
133
144
  if paid and grant_store:
134
- grant_store.add(key)
145
+ tier = resolve_tier(scan_result["amount_paid"], price_config["pricing_tiers"])
146
+ duration_seconds = tier["duration_seconds"] if tier else price_config["access_duration"]
147
+ expires_at = (_time.time() + duration_seconds) if duration_seconds else None
148
+ grant_store.add(key, expires_at=expires_at, tier=(tier["name"] if tier else None))
135
149
  if not paid:
136
150
  return _payment_required_flask(
137
151
  {
138
152
  "error": "payment_required",
139
153
  "message": "Key is valid but payment has not been verified on-chain yet.",
140
154
  "your_key": key,
141
- "payment": build_payment_object(network=network, min_payment=MIN_PAYMENT, wallet_address=home_wallet_address, memo=key, fee_info=fee_info),
155
+ "payment": build_payment_object(network=network, min_payment=price_config["min_payment"], wallet_address=home_wallet_address, memo=key, fee_info=fee_info),
142
156
  },
143
- wallet_address=home_wallet_address, mint=mint, min_payment=MIN_PAYMENT,
157
+ wallet_address=home_wallet_address, mint=mint, min_payment=price_config["min_payment"],
144
158
  debug=debug, agent_key=key, resource=path,
159
+ 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
160
  )
146
161
  return None
147
162
 
@@ -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: set[str] = set()
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
- return agent_key in self._grants
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 add(self, agent_key: str) -> None:
66
+ def revoke(self, agent_key: str) -> None:
45
67
  with self._lock:
46
- self._grants.add(agent_key)
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: set[str] = set()
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
- keys = json.loads(self._path.read_text())
66
- if isinstance(keys, list):
67
- self._grants.update(keys)
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(sorted(self._grants), indent=2))
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
- return agent_key in self._grants
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 add(self, agent_key: str) -> None:
126
+ def revoke(self, agent_key: str) -> None:
82
127
  with self._lock:
83
- if agent_key in self._grants:
84
- return
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
@@ -98,26 +98,41 @@ 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) -> bool:
102
102
  """
103
- Verify payment on-chain. rpc_url may be a string or list of strings (fallback URLs).
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
+ result = _scan_for_payment(agent_key, wallet_address, rpc_url, usdc_mint, min_payment=min_payment, fee_info=fee_info)
108
+ return result["paid"]
109
+
110
+
111
+ def _scan_for_payment(agent_key: str, wallet_address: str, rpc_url, usdc_mint: str, min_payment: float = MIN_PAYMENT, fee_info: dict | None = None) -> dict:
112
+ """
113
+ Scans the chain for a matching payment, same as verify_payment_on_chain,
114
+ but also returns the actual amount paid (decimal USDC) so callers can
115
+ resolve pricing tiers. rpc_url may be a string or list of strings
116
+ (fallback URLs).
104
117
 
105
118
  fee_info, when set (hosted-platform mode with an on-chain fee configured), is
106
119
  {"wallet": str, "rate_pct": float}. When set, the SAME transaction that carries
107
120
  the vendor payment must also carry a USDC transfer to fee_info["wallet"] of at
108
- least MIN_PAYMENT * rate_pct / 100, or the payment is treated as unverified.
121
+ least min_payment * rate_pct / 100, or the payment is treated as unverified.
109
122
  """
110
123
  # Normalise to list so _rpc_call_with_fallback always gets a list.
111
124
  rpc_urls: list[str] = rpc_url if isinstance(rpc_url, list) else [rpc_url]
125
+ min_payment_micro = round(min_payment * 1_000_000)
126
+ not_paid = {"paid": False, "amount_paid": None}
112
127
 
113
128
  cached = _payment_cache.get(agent_key)
114
129
  if cached is True:
115
- return True
130
+ return {"paid": True, "amount_paid": None} # cache doesn't retain the amount
116
131
  if cached is False:
117
- return False # negative cached — skip RPC until TTL expires
132
+ return not_paid # negative cached — skip RPC until TTL expires
118
133
  if not is_valid_solana_address(wallet_address):
119
134
  logger.error("[gate] Invalid wallet address: %s", wallet_address)
120
- return False
135
+ return not_paid
121
136
  try:
122
137
  # commitment: 'finalized' — confirmed blocks can be rolled back (rare but possible).
123
138
  # Finalized adds ~10-20 s latency vs confirmed but guarantees irreversibility.
@@ -128,16 +143,16 @@ def verify_payment_on_chain(agent_key: str, wallet_address: str, rpc_url, usdc_m
128
143
  # token is USDC for plain `transfer` instructions (which carry no mint field).
129
144
  vendor_usdc_accounts = set(token_accounts)
130
145
  if not vendor_usdc_accounts:
131
- return False # vendor has no USDC account yet — no payment possible
146
+ return not_paid # vendor has no USDC account yet — no payment possible
132
147
 
133
148
  fee_usdc_accounts = None
134
149
  fee_amount_micro = 0
135
150
  if fee_info:
136
151
  fee_ata_data = _rpc_call_with_fallback(rpc_urls, "getTokenAccountsByOwner", [fee_info["wallet"], {"mint": usdc_mint}, {"encoding": "jsonParsed", "commitment": "finalized"}])
137
152
  fee_usdc_accounts = {a["pubkey"] for a in fee_ata_data.get("result", {}).get("value", [])}
138
- fee_amount_micro = round(MIN_PAYMENT_MICRO * fee_info["rate_pct"] / 100)
153
+ fee_amount_micro = round(min_payment_micro * fee_info["rate_pct"] / 100)
139
154
  if not fee_usdc_accounts:
140
- return False # fee wallet has no USDC account — fee can never be satisfied
155
+ return not_paid # fee wallet has no USDC account — fee can never be satisfied
141
156
 
142
157
  addresses_to_scan = [wallet_address] + token_accounts
143
158
  seen = set()
@@ -173,6 +188,7 @@ def verify_payment_on_chain(agent_key: str, wallet_address: str, rpc_url, usdc_m
173
188
  has_memo = False
174
189
  has_payment = False
175
190
  has_fee_payment = fee_info is None # vacuously satisfied when no fee is required
191
+ matched_amount_micro = None
176
192
 
177
193
  for ix in all_ix:
178
194
  program = ix.get("program", "")
@@ -206,16 +222,17 @@ def verify_payment_on_chain(agent_key: str, wallet_address: str, rpc_url, usdc_m
206
222
  amount_micro = int(amount_str)
207
223
  except (ValueError, TypeError):
208
224
  amount_micro = 0
209
- if is_vendor_dest and amount_micro >= MIN_PAYMENT_MICRO:
225
+ if is_vendor_dest and amount_micro >= min_payment_micro:
210
226
  has_payment = True
227
+ matched_amount_micro = amount_micro
211
228
  elif is_fee_dest and amount_micro >= fee_amount_micro:
212
229
  has_fee_payment = True
213
230
 
214
231
  if has_memo and has_payment and has_fee_payment:
215
232
  _payment_cache.set(agent_key, True, PAYMENT_CACHE_TTL)
216
- return True
233
+ return {"paid": True, "amount_paid": matched_amount_micro / 1_000_000}
217
234
  except Exception:
218
235
  logger.exception("[gate] Solana RPC error")
219
236
 
220
237
  _payment_cache.set(agent_key, False, NEGATIVE_CACHE_TTL)
221
- return False
238
+ return not_paid
@@ -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.1.2
3
+ Version: 0.2.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,6 +116,10 @@ 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,6 +130,40 @@ Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`, `settin
126
130
 
127
131
  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
132
 
133
+ ## Pricing & Access Model
134
+
135
+ 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):
136
+
137
+ ```python
138
+ # FastAPI / Flask
139
+ register_agentpayments( # or AgentPaymentsASGIMiddleware(...)
140
+ app, ...,
141
+ grant_store=FileGrantStore("./data/grants.json"), # required for duration/tiers to persist
142
+ access_duration=86400, # seconds; a paid key is only good for 24h, then re-verifies
143
+ pricing_tiers=[
144
+ {"min_amount": 0.01, "duration_seconds": 3600, "name": "hourly"},
145
+ {"min_amount": 0.05, "duration_seconds": 86400, "name": "daily"},
146
+ {"min_amount": 0.50, "duration_seconds": None, "name": "lifetime"},
147
+ ],
148
+ routes=[
149
+ {"path_prefix": "/api/premium", "min_payment": 0.05},
150
+ {"path_prefix": "/api/basic", "min_payment": 0.01},
151
+ ],
152
+ )
153
+ ```
154
+
155
+ ```python
156
+ # Django settings.py
157
+ AGENTPAYMENTS_GRANT_STORE = FileGrantStore("/var/data/agp_grants.json")
158
+ AGENTPAYMENTS_ACCESS_DURATION = 86400
159
+ AGENTPAYMENTS_PRICING_TIERS = [...] # same shape as above
160
+ AGENTPAYMENTS_ROUTES = [...]
161
+ ```
162
+
163
+ `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`.
164
+
165
+ **Revocation**: `MemoryGrantStore`/`FileGrantStore` (`agentpayments_python.grant_store`) both gained a `revoke(agent_key)` method — call it from your own admin view to cut off a specific paid key early. The adapters need no changes to respect this: `has()` already returns `False` for a revoked (or expired) grant. A grants file written by an older SDK version (a plain JSON array of key strings) is still read correctly as a set of permanent grants.
166
+
129
167
  ## Security Features
130
168
 
131
169
  - **Timing-safe HMAC comparison** — uses `hmac.compare_digest()` for all signature checks
@@ -150,6 +188,8 @@ agentpayments_python/
150
188
  detection.py Browser detection and public path checks
151
189
  solana.py On-chain payment verification + caching
152
190
  ratelimit.py Shared IP-based rate limiter
191
+ grant_store.py Durable paid-key persistence (expiry, revocation)
192
+ pricing.py Pricing-tier / access-duration / per-route resolution
153
193
  ```
154
194
 
155
195
  ## Notes
@@ -0,0 +1,21 @@
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=D8dq0TUoZUN4u5g9VCEkqDzFYnwIHM_FRYzM9QholFI,15303
9
+ agentpayments_python/fastapi_adapter.py,sha256=0L4PBqFb2A4UbNp7tLLQ3AEJC_tJAC9nAxM_sdhBboc,14573
10
+ agentpayments_python/flask_adapter.py,sha256=i2hbB8Jf5qLmv_uLR7tGoNRR7Rls8xBnrIHzPqnJnYI,12733
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/solana.py,sha256=hRJ6V_rcxLgcw68XdNTqE_ShCIwpBXljjptj2IylPtw,11335
16
+ agentpayments_python/x402.py,sha256=PS5U_dWX1CyRMTxxAYnyuzb-q4mRWvXe1Pys3y-CvEg,5527
17
+ agentpayments_python-0.2.0.dist-info/licenses/LICENSE,sha256=PMKDe4kanHRVI7QQezG4GeluZkMljhVO-tFSFNUiYS4,1069
18
+ agentpayments_python-0.2.0.dist-info/METADATA,sha256=0uSQF-ytcrOf96T2kdg2tz3TmfXxcxBIUNS6mPPj_UY,9317
19
+ agentpayments_python-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
20
+ agentpayments_python-0.2.0.dist-info/top_level.txt,sha256=DxGMGvTYQVdoBQ6pBbbsJXy5BZhbSjNGF_uSO3git4w,21
21
+ agentpayments_python-0.2.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,,