envarpay 0.1.0a6__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.
envarpay/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Standard-protocol payment adapters and read-only configuration/receipt utilities."""
2
+
3
+ __version__ = "0.1.0a6"
4
+
5
+ from .config import Config, load_config
6
+
7
+ __all__ = ["Config", "PaidServer", "WalletService", "load_config", "__version__"]
8
+
9
+
10
+ def __getattr__(name):
11
+ # Reading configuration or receipts does not load signer/runtime dependencies.
12
+ if name == "PaidServer":
13
+ from .seller import PaidServer
14
+
15
+ return PaidServer
16
+ if name == "WalletService":
17
+ from .wallet import WalletService
18
+
19
+ return WalletService
20
+ raise AttributeError(name)
envarpay/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from envarpay.cli import main
2
+
3
+ main()
envarpay/backend.py ADDED
@@ -0,0 +1,55 @@
1
+ """Thin adapters to existing Agent services. No Agent is instantiated here."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mcp.types import CallToolResult, Tool
6
+
7
+ from .config import Backend
8
+ from .storage import PaymentError
9
+ from .transport import connect
10
+
11
+
12
+ class AgentBackend:
13
+ def __init__(self, config: Backend, timeout: int):
14
+ self.config, self.timeout = config, timeout
15
+
16
+ async def list_tools(self) -> list[Tool]:
17
+ if self.config.kind == "http":
18
+ from .runtime_http import RuntimeHTTP
19
+
20
+ RuntimeHTTP(self.config, self.timeout).credential()
21
+ return [
22
+ Tool(
23
+ name="ask_agent",
24
+ description="Ask the connected agent to perform a single task",
25
+ inputSchema={
26
+ "type": "object",
27
+ "properties": {
28
+ "question": {"type": "string", "minLength": 1, "maxLength": 32000}
29
+ },
30
+ "required": ["question"],
31
+ "additionalProperties": False,
32
+ },
33
+ )
34
+ ]
35
+ async with connect(self.config.upstream, self.timeout) as session:
36
+ tools, cursor = [], None
37
+ for _ in range(4):
38
+ page = await session.list_tools(cursor=cursor)
39
+ tools.extend(page.tools)
40
+ if len(tools) > 64:
41
+ raise PaymentError("Expose at most 64 selected capabilities")
42
+ cursor = page.nextCursor
43
+ if not cursor:
44
+ return tools
45
+ raise PaymentError("Capability discovery exceeded the page limit")
46
+
47
+ async def call(self, name: str, arguments: dict) -> CallToolResult:
48
+ if self.config.kind == "mcp":
49
+ async with connect(self.config.upstream, self.timeout) as session:
50
+ return await session.call_tool(name, arguments)
51
+ from .runtime_http import RuntimeHTTP
52
+
53
+ if name != "ask_agent":
54
+ raise PaymentError("Unknown Agent capability")
55
+ return await RuntimeHTTP(self.config, self.timeout).call(arguments["question"])
envarpay/chain.py ADDED
@@ -0,0 +1,128 @@
1
+ """Independent USDC receipt checks, including the authorization nonce."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from typing import Any
8
+
9
+ import httpx
10
+ from eth_utils import keccak
11
+
12
+ from .config import Config, address
13
+ from .storage import PaymentError
14
+
15
+
16
+ class Chain:
17
+ def __init__(self, config: Config):
18
+ self.config = config
19
+
20
+ async def rpc(self, method: str, params: list) -> Any:
21
+ async with httpx.AsyncClient(timeout=30) as client:
22
+ response = await client.post(
23
+ self.config.rpc_url,
24
+ json={
25
+ "jsonrpc": "2.0",
26
+ "id": 1,
27
+ "method": method,
28
+ "params": params,
29
+ },
30
+ )
31
+ response.raise_for_status()
32
+ data = response.json()
33
+ if "error" in data or "result" not in data:
34
+ raise PaymentError("RPC failed; preserve the original payment and inspect status")
35
+ return data["result"]
36
+
37
+ async def check_network(self) -> None:
38
+ if int(await self.rpc("eth_chainId", []), 16) != self.config.chain_id:
39
+ raise PaymentError("RPC chain does not match the configured network")
40
+
41
+ async def prove(self, tx: str, authorization: dict) -> dict:
42
+ await self.check_network()
43
+ if len(tx) != 66 or not tx.startswith("0x"):
44
+ raise PaymentError("Invalid settlement transaction hash")
45
+ end = time.monotonic() + self.config.timeout_seconds
46
+ while True:
47
+ receipt = await self.rpc("eth_getTransactionReceipt", [tx])
48
+ if receipt:
49
+ latest = int(await self.rpc("eth_blockNumber", []), 16)
50
+ if latest - int(receipt["blockNumber"], 16) + 1 >= self.config.confirmations:
51
+ break
52
+ if time.monotonic() >= end:
53
+ raise PaymentError("Receipt remains unconfirmed; do not sign again")
54
+ await asyncio.sleep(1)
55
+ block = await self.rpc("eth_getBlockByNumber", [receipt["blockNumber"], False])
56
+ log_index = self.validate_receipt(receipt, tx, authorization)
57
+ if not block or block["hash"].lower() != receipt["blockHash"].lower():
58
+ raise PaymentError("Receipt is not on the current canonical chain")
59
+ return {
60
+ "network": self.config.network,
61
+ "transaction": tx,
62
+ "asset": self.config.asset,
63
+ "payer": address(authorization["from"]),
64
+ "recipient": address(authorization["to"]),
65
+ "amount_atomic": str(authorization["value"]),
66
+ "nonce": authorization["nonce"],
67
+ "block_number": int(receipt["blockNumber"], 16),
68
+ "block_hash": receipt["blockHash"],
69
+ "log_index": log_index,
70
+ "confirmations": self.config.confirmations,
71
+ "verified_at": time.time(),
72
+ }
73
+
74
+ async def find_authorization(self, authorization: dict, from_block: int) -> str | None:
75
+ await self.check_network()
76
+ latest = int(await self.rpc("eth_blockNumber", []), 16)
77
+ if from_block < 0 or latest - from_block > 50000:
78
+ raise PaymentError("Original payment needs a bounded chain audit")
79
+ used = "0x" + keccak(text="AuthorizationUsed(address,bytes32)").hex()
80
+ payer = "0x" + address(authorization["from"])[2:].lower().zfill(64)
81
+ logs = await self.rpc(
82
+ "eth_getLogs",
83
+ [
84
+ {
85
+ "address": self.config.asset,
86
+ "fromBlock": hex(from_block),
87
+ "toBlock": hex(latest),
88
+ "topics": [used, payer, authorization["nonce"]],
89
+ }
90
+ ],
91
+ )
92
+ transactions = {log["transactionHash"] for log in logs if not log.get("removed")}
93
+ if len(transactions) > 1:
94
+ raise PaymentError("Ambiguous authorization history")
95
+ return next(iter(transactions), None)
96
+
97
+ def validate_receipt(self, receipt: dict, tx: str, authorization: dict) -> int:
98
+ return verify_usdc_receipt(receipt, tx, self.config.asset, authorization)
99
+
100
+
101
+ def verify_usdc_receipt(receipt: dict, tx: str, asset: str, authorization: dict) -> int:
102
+ """Verify transfer identity without constructing a wallet or reading a signing key."""
103
+ if int(receipt["status"], 16) != 1 or receipt["transactionHash"].lower() != tx.lower():
104
+ raise PaymentError("Transaction did not succeed")
105
+ payer = "0x" + address(authorization["from"])[2:].lower().zfill(64)
106
+ recipient = "0x" + address(authorization["to"])[2:].lower().zfill(64)
107
+ transfer = "0x" + keccak(text="Transfer(address,address,uint256)").hex()
108
+ used = "0x" + keccak(text="AuthorizationUsed(address,bytes32)").hex()
109
+ logs = [
110
+ log
111
+ for log in receipt["logs"]
112
+ if log["address"].lower() == asset.lower() and not log.get("removed")
113
+ ]
114
+ transfers = [
115
+ log
116
+ for log in logs
117
+ if [t.lower() for t in log["topics"]] == [transfer, payer, recipient]
118
+ and len(log["data"]) == 66
119
+ and int(log["data"], 16) == int(authorization["value"])
120
+ ]
121
+ nonces = [
122
+ log
123
+ for log in logs
124
+ if [t.lower() for t in log["topics"]] == [used, payer, authorization["nonce"].lower()]
125
+ ]
126
+ if len(transfers) != 1 or len(nonces) != 1:
127
+ raise PaymentError("Exact USDC Transfer and original AuthorizationUsed nonce required")
128
+ return int(transfers[0]["logIndex"], 16)
envarpay/cli.py ADDED
@@ -0,0 +1,315 @@
1
+ """Command-line setup, serving and read-only diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import sys
9
+ from importlib.metadata import version
10
+ from pathlib import Path
11
+
12
+ from eth_account import Account
13
+ from loguru import logger
14
+ from pydantic import ValidationError
15
+
16
+ from . import __version__
17
+ from .chain import Chain
18
+ from .config import load_config
19
+ from .setup import AGENTS, describe_config, initialize, write_new
20
+ from .storage import PaymentError, Store
21
+
22
+
23
+ def parser() -> argparse.ArgumentParser:
24
+ root = argparse.ArgumentParser(prog="envarpay", description="MCP + x402 agent payments")
25
+ root.add_argument("--version", action="version", version=__version__)
26
+ sub = root.add_subparsers(dest="command", required=True)
27
+ from .tasks.cli import add_parser
28
+
29
+ add_parser(sub)
30
+ init = sub.add_parser(
31
+ "init", help="Choose an agent and role; generate configs, wallet entry and setup guide"
32
+ )
33
+ init.add_argument(
34
+ "--directory", type=Path, default=Path("agent-pay"), help="New empty setup directory"
35
+ )
36
+ init.add_argument(
37
+ "--agent", choices=AGENTS, default="mcp", help="Your existing agent/framework"
38
+ )
39
+ init.add_argument(
40
+ "--role", choices=["buyer", "seller", "both"], help="Paid setup role (default: both)"
41
+ )
42
+ init.add_argument(
43
+ "--mode",
44
+ choices=["seller", "private"],
45
+ default="seller",
46
+ help="Private mode connects an authenticated service without payments",
47
+ )
48
+ init.add_argument(
49
+ "--pay-to",
50
+ help="Full receiving address: yours for seller/both, the seller's for buyer",
51
+ )
52
+ init.add_argument("--peer-pay-to", help="Other seller's full address when using --role both")
53
+ init.add_argument(
54
+ "--peer-url", default="http://127.0.0.1:4020/mcp", help="Paid seller MCP endpoint"
55
+ )
56
+ init.add_argument(
57
+ "--upstream", default="http://127.0.0.1:8000/mcp", help="Private MCP capability to sell"
58
+ )
59
+ init.add_argument("--tool", default="ask_agent", help="MCP tool to sell/allow")
60
+ amounts = init.add_argument_group("amounts in USDC (up to six decimals)")
61
+ amounts.add_argument(
62
+ "--price", default="0.01", help="Seller price per tool call (default: 0.01)"
63
+ )
64
+ amounts.add_argument(
65
+ "--max-per-call", default="0.01", help="Buyer per-call limit (default: 0.01)"
66
+ )
67
+ amounts.add_argument(
68
+ "--budget", default="0.01", help="Buyer cumulative limit, not daily (default: 0.01)"
69
+ )
70
+ init.add_argument(
71
+ "--allow-http",
72
+ action="store_true",
73
+ help="Allow remote plain HTTP for private container networks",
74
+ )
75
+ init.add_argument("--json", action="store_true", help="Print the setup summary as JSON")
76
+ init.add_argument(
77
+ "--backend",
78
+ choices=["mcp", "openclaw", "hermes-http"],
79
+ default="mcp",
80
+ help="Seller adapter; defaults to private MCP. HTTP presets are experimental.",
81
+ )
82
+ keygen = sub.add_parser(
83
+ "keygen", help="Create a new dedicated EVM key, displaying only its address"
84
+ )
85
+ keygen.add_argument("--output", type=Path, required=True)
86
+ for command in [
87
+ "serve",
88
+ "wallet",
89
+ "wallet-serve",
90
+ "sync",
91
+ "recover",
92
+ "call",
93
+ "probe",
94
+ "tools",
95
+ "status",
96
+ "reconcile",
97
+ "host-config",
98
+ "doctor",
99
+ ]:
100
+ item = sub.add_parser(command)
101
+ item.add_argument("--config", type=Path, required=True)
102
+ if command in ("call", "probe", "tools"):
103
+ item.add_argument("--peer", required=True)
104
+ if command in ("call", "probe"):
105
+ item.add_argument("--tool", required=True)
106
+ item.add_argument("--arguments", default="{}", help="JSON object")
107
+ if command == "call":
108
+ item.add_argument(
109
+ "--request-id", required=True, help="Stable ID; reuse for the same request"
110
+ )
111
+ if command == "recover":
112
+ item.add_argument("--request-id", required=True)
113
+ if command == "sync":
114
+ item.add_argument("--watch", action="store_true")
115
+ if command in ("status", "reconcile"):
116
+ item.add_argument("--operation-id", required=command == "reconcile")
117
+ if command == "host-config":
118
+ item.add_argument(
119
+ "--host", choices=["hermes", "openclaw", "opencode", "goose"], required=True
120
+ )
121
+ if command == "doctor":
122
+ item.add_argument("--online", action="store_true", help="Also read RPC chain ID")
123
+ return root
124
+
125
+
126
+ async def read_or_call(args: argparse.Namespace) -> dict | list:
127
+ from x402 import x402Client
128
+ from x402.mcp.client import x402MCPSession
129
+
130
+ from .transport import connect
131
+ from .wallet import WalletService
132
+
133
+ config = load_config(args.config)
134
+ if args.command == "sync":
135
+ from .directory import Envar
136
+
137
+ if not config.connection:
138
+ raise PaymentError("Configure [connection] before synchronizing reports")
139
+ envar = Envar(config.connection, Store(config.state_dir))
140
+ while True:
141
+ result = await envar.flush()
142
+ if not args.watch:
143
+ return result
144
+ await asyncio.sleep(5)
145
+ if args.command == "recover":
146
+ return await WalletService(config).recover(args.request_id)
147
+ if args.command == "doctor":
148
+ endpoint = config.seller or config.service
149
+ if endpoint and endpoint.backend.kind == "http":
150
+ from .runtime_http import RuntimeHTTP
151
+
152
+ RuntimeHTTP(endpoint.backend, config.timeout_seconds).credential()
153
+ if config.service:
154
+ from .service import AgentService
155
+
156
+ AgentService(config)
157
+ rpc_checked = bool(args.online and (config.seller or config.wallet))
158
+ if rpc_checked:
159
+ await Chain(config).check_network()
160
+ if args.online and endpoint:
161
+ from .backend import AgentBackend
162
+
163
+ await AgentBackend(endpoint.backend, config.timeout_seconds).list_tools()
164
+ return {
165
+ "valid": True,
166
+ **describe_config(config),
167
+ "rpc_checked": rpc_checked,
168
+ "versions": {name: version(name) for name in ("envarpay", "x402", "mcp")},
169
+ }
170
+ if args.command in ("status", "reconcile"):
171
+ store = Store(config.state_dir)
172
+ if args.command == "status":
173
+ return store.public_status(args.operation_id)
174
+ row = store.get(args.operation_id)
175
+ if not row or not row["data"].get("transaction") or not row["data"].get("payload"):
176
+ raise PaymentError(
177
+ "No recorded transaction to reconcile; this command never resubmits payment"
178
+ )
179
+ return await Chain(config).prove(
180
+ row["data"]["transaction"], row["data"]["payload"]["payload"]["authorization"]
181
+ )
182
+ service = WalletService(config)
183
+ if args.command == "tools":
184
+ return await service.list_tools(args.peer)
185
+ arguments = json.loads(args.arguments)
186
+ if not isinstance(arguments, dict):
187
+ raise PaymentError("--arguments must be a JSON object")
188
+ if args.command == "call":
189
+ return await service.call(args.peer, args.tool, arguments, args.request_id)
190
+ peer = service.peer(args.peer, args.tool)
191
+ async with connect(peer, config.timeout_seconds) as session:
192
+ result = await x402MCPSession(session, x402Client(), auto_payment=False).call_tool(
193
+ args.tool, arguments
194
+ )
195
+ return {
196
+ "payment_made": False,
197
+ "result": result.raw_result.model_dump(mode="json", by_alias=True),
198
+ }
199
+
200
+
201
+ def main() -> None:
202
+ logger.remove()
203
+ logger.add(sys.stderr, level="WARNING", format="{level}: {message}")
204
+ args = parser().parse_args()
205
+ try:
206
+ if args.command == "task":
207
+ from .tasks.cli import run
208
+
209
+ run(args)
210
+ return
211
+ if args.command == "init":
212
+ result = initialize(
213
+ args.directory,
214
+ args.pay_to,
215
+ args.backend,
216
+ mode=args.mode,
217
+ agent=args.agent,
218
+ role=args.role,
219
+ upstream=args.upstream,
220
+ peer_url=args.peer_url,
221
+ peer_pay_to=args.peer_pay_to,
222
+ tool=args.tool,
223
+ price=args.price,
224
+ max_per_call=args.max_per_call,
225
+ budget=args.budget,
226
+ allow_http=args.allow_http,
227
+ )
228
+ if not args.json:
229
+ print(f"EnvarPay · {result['agent']} · {result['role']}")
230
+ print(f"Network: {result['network']} | Buyer payments: OFF")
231
+ if result["buyer"]:
232
+ buyer = result["buyer"]
233
+ print(f"\nBuy from: {buyer['peer_url']}")
234
+ print(f" Recipient: {buyer['recipient']}")
235
+ print(f" Tool: {buyer['tool']}")
236
+ print(
237
+ f" Limit: {buyer['max_per_call_usdc']} USDC/call; "
238
+ f"{buyer['budget_usdc']} USDC cumulative"
239
+ )
240
+ if result["seller"]:
241
+ seller = result["seller"]
242
+ print(f"\nSell: {seller['tool']} for {seller['price_usdc']} USDC/call")
243
+ print(f" Receive at: {seller['recipient']}")
244
+ print(f" Private upstream: {seller['upstream']} ({seller['backend']})")
245
+ print("\nCreated in " + result["directory"] + ": " + ", ".join(result["files"]))
246
+ print("\nNext:")
247
+ for index, step in enumerate(result["next_steps"], 1):
248
+ print(f" {index}. {step}")
249
+ print("\nGuide: " + result["guide"])
250
+ return
251
+ elif args.command == "keygen":
252
+ account = Account.create()
253
+ write_new(args.output.expanduser(), account.key.hex() + "\n")
254
+ result = {
255
+ "address": account.address,
256
+ "key_file": str(args.output),
257
+ "private_key_printed": False,
258
+ }
259
+ elif args.command == "host-config":
260
+ from .host_config import host_config
261
+
262
+ result = host_config(args.host, args.config)
263
+ elif args.command == "wallet":
264
+ from .wallet import WalletService, wallet_mcp
265
+
266
+ wallet_mcp(WalletService(load_config(args.config))).run(transport="stdio")
267
+ return
268
+ elif args.command == "wallet-serve":
269
+ import uvicorn
270
+
271
+ from .wallet import WalletService, wallet_app
272
+
273
+ config = load_config(args.config)
274
+ app = wallet_app(WalletService(config))
275
+ uvicorn.run(
276
+ app,
277
+ host=config.wallet_server.host,
278
+ port=config.wallet_server.port,
279
+ log_level="warning",
280
+ )
281
+ return
282
+ elif args.command == "serve":
283
+ import uvicorn
284
+
285
+ from .seller import PaidServer
286
+ from .service import AgentService
287
+
288
+ config = load_config(args.config)
289
+ service = PaidServer(config) if config.seller else AgentService(config)
290
+ uvicorn.run(
291
+ service.app(config_path=args.config) if config.seller else service.app(),
292
+ host=service.policy.host,
293
+ port=service.policy.port,
294
+ log_level="warning",
295
+ )
296
+ return
297
+ else:
298
+ result = asyncio.run(read_or_call(args))
299
+ print(json.dumps(result, ensure_ascii=False, indent=2))
300
+ except (PaymentError, FileExistsError, FileNotFoundError) as error:
301
+ print(f"envarpay: {error}", file=sys.stderr)
302
+ raise SystemExit(1) from None
303
+ except ValidationError as error:
304
+ print(
305
+ "envarpay: invalid configuration: "
306
+ + "; ".join(
307
+ f"{'.'.join(map(str, e['loc']))}: {e['msg']}"
308
+ for e in error.errors(include_input=False)
309
+ ),
310
+ file=sys.stderr,
311
+ )
312
+ raise SystemExit(1) from None
313
+ except Exception as error:
314
+ print(f"envarpay: {type(error).__name__}; operation did not complete", file=sys.stderr)
315
+ raise SystemExit(1) from None