agentpayments-python 0.1.0__tar.gz → 0.1.1__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.
Files changed (26) hide show
  1. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/PKG-INFO +8 -2
  2. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/README.md +7 -1
  3. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/constants.json +1 -1
  4. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/django_adapter.py +22 -19
  5. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/fastapi_adapter.py +24 -13
  6. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/flask_adapter.py +23 -12
  7. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/platform_client.py +33 -9
  8. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/solana.py +31 -6
  9. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/x402.py +44 -0
  10. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python.egg-info/PKG-INFO +8 -2
  11. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/pyproject.toml +1 -1
  12. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/tests/test_core.py +157 -26
  13. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/LICENSE +0 -0
  14. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/__init__.py +0 -0
  15. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/challenge.py +0 -0
  16. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/cookies.py +0 -0
  17. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/crawler.py +0 -0
  18. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/crypto.py +0 -0
  19. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/detection.py +0 -0
  20. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/grant_store.py +0 -0
  21. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/ratelimit.py +0 -0
  22. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python.egg-info/SOURCES.txt +0 -0
  23. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python.egg-info/dependency_links.txt +0 -0
  24. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python.egg-info/requires.txt +0 -0
  25. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python.egg-info/top_level.txt +0 -0
  26. {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentpayments-python
3
- Version: 0.1.0
3
+ Version: 0.1.1
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
@@ -117,8 +117,14 @@ register_agentpayments(
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
119
  | `debug` | No | `True` | `True` = devnet. `False` = mainnet + strict mode. |
120
+ | `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
+ | `platform_url` | No | AgentPayments-hosted URL | Override for a self-hosted platform API. |
120
122
 
121
- Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`). FastAPI and Flask accept them as constructor arguments.
123
+ Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`, `settings.AGENTPAYMENTS_API_KEY`). FastAPI and Flask accept them as constructor arguments.
124
+
125
+ ## Hosted Platform Mode
126
+
127
+ 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.
122
128
 
123
129
  ## Security Features
124
130
 
@@ -80,8 +80,14 @@ register_agentpayments(
80
80
  | `solana_rpc_url` | No | Auto (devnet/mainnet) | Custom Solana RPC endpoint. |
81
81
  | `usdc_mint` | No | Auto (devnet/mainnet) | Custom USDC mint address. |
82
82
  | `debug` | No | `True` | `True` = devnet. `False` = mainnet + strict mode. |
83
+ | `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. |
84
+ | `platform_url` | No | AgentPayments-hosted URL | Override for a self-hosted platform API. |
83
85
 
84
- Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`). FastAPI and Flask accept them as constructor arguments.
86
+ Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`, `settings.AGENTPAYMENTS_API_KEY`). FastAPI and Flask accept them as constructor arguments.
87
+
88
+ ## Hosted Platform Mode
89
+
90
+ 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.
85
91
 
86
92
  ## Security Features
87
93
 
@@ -22,6 +22,6 @@
22
22
  "X402_VERSION": 1,
23
23
  "SOLANA_CHAIN_ID_MAINNET": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
24
24
  "SOLANA_CHAIN_ID_DEVNET": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
25
- "PLATFORM_API_URL": "https://api.agentpayments.dev",
25
+ "PLATFORM_API_URL": "https://api.agentpayments.cloud",
26
26
  "HOSTED_KEY_PREFIX": "agp_"
27
27
  }
@@ -12,7 +12,7 @@ from .detection import is_browser_from_headers, is_public_path
12
12
  from .ratelimit import _challenge_limiter, _agent_key_limiter, _challenge_issue_limiter
13
13
  from .crawler import is_verified_crawler
14
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_requirements, enrich_402_body, payment_required_header
15
+ from .x402 import build_payment_object, build_payment_requirements, enrich_402_body, payment_required_header
16
16
  from .platform_client import HOSTED_KEY_PREFIX, PlatformClient, is_valid_hosted_key
17
17
 
18
18
  import json as _json
@@ -105,6 +105,16 @@ class GateMiddleware:
105
105
  }
106
106
  if not is_browser_from_headers(headers):
107
107
  agent_key = request.META.get("HTTP_X_AGENT_KEY")
108
+
109
+ # Resolve the on-chain platform fee requirement once (hosted-platform
110
+ # mode only — always None for self-hosted vendors with no platform client).
111
+ fee_info = None
112
+ if self._platform_client:
113
+ try:
114
+ fee_info = self._platform_client.get_platform_fee_info()
115
+ except Exception as exc:
116
+ logger.warning("Failed to fetch platform fee info, proceeding without fee enforcement: %s", exc)
117
+
108
118
  if not agent_key:
