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.
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/PKG-INFO +8 -2
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/README.md +7 -1
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/constants.json +1 -1
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/django_adapter.py +22 -19
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/fastapi_adapter.py +24 -13
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/flask_adapter.py +23 -12
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/platform_client.py +33 -9
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/solana.py +31 -6
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/x402.py +44 -0
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python.egg-info/PKG-INFO +8 -2
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/pyproject.toml +1 -1
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/tests/test_core.py +157 -26
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/LICENSE +0 -0
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/__init__.py +0 -0
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/challenge.py +0 -0
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/cookies.py +0 -0
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/crawler.py +0 -0
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/crypto.py +0 -0
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/detection.py +0 -0
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/grant_store.py +0 -0
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/ratelimit.py +0 -0
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python.egg-info/SOURCES.txt +0 -0
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python.egg-info/dependency_links.txt +0 -0
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python.egg-info/requires.txt +0 -0
- {agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
|
{agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/constants.json
RENAMED
|
@@ -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.
|
|
25
|
+
"PLATFORM_API_URL": "https://api.agentpayments.cloud",
|
|
26
26
|
"HOSTED_KEY_PREFIX": "agp_"
|
|
27
27
|
}
|
{agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/django_adapter.py
RENAMED
|
@@ -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,
|
{agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/fastapi_adapter.py
RENAMED
|
@@ -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
|
|
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":
|
|
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,
|
{agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/flask_adapter.py
RENAMED
|
@@ -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":
|
|
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,
|
{agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/platform_client.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
173
|
-
|
|
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.
|
{agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentpayments-python
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python/grant_store.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{agentpayments_python-0.1.0 → agentpayments_python-0.1.1}/agentpayments_python.egg-info/requires.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|