109
119
  # Hosted mode: issue metered platform key (agp_).
110
120
  # Local mode: generate self-signed key (ag_).
@@ -116,20 +126,20 @@ class GateMiddleware:
116
126
  new_key = generate_agent_key(secret)
117
127
  else:
118
128
  new_key = generate_agent_key(secret)
129
+ if fee_info:
130
+ no_key_instructions = (
131
+ f'Send {MIN_PAYMENT} USDC on Solana {network} to {wallet_address} with memo "{new_key}", '
132
+ f'AND in the SAME transaction send the platform fee (see platform_fee below) to {fee_info["wallet"]}. '
133
+ f'Then include the header X-Agent-Key: {new_key} on all subsequent requests.'
134
+ )
135
+ 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.'
119
137
  return _payment_required_response(
120
138
  {
121
139
  "error": "payment_required",
122
140
  "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.",
123
141
  "your_key": new_key,
124
- "payment": {
125
- "chain": "solana",
126
- "network": network,
127
- "token": "USDC",
128
- "amount": str(MIN_PAYMENT),
129
- "wallet_address": wallet_address,
130
- "memo": new_key,
131
- "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.',
132
- },
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),
133
143
  },
134
144
  wallet_address=wallet_address, mint=self.usdc_mint, min_payment=MIN_PAYMENT,
135
145
  debug=self.debug, agent_key=new_key, resource=pathname,
@@ -160,7 +170,7 @@ class GateMiddleware:
160
170
  if self.grant_store and self.grant_store.has(agent_key):
161
171
  return self.get_response(request)
162
172
 
163
- paid = verify_payment_on_chain(agent_key, wallet_address, self.rpc_url, self.usdc_mint)
173
+ paid = verify_payment_on_chain(agent_key, wallet_address, self.rpc_url, self.usdc_mint, fee_info=fee_info)
164
174
  if paid and self.grant_store:
165
175
  self.grant_store.add(agent_key)
166
176
  if not paid:
@@ -169,14 +179,7 @@ class GateMiddleware:
169
179
  "error": "payment_required",
170
180
  "message": "Key is valid but payment has not been verified on-chain yet.",
171
181
  "your_key": agent_key,
172
- "payment": {
173
- "chain": "solana",
174
- "network": network,
175
- "token": "USDC",
176
- "amount": str(MIN_PAYMENT),
177
- "wallet_address": wallet_address,
178
- "memo": agent_key,
179
- },
182
+ "payment": build_payment_object(network=network, min_payment=MIN_PAYMENT, wallet_address=wallet_address, memo=agent_key, fee_info=fee_info),
180
183
  },
181
184
  wallet_address=wallet_address, mint=self.usdc_mint, min_payment=MIN_PAYMENT,
182
185
  debug=self.debug, agent_key=agent_key, resource=pathname,
@@ -11,7 +11,7 @@ from .detection import is_browser_from_headers, is_public_path
11
11
  from .ratelimit import _challenge_limiter, _agent_key_limiter, _challenge_issue_limiter
12
12
  from .crawler import is_verified_crawler
13
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
14
+ from .x402 import build_payment_object, build_payment_requirements, enrich_402_body, payment_required_header
15
15
  from .platform_client import HOSTED_KEY_PREFIX, PlatformClient, is_valid_hosted_key
16
16
 
17
17
  import json as _json
@@ -88,6 +88,18 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
88
88
  if not is_browser_from_headers(dict(request.headers)):
89
89
  agent_key = request.headers.get("x-agent-key")
90
90
  network = "devnet" if self.debug else "mainnet-beta"
91
+ loop = asyncio.get_event_loop()
92
+
93
+ # Resolve the on-chain platform fee requirement once (hosted-platform
94
+ # mode only — always None for self-hosted vendors with no platform client).
95
+ fee_info = None
96
+ if self._platform_client:
97
+ try:
98
+ fee_info = await loop.run_in_executor(None, self._platform_client.get_platform_fee_info)
99
+ except Exception as exc:
100
+ import logging as _log
101
+ _log.getLogger("agentpayments").warning("Failed to fetch platform fee info, proceeding without fee enforcement: %s", exc)
102
+
91
103
  if not agent_key:
92
104
  if self._platform_client:
93
105
  try:
@@ -98,20 +110,20 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
98
110
  new_key = generate_agent_key(self.challenge_secret)
99
111
  else:
100
112
  new_key = generate_agent_key(self.challenge_secret)
113
+ if fee_info:
114
+ no_key_instructions = (
115
+ f'Send {MIN_PAYMENT} USDC on Solana {network} to {self.home_wallet_address} with memo "{new_key}", '
116
+ f'AND in the SAME transaction send the platform fee (see platform_fee below) to {fee_info["wallet"]}. '
117
+ f'Then include the header X-Agent-Key: {new_key} on all subsequent requests.'
118
+ )
119
+ 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.'
101
121
  return _payment_required_response(
102
122
  {
103
123
  "error": "payment_required",
104
124
  "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
125
  "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
- },
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),
115
127
  },
116
128
  wallet_address=self.home_wallet_address, mint=self.usdc_mint, min_payment=MIN_PAYMENT,
117
129
  debug=self.debug, agent_key=new_key, resource=path,
@@ -143,9 +155,8 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
143
155
 
144
156
  # verify_payment_on_chain is synchronous (uses requests). Run it in a
145
157
  # thread-pool executor so it doesn't block the async event loop.
146
- loop = asyncio.get_event_loop()
147
158
  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
159
+ None, lambda: verify_payment_on_chain(agent_key, self.home_wallet_address, self.solana_rpc_url, self.usdc_mint, fee_info=fee_info)
149
160
  )
150
161
  if paid and self.grant_store:
151
162
  self.grant_store.add(agent_key)
@@ -155,7 +166,7 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
155
166
  "error": "payment_required",
156
167
  "message": "Key is valid but payment has not been verified on-chain yet.",
157
168
  "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},
169
+ "payment": build_payment_object(network=network, min_payment=MIN_PAYMENT, wallet_address=self.home_wallet_address, memo=agent_key, fee_info=fee_info),
159
170
  },
160
171
  wallet_address=self.home_wallet_address, mint=self.usdc_mint, min_payment=MIN_PAYMENT,
161
172
  debug=self.debug, agent_key=agent_key, resource=path,
@@ -8,7 +8,7 @@ from .detection import is_browser_from_headers, is_public_path
8
8
  from .ratelimit import _challenge_limiter, _agent_key_limiter, _challenge_issue_limiter
9
9
  from .crawler import is_verified_crawler
10
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
11
+ from .x402 import build_payment_object, build_payment_requirements, enrich_402_body, payment_required_header
12
12
  from .platform_client import HOSTED_KEY_PREFIX, PlatformClient, is_valid_hosted_key
13
13
 
14
14
  import json as _json
@@ -71,6 +71,17 @@ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: s
71
71
  if not is_browser_from_headers(request.headers):
72
72
  key = request.headers.get("X-Agent-Key")
73
73
  network = "devnet" if debug else "mainnet-beta"
74
+
75
+ # Resolve the on-chain platform fee requirement once (hosted-platform
76
+ # mode only — always None for self-hosted vendors with no platform client).
77
+ fee_info = None
78
+ if _platform_client:
79
+ try:
80
+ fee_info = _platform_client.get_platform_fee_info()
81
+ except Exception as exc:
82
+ import logging as _log
83
+ _log.getLogger("agentpayments").warning("Failed to fetch platform fee info, proceeding without fee enforcement: %s", exc)
84
+
74
85
  if not key:
75
86
  if _platform_client:
76
87
  try:
@@ -81,20 +92,20 @@ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: s
81
92
  new_key = generate_agent_key(challenge_secret)
82
93
  else:
83
94
  new_key = generate_agent_key(challenge_secret)
95
+ if fee_info:
96
+ no_key_instructions = (
97
+ f'Send {MIN_PAYMENT} USDC on Solana {network} to {home_wallet_address} with memo "{new_key}", '
98
+ f'AND in the SAME transaction send the platform fee (see platform_fee below) to {fee_info["wallet"]}. '
99
+ f'Then include the header X-Agent-Key: {new_key} on all subsequent requests.'
100
+ )
101
+ 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.'
84
103
  return _payment_required_flask(
85
104
  {
86
105
  "error": "payment_required",
87
106
  "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
107
  "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
- },
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),
98
109
  },
99
110
  wallet_address=home_wallet_address, mint=mint, min_payment=MIN_PAYMENT,
100
111
  debug=debug, agent_key=new_key, resource=path,
@@ -118,7 +129,7 @@ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: s
118
129
  return jsonify({"error": "server_error", "message": "Payment verification unavailable."}), 500
119
130
  if grant_store and grant_store.has(key):
120
131
  return None
121
- paid = verify_payment_on_chain(key, home_wallet_address, rpc_url, mint)
132
+ paid = verify_payment_on_chain(key, home_wallet_address, rpc_url, mint, fee_info=fee_info)
122
133
  if paid and grant_store:
123
134
  grant_store.add(key)
124
135
  if not paid:
@@ -127,7 +138,7 @@ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: s
127
138
  "error": "payment_required",
128
139
  "message": "Key is valid but payment has not been verified on-chain yet.",
129
140
  "your_key": key,
130
- "payment": {"chain": "solana", "network": network, "token": "USDC", "amount": str(MIN_PAYMENT), "wallet_address": home_wallet_address, "memo": key},
141
+ "payment": build_payment_object(network=network, min_payment=MIN_PAYMENT, wallet_address=home_wallet_address, memo=key, fee_info=fee_info),
131
142
  },
132
143
  wallet_address=home_wallet_address, mint=mint, min_payment=MIN_PAYMENT,
133
144
  debug=debug, agent_key=key, resource=path,
@@ -63,8 +63,13 @@ class PlatformClient:
63
63
 
64
64
  def __init__(self, api_key: str, platform_url: str = PLATFORM_API_URL) -> None:
65
65
  self.api_key = api_key
66
- self.platform_url = platform_url.rstrip("/")
66
+ # Callers (django/fastapi/flask adapters) often pass platform_url=None
67
+ # explicitly when no override was configured, which bypasses this
68
+ # parameter's own default — fall back to PLATFORM_API_URL here too.
69
+ self.platform_url = (platform_url or PLATFORM_API_URL).rstrip("/")
67
70
  self._verification_secret: str | None = None
71
+ self._platform_fee_info: dict | None = None
72
+ self._account_fetched = False
68
73
  self._lock = threading.Lock()
69
74
 
70
75
  def _auth_headers(self) -> dict:
@@ -98,19 +103,38 @@ class PlatformClient:
98
103
  except urllib.error.HTTPError as exc:
99
104
  raise RuntimeError(f"Platform API {path} returned {exc.code}") from exc
100
105
 
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
+ def _ensure_account_fetched(self) -> None:
107
+ """Fetch + cache the /v1/account response once (thread-safe, double-checked)."""
108
+ if self._account_fetched:
109
+ return
106
110
  with self._lock:
107
- # Double-checked locking
108
- if self._verification_secret:
109
- return self._verification_secret
111
+ if self._account_fetched:
112
+ return
110
113
  data = self._get("/v1/account")
111
114
  self._verification_secret = data["verificationSecret"]
115
+ fee_wallet = data.get("platformFeeWallet")
116
+ self._platform_fee_info = (
117
+ {"wallet": fee_wallet, "rate_pct": data.get("platformFeeRatePct")}
118
+ if fee_wallet
119
+ else None
120
+ )
121
+ self._account_fetched = True
122
+
123
+ @property
124
+ def verification_secret(self) -> str:
125
+ """Fetch + cache verificationSecret from /v1/account (thread-safe)."""
126
+ self._ensure_account_fetched()
112
127
  return self._verification_secret
113
128
 
129
+ def get_platform_fee_info(self) -> dict | None:
130
+ """
131
+ Fetch + cache the on-chain platform fee config from /v1/account (same
132
+ request as verification_secret — no extra round trip if already fetched).
133
+ Returns {"wallet": str, "rate_pct": float} or None if no fee is configured.
134
+ """
135
+ self._ensure_account_fetched()
136
+ return self._platform_fee_info
137
+
114
138
  def issue_key(self) -> str:
115
139
  """Issue a single platform-signed agent key (agp_...). Metered."""
116
140
  data = self._post("/v1/keys/issue")
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import json
2
4
  import logging
3
5
  import re
@@ -96,8 +98,15 @@ def is_valid_solana_address(address: str) -> bool:
96
98
  return bool(address and BASE58_RE.match(address))
97
99
 
98
100
 
99
- def verify_payment_on_chain(agent_key: str, wallet_address: str, rpc_url, usdc_mint: str) -> bool:
100
- """Verify payment on-chain. rpc_url may be a string or list of strings (fallback URLs)."""
101
+ def verify_payment_on_chain(agent_key: str, wallet_address: str, rpc_url, usdc_mint: str, fee_info: dict | None = None) -> bool:
102
+ """
103
+ Verify payment on-chain. rpc_url may be a string or list of strings (fallback URLs).
104
+
105
+ fee_info, when set (hosted-platform mode with an on-chain fee configured), is
106
+ {"wallet": str, "rate_pct": float}. When set, the SAME transaction that carries
107
+ 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.
109
+ """
101
110
  # Normalise to list so _rpc_call_with_fallback always gets a list.
102
111
  rpc_urls: list[str] = rpc_url if isinstance(rpc_url, list) else [rpc_url]
103
112
 
@@ -121,6 +130,15 @@ def verify_payment_on_chain(agent_key: str, wallet_address: str, rpc_url, usdc_m
121
130
  if not vendor_usdc_accounts:
122
131
  return False # vendor has no USDC account yet — no payment possible
123
132
 
133
+ fee_usdc_accounts = None
134
+ fee_amount_micro = 0
135
+ if fee_info:
136
+ fee_ata_data = _rpc_call_with_fallback(rpc_urls, "getTokenAccountsByOwner", [fee_info["wallet"], {"mint": usdc_mint}, {"encoding": "jsonParsed", "commitment": "finalized"}])
137
+ 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)
139
+ if not fee_usdc_accounts:
140
+ return False # fee wallet has no USDC account — fee can never be satisfied
141
+
124
142
  addresses_to_scan = [wallet_address] + token_accounts
125
143
  seen = set()
126
144
  all_signatures = []
@@ -154,6 +172,7 @@ def verify_payment_on_chain(agent_key: str, wallet_address: str, rpc_url, usdc_m
154
172
 
155
173
  has_memo = False
156
174
  has_payment = False
175
+ has_fee_payment = fee_info is None # vacuously satisfied when no fee is required
157
176
 
158
177
  for ix in all_ix:
159
178
  program = ix.get("program", "")
@@ -169,8 +188,12 @@ def verify_payment_on_chain(agent_key: str, wallet_address: str, rpc_url, usdc_m
169
188
  tx_type = parsed.get("type", "")
170
189
  if tx_type in ("transfer", "transferChecked"):
171
190
  info = parsed.get("info", {})
172
- # Payment must be delivered to one of the vendor's USDC token accounts.
173
- if info.get("destination") not in vendor_usdc_accounts:
191
+ destination = info.get("destination")
192
+ # Payment must be delivered to one of the vendor's or fee
193
+ # wallet's USDC token accounts — anything else is irrelevant.
194
+ is_vendor_dest = destination in vendor_usdc_accounts
195
+ is_fee_dest = fee_usdc_accounts is not None and destination in fee_usdc_accounts
196
+ if not is_vendor_dest and not is_fee_dest:
174
197
  continue
175
198
  if tx_type == "transferChecked" and info.get("mint") != usdc_mint:
176
199
  continue
@@ -183,10 +206,12 @@ def verify_payment_on_chain(agent_key: str, wallet_address: str, rpc_url, usdc_m
183
206
  amount_micro = int(amount_str)
184
207
  except (ValueError, TypeError):
185
208
  amount_micro = 0
186
- if amount_micro >= MIN_PAYMENT_MICRO:
209
+ if is_vendor_dest and amount_micro >= MIN_PAYMENT_MICRO:
187
210
  has_payment = True
211
+ elif is_fee_dest and amount_micro >= fee_amount_micro:
212
+ has_fee_payment = True
188
213
 
189
- if has_memo and has_payment:
214
+ if has_memo and has_payment and has_fee_payment:
190
215
  _payment_cache.set(agent_key, True, PAYMENT_CACHE_TTL)
191
216
  return True
192
217
  except Exception:
@@ -7,6 +7,8 @@ requirements from our 402 responses.
7
7
 
8
8
  Spec: https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_svm.md
9
9
  """
10
+ from __future__ import annotations
11
+
10
12
  import base64
11
13
  import json as _json
12
14
  import math
@@ -64,6 +66,48 @@ def build_payment_requirements(
64
66
  return req
65
67
 
66
68
 
69
+ def build_payment_object(
70
+ *,
71
+ network: str,
72
+ min_payment: float,
73
+ wallet_address: str,
74
+ memo: str,
75
+ fee_info: dict | None = None,
76
+ instructions: str = "",
77
+ ) -> dict:
78
+ """
79
+ Build the custom `payment` dict for a 402 body — NOT part of the x402 spec
80
+ (that's build_payment_requirements above, which stays vendor-leg-only). When
81
+ fee_info is set (hosted-platform mode with an on-chain fee configured), adds
82
+ a platform_fee field describing the second required transfer. Deliberately
83
+ not added as a second x402 accepts[] entry — that would read to a
84
+ spec-compliant client as an alternative payment method, not an additional
85
+ requirement.
86
+
87
+ fee_info: {"wallet": str, "rate_pct": float} or None.
88
+ """
89
+ payment: dict = {
90
+ "chain": "solana",
91
+ "network": network,
92
+ "token": "USDC",
93
+ "amount": str(min_payment),
94
+ "wallet_address": wallet_address,
95
+ "memo": memo,
96
+ }
97
+ if fee_info:
98
+ fee_amount_micro = round(round(min_payment * 1_000_000) * fee_info["rate_pct"] / 100)
99
+ payment["platform_fee"] = {
100
+ "wallet_address": fee_info["wallet"],
101
+ "amount": str(fee_amount_micro / 1_000_000),
102
+ "token": "USDC",
103
+ "rate_pct": fee_info["rate_pct"],
104
+ "note": "Must be a second USDC transfer inside the SAME Solana transaction as the payment above, or access will be denied.",
105
+ }
106
+ if instructions:
107
+ payment["instructions"] = instructions
108
+ return payment
109
+
110
+
67
111
  def payment_required_header(payment_requirements: dict) -> str:
68
112
  """
69
113
  Return the value for the X-PAYMENT-REQUIRED response header.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentpayments-python
3
- Version: 0.1.0
3
+ Version: 0.1.1
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
@@ -117,8 +117,14 @@ register_agentpayments(
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
119
  | `debug` | No | `True` | `True` = devnet. `False` = mainnet + strict mode. |
120
+ | `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
+ | `platform_url` | No | AgentPayments-hosted URL | Override for a self-hosted platform API. |
120
122
 
121
- Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`). FastAPI and Flask accept them as constructor arguments.
123
+ Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`, `settings.AGENTPAYMENTS_API_KEY`). FastAPI and Flask accept them as constructor arguments.
124
+
125
+ ## Hosted Platform Mode
126
+
127
+ 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.
122
128
 
123
129
  ## Security Features
124
130
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentpayments-python"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "AgentPayments gate for Python web frameworks — charge AI agents USDC on Solana before they can access your API"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -587,8 +587,47 @@ def _rpc_response(result):
587
587
  )
588
588
 
589
589
 
590
- def _build_tx(memo: str, amount: float, mint: str, destination_owner: str, ok=True):
591
- """Build a minimal mock RPC getTransaction response for a transferChecked."""
590
+ def _build_tx(memo: str, amount: float, mint: str, destination_owner: str, ok=True, fee_amount: float = None):
591
+ """Build a minimal mock RPC getTransaction response for a transferChecked.
592
+
593
+ fee_amount, if given, adds a second transferChecked instruction (in the SAME
594
+ transaction) to a distinct "fee_ata_address" destination — simulating the
595
+ on-chain platform fee leg.
596
+ """
597
+ instructions = [
598
+ {
599
+ "program": "spl-token",
600
+ "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
601
+ "parsed": {
602
+ "type": "transferChecked",
603
+ "info": {
604
+ "mint": mint,
605
+ "tokenAmount": {"uiAmount": amount, "amount": str(round(amount * 1_000_000)), "decimals": 6},
606
+ "destination": "dest_ata_address",
607
+ "authority": "payer_address",
608
+ },
609
+ },
610
+ },
611
+ ]
612
+ if fee_amount is not None:
613
+ instructions.append({
614
+ "program": "spl-token",
615
+ "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
616
+ "parsed": {
617
+ "type": "transferChecked",
618
+ "info": {
619
+ "mint": mint,
620
+ "tokenAmount": {"uiAmount": fee_amount, "amount": str(round(fee_amount * 1_000_000)), "decimals": 6},
621
+ "destination": "fee_ata_address",
622
+ "authority": "payer_address",
623
+ },
624
+ },
625
+ })
626
+ instructions.append({
627
+ "program": "spl-memo",
628
+ "programId": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",
629
+ "parsed": memo,
630
+ })
592
631
  return {
593
632
  "meta": {
594
633
  "err": None if ok else {"InstructionError": [0, "Custom"]},
@@ -597,26 +636,7 @@ def _build_tx(memo: str, amount: float, mint: str, destination_owner: str, ok=Tr
597
636
  },
598
637
  "transaction": {
599
638
  "message": {
600
- "instructions": [
601
- {
602
- "program": "spl-token",
603
- "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
604
- "parsed": {
605
- "type": "transferChecked",
606
- "info": {
607
- "mint": mint,
608
- "tokenAmount": {"uiAmount": amount, "amount": str(round(amount * 1_000_000)), "decimals": 6},
609
- "destination": "dest_ata_address",
610
- "authority": "payer_address",
611
- },
612
- },
613
- },
614
- {
615
- "program": "spl-memo",
616
- "programId": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",
617
- "parsed": memo,
618
- },
619
- ],
639
+ "instructions": instructions,
620
640
  "accountKeys": [],
621
641
  }
622
642
  },
@@ -625,21 +645,28 @@ def _build_tx(memo: str, amount: float, mint: str, destination_owner: str, ok=Tr
625
645
 
626
646
  class TestVerifyPaymentOnChain:
627
647
  WALLET = "5rXZeAEbg13DQnSFijEno2hKEJLK2p14fAo3AmPtfBft"
648
+ FEE_WALLET = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"
628
649
  MINT = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" # devnet USDC
629
650
  RPC = "https://api.devnet.solana.com"
630
651
 
631
652
  def _fresh_key(self):
632
653
  return generate_agent_key(SECRET)
633
654
 
634
- def _patch_rpc(self, sigs_result, ata_result, tx_result):
635
- """Return a context manager that patches requests.post with ordered responses."""
636
- from unittest.mock import call
655
+ def _patch_rpc(self, sigs_result, ata_result, tx_result, fee_ata_result=None):
656
+ """Return a context manager that patches requests.post with ordered responses.
637
657
 
638
- responses = []
658
+ When fee_ata_result is given, getTokenAccountsByOwner is dispatched by the
659
+ queried owner (params[0]): self.WALLET -> ata_result, self.FEE_WALLET ->
660
+ fee_ata_result — mirrors the two separate ATA lookups verify_payment_on_chain
661
+ makes when fee_info is set.
662
+ """
639
663
 
640
664
  def side_effect(url, json=None, timeout=None):
641
665
  method = json.get("method", "")
642
666
  if method == "getTokenAccountsByOwner":
667
+ owner = (json.get("params") or [None])[0]
668
+ if fee_ata_result is not None and owner == self.FEE_WALLET:
669
+ return _rpc_response(fee_ata_result)
643
670
  return _rpc_response(ata_result)
644
671
  if method == "getSignaturesForAddress":
645
672
  return _rpc_response(sigs_result)
@@ -765,3 +792,107 @@ class TestVerifyPaymentOnChain:
765
792
  with patch("requests.post", side_effect=Exception("should not be called")):
766
793
  result = verify_payment_on_chain(key, self.WALLET, self.RPC, self.MINT)
767
794
  assert result is False
795
+
796
+
797
+ class TestVerifyPaymentOnChainFee:
798
+ """On-chain platform fee leg — hosted-mode vendors only (fee_info set)."""
799
+
800
+ WALLET = TestVerifyPaymentOnChain.WALLET
801
+ FEE_WALLET = TestVerifyPaymentOnChain.FEE_WALLET
802
+ MINT = TestVerifyPaymentOnChain.MINT
803
+ RPC = TestVerifyPaymentOnChain.RPC
804
+ FEE_INFO = {"wallet": FEE_WALLET, "rate_pct": 2}
805
+ FEE_AMOUNT = 0.01 * 0.02 # 2% of MIN_PAYMENT (0.01)
806
+
807
+ def _fresh_key(self):
808
+ return generate_agent_key(SECRET)
809
+
810
+ def _ata(self, pubkey, owner):
811
+ return {"value": [{"pubkey": pubkey, "account": {"data": {"parsed": {"info": {"mint": self.MINT, "owner": owner}}}}}]}
812
+
813
+ def _patch_rpc(self, sigs_result, ata_result, tx_result, fee_ata_result):
814
+ """getTokenAccountsByOwner is dispatched by the queried owner (params[0])."""
815
+
816
+ def side_effect(url, json=None, timeout=None):
817
+ method = json.get("method", "")
818
+ if method == "getTokenAccountsByOwner":
819
+ owner = (json.get("params") or [None])[0]
820
+ return _rpc_response(fee_ata_result if owner == self.FEE_WALLET else ata_result)
821
+ if method == "getSignaturesForAddress":
822
+ return _rpc_response(sigs_result)
823
+ if method == "getTransaction":
824
+ return _rpc_response(tx_result)
825
+ return _rpc_response(None)
826
+
827
+ return patch("requests.post", side_effect=side_effect)
828
+
829
+ def test_fee_leg_missing_denies_access(self):
830
+ from agentpayments_python.solana import verify_payment_on_chain
831
+ key = self._fresh_key()
832
+ tx = _build_tx(key, 0.01, self.MINT, self.WALLET) # vendor leg only, no fee leg
833
+ ata = self._ata("dest_ata_address", self.WALLET)
834
+ fee_ata = self._ata("fee_ata_address", self.FEE_WALLET)
835
+ sigs = [{"signature": "fee_sig_missing", "err": None}]
836
+ with self._patch_rpc(sigs, ata, tx, fee_ata):
837
+ result = verify_payment_on_chain(key, self.WALLET, self.RPC, self.MINT, fee_info=self.FEE_INFO)
838
+ assert result is False
839
+
840
+ def test_fee_leg_present_grants_access(self):
841
+ from agentpayments_python.solana import verify_payment_on_chain
842
+ key = self._fresh_key()
843
+ tx = _build_tx(key, 0.01, self.MINT, self.WALLET, fee_amount=self.FEE_AMOUNT)
844
+ ata = self._ata("dest_ata_address", self.WALLET)
845
+ fee_ata = self._ata("fee_ata_address", self.FEE_WALLET)
846
+ sigs = [{"signature": "fee_sig_ok", "err": None}]
847
+ with self._patch_rpc(sigs, ata, tx, fee_ata):
848
+ result = verify_payment_on_chain(key, self.WALLET, self.RPC, self.MINT, fee_info=self.FEE_INFO)
849
+ assert result is True
850
+
851
+ def test_fee_leg_underpaid_denies_access(self):
852
+ from agentpayments_python.solana import verify_payment_on_chain
853
+ key = self._fresh_key()
854
+ tx = _build_tx(key, 0.01, self.MINT, self.WALLET, fee_amount=self.FEE_AMOUNT / 2)
855
+ ata = self._ata("dest_ata_address", self.WALLET)
856
+ fee_ata = self._ata("fee_ata_address", self.FEE_WALLET)
857
+ sigs = [{"signature": "fee_sig_underpaid", "err": None}]
858
+ with self._patch_rpc(sigs, ata, tx, fee_ata):
859
+ result = verify_payment_on_chain(key, self.WALLET, self.RPC, self.MINT, fee_info=self.FEE_INFO)
860
+ assert result is False
861
+
862
+ def test_fee_wallet_with_no_usdc_account_denies_access(self):
863
+ from agentpayments_python.solana import verify_payment_on_chain
864
+ key = self._fresh_key()
865
+ tx = _build_tx(key, 0.01, self.MINT, self.WALLET, fee_amount=self.FEE_AMOUNT)
866
+ ata = self._ata("dest_ata_address", self.WALLET)
867
+ fee_ata = {"value": []} # fee wallet has no USDC ATA yet
868
+ sigs = [{"signature": "fee_sig_no_ata", "err": None}]
869
+ with self._patch_rpc(sigs, ata, tx, fee_ata):
870
+ result = verify_payment_on_chain(key, self.WALLET, self.RPC, self.MINT, fee_info=self.FEE_INFO)
871
+ assert result is False
872
+
873
+ def test_no_fee_info_skips_fee_check_entirely(self):
874
+ """fee_info=None (self-hosted / no platform fee configured) behaves exactly
875
+ like today: only one getTokenAccountsByOwner call, vendor leg alone suffices."""
876
+ from agentpayments_python.solana import verify_payment_on_chain
877
+ key = self._fresh_key()
878
+ tx = _build_tx(key, 0.01, self.MINT, self.WALLET) # vendor leg only
879
+ ata = self._ata("dest_ata_address", self.WALLET)
880
+ sigs = [{"signature": "fee_sig_none", "err": None}]
881
+
882
+ ata_calls = {"n": 0}
883
+
884
+ def side_effect(url, json=None, timeout=None):
885
+ method = json.get("method", "")
886
+ if method == "getTokenAccountsByOwner":
887
+ ata_calls["n"] += 1
888
+ return _rpc_response(ata)
889
+ if method == "getSignaturesForAddress":
890
+ return _rpc_response(sigs)
891
+ if method == "getTransaction":
892
+ return _rpc_response(tx)
893
+ return _rpc_response(None)
894
+
895
+ with patch("requests.post", side_effect=side_effect):
896
+ result = verify_payment_on_chain(key, self.WALLET, self.RPC, self.MINT, fee_info=None)
897
+ assert result is True
898
+ assert ata_calls["n"] == 1