apay 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apay/__init__.py +44 -0
- apay/cli.py +216 -0
- apay/client.py +146 -0
- apay/integrations/__init__.py +15 -0
- apay/integrations/crewai.py +92 -0
- apay/integrations/langchain.py +137 -0
- apay/proxy.py +258 -0
- apay/relayer.py +215 -0
- apay/wallet.py +136 -0
- apay-0.1.0.dist-info/METADATA +119 -0
- apay-0.1.0.dist-info/RECORD +13 -0
- apay-0.1.0.dist-info/WHEEL +4 -0
- apay-0.1.0.dist-info/entry_points.txt +2 -0
apay/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A-Pay (AgentPay) Protocol
|
|
3
|
+
Universal Micropayment & Economic Layer for Autonomous AI Agents.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .client import APayClient, APaySession
|
|
7
|
+
from .integrations import (
|
|
8
|
+
APayCrewTool,
|
|
9
|
+
APayRequestsWrapper,
|
|
10
|
+
APayTool,
|
|
11
|
+
create_apay_tool,
|
|
12
|
+
create_crew_tool,
|
|
13
|
+
)
|
|
14
|
+
from .proxy import create_proxy_app
|
|
15
|
+
from .relayer import ClaimJournal, ClaimRecord, SettlementRelayer
|
|
16
|
+
from .wallet import (
|
|
17
|
+
DEFAULT_CHAIN_ID,
|
|
18
|
+
DEFAULT_EIP712_NAME,
|
|
19
|
+
DEFAULT_EIP712_VERSION,
|
|
20
|
+
DEFAULT_ROUTER_ADDRESS,
|
|
21
|
+
AgentWallet,
|
|
22
|
+
get_eip712_domain,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"APayClient",
|
|
27
|
+
"APayCrewTool",
|
|
28
|
+
"APayRequestsWrapper",
|
|
29
|
+
"APaySession",
|
|
30
|
+
"APayTool",
|
|
31
|
+
"AgentWallet",
|
|
32
|
+
"ClaimJournal",
|
|
33
|
+
"ClaimRecord",
|
|
34
|
+
"DEFAULT_CHAIN_ID",
|
|
35
|
+
"DEFAULT_EIP712_NAME",
|
|
36
|
+
"DEFAULT_EIP712_VERSION",
|
|
37
|
+
"DEFAULT_ROUTER_ADDRESS",
|
|
38
|
+
"SettlementRelayer",
|
|
39
|
+
"create_apay_tool",
|
|
40
|
+
"create_crew_tool",
|
|
41
|
+
"create_proxy_app",
|
|
42
|
+
"get_eip712_domain",
|
|
43
|
+
]
|
|
44
|
+
__version__ = "0.1.0"
|
apay/cli.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""A-Pay CLI: 1-Command Tooling for Website Owners & AI Agent Builders."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import httpx
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
from .client import APaySession
|
|
10
|
+
from .proxy import create_proxy_app
|
|
11
|
+
from .relayer import ClaimJournal, SettlementRelayer
|
|
12
|
+
from .wallet import AgentWallet
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
@click.version_option("0.1.0", prog_name="apay")
|
|
17
|
+
def main() -> None:
|
|
18
|
+
"""⚡ A-Pay: Universal HTTP 402 Micropayment Rail for AI Agents & APIs."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@main.command()
|
|
23
|
+
@click.option(
|
|
24
|
+
"--target", "-t", required=True, help="Upstream API/website URL to protect (e.g. http://localhost:8000)"
|
|
25
|
+
)
|
|
26
|
+
@click.option("--price", "-p", required=True, type=float, help="Price per request in USDC (e.g. 0.001)")
|
|
27
|
+
@click.option("--wallet", "-w", required=True, help="Receiver wallet address to collect funds (0x...)")
|
|
28
|
+
@click.option("--port", default=8080, type=int, help="Port to run the A-Pay Gateway on (default: 8080)")
|
|
29
|
+
@click.option("--host", default="0.0.0.0", help="Host to bind gateway (default: 0.0.0.0)")
|
|
30
|
+
@click.option("--network", default="base", help="Settlement network (default: base)")
|
|
31
|
+
def protect(target: str, price: float, wallet: str, port: int, host: str, network: str) -> None:
|
|
32
|
+
"""🛡️ Protect ANY website or API with an instant 1-command HTTP 402 Paywall."""
|
|
33
|
+
click.echo(click.style("==================================================", fg="cyan", bold=True))
|
|
34
|
+
click.echo(click.style("⚡ A-PAY GATEWAY IS ACTIVE", fg="green", bold=True))
|
|
35
|
+
click.echo(click.style("==================================================", fg="cyan", bold=True))
|
|
36
|
+
click.echo(f" Target URL : {target}")
|
|
37
|
+
click.echo(f" Paywall Price : ${price} USDC / request")
|
|
38
|
+
click.echo(f" Receiver Wallet : {wallet}")
|
|
39
|
+
click.echo(f" Settlement Rail : {network}")
|
|
40
|
+
click.echo(f" Gateway Running : http://{host}:{port}")
|
|
41
|
+
click.echo(click.style("\nIncoming AI bots will be challenged with HTTP 402 unless paid.\n", fg="yellow"))
|
|
42
|
+
|
|
43
|
+
app = create_proxy_app(target_url=target, receiver_wallet=wallet, price_usdc=price, network=network)
|
|
44
|
+
uvicorn.run(app, host=host, port=port, log_level="info")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@main.group()
|
|
48
|
+
def wallet() -> None:
|
|
49
|
+
"""🔐 Manage Agent Wallets & Cryptographic Keys."""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@wallet.command("new")
|
|
54
|
+
@click.option("--max-call", default=0.05, type=float, help="Max spend limit per API call in USDC")
|
|
55
|
+
@click.option("--daily-budget", default=5.0, type=float, help="Max total daily budget in USDC")
|
|
56
|
+
def wallet_new(max_call: float, daily_budget: float) -> None:
|
|
57
|
+
"""Generate a new cryptographic Agent Wallet."""
|
|
58
|
+
w = AgentWallet.create(max_per_call_usdc=max_call, daily_budget_usdc=daily_budget)
|
|
59
|
+
click.echo(click.style("\n✨ New Agent Wallet Generated Successfully!\n", fg="green", bold=True))
|
|
60
|
+
click.echo(f" Address : {click.style(w.address, fg='cyan', bold=True)}")
|
|
61
|
+
click.echo(f" Private Key : {click.style(w.private_key, fg='red')}")
|
|
62
|
+
click.echo(f" Max Per Call : ${w.max_per_call_usdc} USDC")
|
|
63
|
+
click.echo(f" Daily Budget : ${w.daily_budget_usdc} USDC")
|
|
64
|
+
click.echo(
|
|
65
|
+
click.style("\n⚠️ Keep the private key safe. Pass it to APaySession(private_key=...)\n", fg="yellow")
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@main.command()
|
|
70
|
+
@click.option("--url", "-u", required=True, help="URL of the protected API endpoint")
|
|
71
|
+
@click.option("--key", "-k", required=True, help="Agent's private key (0x...)")
|
|
72
|
+
@click.option("--budget", "-b", default=5.0, type=float, help="Daily budget limit in USDC")
|
|
73
|
+
def pay(url: str, key: str, budget: float) -> None:
|
|
74
|
+
"""🤖 Make a test auto-settling request to an A-Pay protected URL."""
|
|
75
|
+
click.echo(f"Contacting {url} with agent wallet...")
|
|
76
|
+
agent_wallet = AgentWallet(private_key=key, daily_budget_usdc=budget)
|
|
77
|
+
session = APaySession(agent_wallet)
|
|
78
|
+
try:
|
|
79
|
+
resp = session.get(url)
|
|
80
|
+
click.echo(click.style(f"\n✅ Status: {resp.status_code}", fg="green", bold=True))
|
|
81
|
+
click.echo(f"X-APay-Settled: {resp.headers.get('X-APay-Settled')}")
|
|
82
|
+
click.echo(f"Response Body : {resp.text[:300]}")
|
|
83
|
+
except Exception as e:
|
|
84
|
+
click.echo(click.style(f"❌ Error: {e!s}", fg="red"))
|
|
85
|
+
finally:
|
|
86
|
+
session.close()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@main.group()
|
|
90
|
+
def claims() -> None:
|
|
91
|
+
"""📊 Inspect & manage offline verified micropayment claims."""
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@claims.command("list")
|
|
96
|
+
def claims_list() -> None:
|
|
97
|
+
"""List all collected claims waiting for on-chain settlement."""
|
|
98
|
+
journal = ClaimJournal()
|
|
99
|
+
all_claims = journal.load_all()
|
|
100
|
+
unsettled = [c for c in all_claims if not c.settled]
|
|
101
|
+
total_unsettled_usdc = sum(c.price_usdc for c in unsettled)
|
|
102
|
+
|
|
103
|
+
click.echo(click.style("\n📜 A-Pay Claims Ledger Summary:\n", fg="cyan", bold=True))
|
|
104
|
+
click.echo(f" Total Claims Recorded : {len(all_claims)}")
|
|
105
|
+
click.echo(f" Unsettled Claims : {len(unsettled)}")
|
|
106
|
+
click.echo(f" Pending Gross Value : ${total_unsettled_usdc:.4f} USDC")
|
|
107
|
+
|
|
108
|
+
if unsettled:
|
|
109
|
+
click.echo("\n Recent Pending Claims:")
|
|
110
|
+
for c in unsettled[-5:]:
|
|
111
|
+
click.echo(
|
|
112
|
+
f" • [{c.id}] Payer: {c.payer[:10]}... | Amount: ${c.price_usdc:.4f} USDC | Nonce: {c.nonce[:8]}..."
|
|
113
|
+
)
|
|
114
|
+
click.echo(
|
|
115
|
+
click.style("\nRun `apay settle --dry-run` to preview batch settlement breakdown.\n", fg="yellow")
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
click.echo(click.style("\n✨ All claims are settled or ledger is clean.\n", fg="green"))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@claims.command("clear")
|
|
122
|
+
def claims_clear() -> None:
|
|
123
|
+
"""Clear claims journal."""
|
|
124
|
+
journal = ClaimJournal()
|
|
125
|
+
journal.clear()
|
|
126
|
+
click.echo(click.style("✅ Claims ledger cleared successfully.", fg="green"))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@main.command()
|
|
130
|
+
@click.option("--key", "-k", help="Merchant / Relayer private key to sign on-chain settlement")
|
|
131
|
+
@click.option("--dry-run", is_flag=True, help="Simulate batch settlement without broadcasting on-chain")
|
|
132
|
+
def settle(key: str | None, dry_run: bool) -> None:
|
|
133
|
+
"""⚡ Batch settle pending micropayment claims onto Base L2 APayRouter."""
|
|
134
|
+
relayer = SettlementRelayer()
|
|
135
|
+
unsettled = relayer.journal.get_unsettled()
|
|
136
|
+
|
|
137
|
+
if not unsettled:
|
|
138
|
+
click.echo(click.style("\n✨ No unsettled claims found in ledger.", fg="yellow"))
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
simulation = relayer.simulate_batch(unsettled)
|
|
142
|
+
|
|
143
|
+
click.echo(click.style("\n==================================================", fg="cyan", bold=True))
|
|
144
|
+
click.echo(click.style("⚡ A-PAY BATCH SETTLEMENT ENGINE", fg="green", bold=True))
|
|
145
|
+
click.echo(click.style("==================================================", fg="cyan", bold=True))
|
|
146
|
+
click.echo(f" Settlement Rail : {simulation['settlement_rail']}")
|
|
147
|
+
click.echo(f" Target Claims Count : {simulation['claims_count']}")
|
|
148
|
+
click.echo(f" Gross Volume : ${simulation['gross_volume_usdc']:.4f} USDC")
|
|
149
|
+
click.echo(f" Protocol Fee (1.0%) : ${simulation['protocol_fee_usdc']:.6f} USDC")
|
|
150
|
+
click.echo(f" Net Merchant Payout : ${simulation['net_merchant_usdc']:.4f} USDC")
|
|
151
|
+
click.echo(f" Router Contract : {simulation['router_contract']}")
|
|
152
|
+
|
|
153
|
+
if dry_run or not key:
|
|
154
|
+
click.echo(
|
|
155
|
+
click.style(
|
|
156
|
+
"\nℹ️ Dry-run mode completed. Pass `--key 0x...` to broadcast live to Base Sepolia.\n",
|
|
157
|
+
fg="yellow",
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
click.echo("\n🚀 Broadcasting batch settlement to Base L2...")
|
|
163
|
+
result = relayer.execute_settle(private_key=key, dry_run=False)
|
|
164
|
+
click.echo(
|
|
165
|
+
click.style(f"✅ Batch Settled Successfully! Status: {result['status']}", fg="green", bold=True)
|
|
166
|
+
)
|
|
167
|
+
click.echo(f" Transaction Hash : {result['tx_hash']}")
|
|
168
|
+
click.echo(f" Basescan Explorer : {result['explorer_url']}\n")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@main.command()
|
|
172
|
+
def verify() -> None:
|
|
173
|
+
"""🔬 Multi-dimensional system, cryptographic benchmark & RPC health check."""
|
|
174
|
+
click.echo(
|
|
175
|
+
click.style("\n⚡ Running A-Pay Multi-Dimensional System Health Check...\n", fg="cyan", bold=True)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# 1. Benchmark Cryptographic Signing
|
|
179
|
+
w = AgentWallet.create()
|
|
180
|
+
t0 = time.perf_counter()
|
|
181
|
+
iterations = 50
|
|
182
|
+
for i in range(iterations):
|
|
183
|
+
sig = w.sign_challenge(f"APAY:0x5eC0:1000:{i}")
|
|
184
|
+
AgentWallet.verify_signature(f"APAY:0x5eC0:1000:{i}", sig)
|
|
185
|
+
avg_latency_ms = ((time.perf_counter() - t0) / iterations) * 1000.0
|
|
186
|
+
|
|
187
|
+
click.echo(f" 🔐 Cryptographic Sign & Verify Latency : {avg_latency_ms:.2f} ms / op (Target: < 20ms)")
|
|
188
|
+
if avg_latency_ms < 20.0:
|
|
189
|
+
click.echo(click.style(" -> PASS: Ultra-low latency machine-to-machine verified.", fg="green"))
|
|
190
|
+
else:
|
|
191
|
+
click.echo(click.style(" -> WARN: Higher than expected latency.", fg="yellow"))
|
|
192
|
+
|
|
193
|
+
# 2. Check RPC Node Connectivity
|
|
194
|
+
rpc_url = "https://sepolia.base.org"
|
|
195
|
+
try:
|
|
196
|
+
with httpx.Client(timeout=5.0) as client:
|
|
197
|
+
resp = client.post(
|
|
198
|
+
rpc_url, json={"jsonrpc": "2.0", "method": "eth_blockNumber", "params": [], "id": 1}
|
|
199
|
+
)
|
|
200
|
+
if resp.status_code == 200 and "result" in resp.json():
|
|
201
|
+
block_hex = resp.json()["result"]
|
|
202
|
+
block_num = int(block_hex, 16)
|
|
203
|
+
click.echo(f" 🌐 Base Sepolia RPC Connectivity : Connected (Block #{block_num:,})")
|
|
204
|
+
click.echo(click.style(" -> PASS: Live on-chain rail active.", fg="green"))
|
|
205
|
+
else:
|
|
206
|
+
click.echo(click.style(" -> WARN: RPC node returned non-standard response.", fg="yellow"))
|
|
207
|
+
except Exception as err:
|
|
208
|
+
click.echo(click.style(f" 🌐 Base Sepolia RPC Error : {err!s}", fg="yellow"))
|
|
209
|
+
|
|
210
|
+
click.echo(
|
|
211
|
+
click.style("\n✅ A-Pay Protocol Core is operating at 100% capacity.\n", fg="green", bold=True)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
main()
|
apay/client.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Async & Sync HTTP Clients with Automatic HTTP 402 Micropayment Negotiation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .wallet import DEFAULT_CHAIN_ID, DEFAULT_ROUTER_ADDRESS, AgentWallet
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class APayClient:
|
|
11
|
+
"""Asynchronous HTTP Client for AI Agents with auto-settling 402 handling."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, wallet: AgentWallet, timeout: float = 30.0, use_eip712: bool = True) -> None:
|
|
14
|
+
self.wallet = wallet
|
|
15
|
+
self.client = httpx.AsyncClient(timeout=timeout)
|
|
16
|
+
self.use_eip712 = use_eip712
|
|
17
|
+
|
|
18
|
+
async def get(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
19
|
+
return await self._request("GET", url, **kwargs)
|
|
20
|
+
|
|
21
|
+
async def post(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
22
|
+
return await self._request("POST", url, **kwargs)
|
|
23
|
+
|
|
24
|
+
async def _request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
|
|
25
|
+
# 1. Initial attempt
|
|
26
|
+
response = await self.client.request(method, url, **kwargs)
|
|
27
|
+
|
|
28
|
+
# 2. Intercept HTTP 402 Payment Required
|
|
29
|
+
if response.status_code == 402:
|
|
30
|
+
return await self._handle_402_and_retry(method, url, response, **kwargs)
|
|
31
|
+
|
|
32
|
+
return response
|
|
33
|
+
|
|
34
|
+
async def _handle_402_and_retry(
|
|
35
|
+
self, method: str, url: str, initial_response: httpx.Response, **kwargs: Any
|
|
36
|
+
) -> httpx.Response:
|
|
37
|
+
headers = initial_response.headers
|
|
38
|
+
raw_amount = headers.get("X-APay-Amount", "0")
|
|
39
|
+
try:
|
|
40
|
+
price = float(raw_amount) / 1_000_000.0 # 6 decimals for USDC
|
|
41
|
+
except (ValueError, TypeError):
|
|
42
|
+
return initial_response
|
|
43
|
+
receiver = headers.get("X-APay-Receiver")
|
|
44
|
+
nonce = headers.get("X-APay-Nonce")
|
|
45
|
+
|
|
46
|
+
if not receiver or not nonce:
|
|
47
|
+
# Not a standard APay 402 response, return original response
|
|
48
|
+
return initial_response
|
|
49
|
+
|
|
50
|
+
# Check local wallet budget guardrails
|
|
51
|
+
can_pay, reason = self.wallet.can_spend(price)
|
|
52
|
+
if not can_pay:
|
|
53
|
+
raise PermissionError(f"[A-Pay Guardrail Triggered] Cannot pay ${price} USDC: {reason}")
|
|
54
|
+
|
|
55
|
+
scheme = headers.get("X-APay-Scheme", "eip712" if self.use_eip712 else "legacy")
|
|
56
|
+
chain_id = int(headers.get("X-APay-Chain-Id", str(DEFAULT_CHAIN_ID)))
|
|
57
|
+
router = headers.get("X-APay-Router", DEFAULT_ROUTER_ADDRESS)
|
|
58
|
+
|
|
59
|
+
if self.use_eip712 and scheme == "eip712":
|
|
60
|
+
signature = self.wallet.sign_typed_payment(
|
|
61
|
+
receiver=receiver,
|
|
62
|
+
amount_base_units=raw_amount,
|
|
63
|
+
nonce=nonce,
|
|
64
|
+
chain_id=chain_id,
|
|
65
|
+
router_address=router,
|
|
66
|
+
)
|
|
67
|
+
auth_header = f"APay eip712:{signature}:{self.wallet.address}:{nonce}:{raw_amount}"
|
|
68
|
+
else:
|
|
69
|
+
payload = f"APAY:{receiver}:{raw_amount}:{nonce}"
|
|
70
|
+
signature = self.wallet.sign_challenge(payload)
|
|
71
|
+
auth_header = f"APay sig:{signature}:{self.wallet.address}:{nonce}:{raw_amount}"
|
|
72
|
+
|
|
73
|
+
req_headers = dict(kwargs.get("headers") or {})
|
|
74
|
+
req_headers["Authorization"] = auth_header
|
|
75
|
+
kwargs["headers"] = req_headers
|
|
76
|
+
|
|
77
|
+
self.wallet.record_spend(price)
|
|
78
|
+
return await self.client.request(method, url, **kwargs)
|
|
79
|
+
|
|
80
|
+
async def aclose(self) -> None:
|
|
81
|
+
await self.client.aclose()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class APaySession:
|
|
85
|
+
"""Synchronous HTTP Session for simple Python scripts, LangChain, and CrewAI."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, wallet: AgentWallet, timeout: float = 30.0, use_eip712: bool = True) -> None:
|
|
88
|
+
self.wallet = wallet
|
|
89
|
+
self.client = httpx.Client(timeout=timeout)
|
|
90
|
+
self.use_eip712 = use_eip712
|
|
91
|
+
|
|
92
|
+
def get(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
93
|
+
return self._request("GET", url, **kwargs)
|
|
94
|
+
|
|
95
|
+
def post(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
96
|
+
return self._request("POST", url, **kwargs)
|
|
97
|
+
|
|
98
|
+
def _request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
|
|
99
|
+
response = self.client.request(method, url, **kwargs)
|
|
100
|
+
|
|
101
|
+
if response.status_code == 402:
|
|
102
|
+
headers = response.headers
|
|
103
|
+
raw_amount = headers.get("X-APay-Amount", "0")
|
|
104
|
+
try:
|
|
105
|
+
price = float(raw_amount) / 1_000_000.0
|
|
106
|
+
except (ValueError, TypeError):
|
|
107
|
+
return response
|
|
108
|
+
receiver = headers.get("X-APay-Receiver")
|
|
109
|
+
nonce = headers.get("X-APay-Nonce")
|
|
110
|
+
|
|
111
|
+
if not receiver or not nonce:
|
|
112
|
+
return response
|
|
113
|
+
|
|
114
|
+
can_pay, reason = self.wallet.can_spend(price)
|
|
115
|
+
if not can_pay:
|
|
116
|
+
raise PermissionError(f"[A-Pay Guardrail Triggered] Cannot pay ${price} USDC: {reason}")
|
|
117
|
+
|
|
118
|
+
scheme = headers.get("X-APay-Scheme", "eip712" if self.use_eip712 else "legacy")
|
|
119
|
+
chain_id = int(headers.get("X-APay-Chain-Id", str(DEFAULT_CHAIN_ID)))
|
|
120
|
+
router = headers.get("X-APay-Router", DEFAULT_ROUTER_ADDRESS)
|
|
121
|
+
|
|
122
|
+
if self.use_eip712 and scheme == "eip712":
|
|
123
|
+
signature = self.wallet.sign_typed_payment(
|
|
124
|
+
receiver=receiver,
|
|
125
|
+
amount_base_units=raw_amount,
|
|
126
|
+
nonce=nonce,
|
|
127
|
+
chain_id=chain_id,
|
|
128
|
+
router_address=router,
|
|
129
|
+
)
|
|
130
|
+
auth_header = f"APay eip712:{signature}:{self.wallet.address}:{nonce}:{raw_amount}"
|
|
131
|
+
else:
|
|
132
|
+
payload = f"APAY:{receiver}:{raw_amount}:{nonce}"
|
|
133
|
+
signature = self.wallet.sign_challenge(payload)
|
|
134
|
+
auth_header = f"APay sig:{signature}:{self.wallet.address}:{nonce}:{raw_amount}"
|
|
135
|
+
|
|
136
|
+
req_headers = dict(kwargs.get("headers") or {})
|
|
137
|
+
req_headers["Authorization"] = auth_header
|
|
138
|
+
kwargs["headers"] = req_headers
|
|
139
|
+
|
|
140
|
+
self.wallet.record_spend(price)
|
|
141
|
+
return self.client.request(method, url, **kwargs)
|
|
142
|
+
|
|
143
|
+
return response
|
|
144
|
+
|
|
145
|
+
def close(self) -> None:
|
|
146
|
+
self.client.close()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""A-Pay Agent Framework Integrations.
|
|
2
|
+
|
|
3
|
+
Provides integrations with LangChain, CrewAI, and other autonomous agent ecosystems.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .crewai import APayCrewTool, create_crew_tool
|
|
7
|
+
from .langchain import APayRequestsWrapper, APayTool, create_apay_tool
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"APayCrewTool",
|
|
11
|
+
"APayRequestsWrapper",
|
|
12
|
+
"APayTool",
|
|
13
|
+
"create_apay_tool",
|
|
14
|
+
"create_crew_tool",
|
|
15
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""CrewAI Integration for A-Pay Protocol.
|
|
2
|
+
|
|
3
|
+
Enables CrewAI autonomous agents to seamlessly execute HTTP 402 micropayments
|
|
4
|
+
when accessing restricted data feeds or services.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
from apay.client import APayClient, APaySession
|
|
12
|
+
from apay.wallet import AgentWallet
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from crewai.tools import BaseTool as CrewAIBaseTool
|
|
16
|
+
except ImportError: # pragma: no cover
|
|
17
|
+
CrewAIBaseTool = None # type: ignore[assignment, misc]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class APayCrewToolInput(BaseModel):
|
|
21
|
+
"""Input arguments schema for CrewAI tool execution."""
|
|
22
|
+
|
|
23
|
+
query: str = Field(default="", description="Input or parameters passed to the paid API endpoint")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _BaseCrewToolImpl(BaseModel):
|
|
27
|
+
name: str
|
|
28
|
+
description: str
|
|
29
|
+
target_url: str
|
|
30
|
+
wallet: AgentWallet
|
|
31
|
+
method: str = "GET"
|
|
32
|
+
use_eip712: bool = True
|
|
33
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
34
|
+
|
|
35
|
+
def _run(self, *args: Any, **kwargs: Any) -> str:
|
|
36
|
+
"""Synchronously call API with auto-negotiating HTTP 402 payments."""
|
|
37
|
+
query = str(kwargs.get("query", args[0] if args else ""))
|
|
38
|
+
session = APaySession(wallet=self.wallet, use_eip712=self.use_eip712)
|
|
39
|
+
try:
|
|
40
|
+
if self.method.upper() == "GET":
|
|
41
|
+
params = {"q": query} if query else {}
|
|
42
|
+
resp = session.get(self.target_url, params=params)
|
|
43
|
+
else:
|
|
44
|
+
payload = {"query": query} if query else {}
|
|
45
|
+
resp = session.post(self.target_url, json=payload)
|
|
46
|
+
return resp.text
|
|
47
|
+
finally:
|
|
48
|
+
session.close()
|
|
49
|
+
|
|
50
|
+
async def _arun(self, *args: Any, **kwargs: Any) -> str:
|
|
51
|
+
"""Asynchronously call API with auto-negotiating HTTP 402 payments."""
|
|
52
|
+
query = str(kwargs.get("query", args[0] if args else ""))
|
|
53
|
+
client = APayClient(wallet=self.wallet, use_eip712=self.use_eip712)
|
|
54
|
+
try:
|
|
55
|
+
if self.method.upper() == "GET":
|
|
56
|
+
params = {"q": query} if query else {}
|
|
57
|
+
resp = await client.get(self.target_url, params=params)
|
|
58
|
+
else:
|
|
59
|
+
payload = {"query": query} if query else {}
|
|
60
|
+
resp = await client.post(self.target_url, json=payload)
|
|
61
|
+
return resp.text
|
|
62
|
+
finally:
|
|
63
|
+
await client.aclose()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if CrewAIBaseTool is not None: # pragma: no cover
|
|
67
|
+
|
|
68
|
+
class APayCrewTool(_BaseCrewToolImpl, CrewAIBaseTool): # type: ignore[misc]
|
|
69
|
+
args_schema: type[BaseModel] = APayCrewToolInput
|
|
70
|
+
else:
|
|
71
|
+
|
|
72
|
+
class APayCrewTool(_BaseCrewToolImpl): # type: ignore[no-redef]
|
|
73
|
+
args_schema: type[BaseModel] = APayCrewToolInput
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def create_crew_tool(
|
|
77
|
+
name: str,
|
|
78
|
+
description: str,
|
|
79
|
+
target_url: str,
|
|
80
|
+
wallet: AgentWallet,
|
|
81
|
+
method: str = "GET",
|
|
82
|
+
use_eip712: bool = True,
|
|
83
|
+
) -> APayCrewTool:
|
|
84
|
+
"""Factory helper to equip a CrewAI Agent with an A-Pay micropayment tool."""
|
|
85
|
+
return APayCrewTool(
|
|
86
|
+
name=name,
|
|
87
|
+
description=description,
|
|
88
|
+
target_url=target_url,
|
|
89
|
+
wallet=wallet,
|
|
90
|
+
method=method,
|
|
91
|
+
use_eip712=use_eip712,
|
|
92
|
+
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""LangChain Integration for A-Pay Protocol.
|
|
2
|
+
|
|
3
|
+
Provides drop-in tools and requests wrappers enabling LangChain AI agents
|
|
4
|
+
to autonomously navigate HTTP 402 payment-gated APIs using AgentWallet.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
from apay.client import APayClient, APaySession
|
|
12
|
+
from apay.wallet import AgentWallet
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from langchain_core.tools import BaseTool as LangchainBaseTool
|
|
16
|
+
except ImportError: # pragma: no cover
|
|
17
|
+
LangchainBaseTool = None # type: ignore[assignment, misc]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class APayRequestsWrapper:
|
|
21
|
+
"""Drop-in HTTP client wrapper for LangChain with automated HTTP 402 negotiation."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, wallet: AgentWallet, use_eip712: bool = True, timeout: float = 30.0) -> None:
|
|
24
|
+
self.wallet = wallet
|
|
25
|
+
self.session = APaySession(wallet=wallet, timeout=timeout, use_eip712=use_eip712)
|
|
26
|
+
self.async_client = APayClient(wallet=wallet, timeout=timeout, use_eip712=use_eip712)
|
|
27
|
+
|
|
28
|
+
def get(self, url: str, **kwargs: Any) -> str:
|
|
29
|
+
"""Synchronously execute GET request, auto-negotiating HTTP 402 if challenged."""
|
|
30
|
+
resp = self.session.get(url, **kwargs)
|
|
31
|
+
return resp.text
|
|
32
|
+
|
|
33
|
+
def post(self, url: str, **kwargs: Any) -> str:
|
|
34
|
+
"""Synchronously execute POST request, auto-negotiating HTTP 402 if challenged."""
|
|
35
|
+
resp = self.session.post(url, **kwargs)
|
|
36
|
+
return resp.text
|
|
37
|
+
|
|
38
|
+
async def aget(self, url: str, **kwargs: Any) -> str:
|
|
39
|
+
"""Asynchronously execute GET request, auto-negotiating HTTP 402 if challenged."""
|
|
40
|
+
resp = await self.async_client.get(url, **kwargs)
|
|
41
|
+
return resp.text
|
|
42
|
+
|
|
43
|
+
async def apost(self, url: str, **kwargs: Any) -> str:
|
|
44
|
+
"""Asynchronously execute POST request, auto-negotiating HTTP 402 if challenged."""
|
|
45
|
+
resp = await self.async_client.post(url, **kwargs)
|
|
46
|
+
return resp.text
|
|
47
|
+
|
|
48
|
+
def close(self) -> None:
|
|
49
|
+
self.session.close()
|
|
50
|
+
|
|
51
|
+
async def aclose(self) -> None:
|
|
52
|
+
await self.async_client.aclose()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class APayToolInput(BaseModel):
|
|
56
|
+
"""Input arguments for invoking an A-Pay gated tool."""
|
|
57
|
+
|
|
58
|
+
query: str = Field(default="", description="Query string or instructions to pass to the endpoint")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _BaseToolImpl(BaseModel):
|
|
62
|
+
name: str
|
|
63
|
+
description: str
|
|
64
|
+
target_url: str
|
|
65
|
+
wallet: AgentWallet
|
|
66
|
+
method: str = "GET"
|
|
67
|
+
use_eip712: bool = True
|
|
68
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
69
|
+
|
|
70
|
+
def _execute(self, query: str = "") -> str:
|
|
71
|
+
session = APaySession(wallet=self.wallet, use_eip712=self.use_eip712)
|
|
72
|
+
try:
|
|
73
|
+
url = self.target_url
|
|
74
|
+
if self.method.upper() == "GET":
|
|
75
|
+
params = {"q": query} if query else {}
|
|
76
|
+
resp = session.get(url, params=params)
|
|
77
|
+
else:
|
|
78
|
+
payload = {"query": query} if query else {}
|
|
79
|
+
resp = session.post(url, json=payload)
|
|
80
|
+
return resp.text
|
|
81
|
+
finally:
|
|
82
|
+
session.close()
|
|
83
|
+
|
|
84
|
+
async def _aexecute(self, query: str = "") -> str:
|
|
85
|
+
client = APayClient(wallet=self.wallet, use_eip712=self.use_eip712)
|
|
86
|
+
try:
|
|
87
|
+
url = self.target_url
|
|
88
|
+
if self.method.upper() == "GET":
|
|
89
|
+
params = {"q": query} if query else {}
|
|
90
|
+
resp = await client.get(url, params=params)
|
|
91
|
+
else:
|
|
92
|
+
payload = {"query": query} if query else {}
|
|
93
|
+
resp = await client.post(url, json=payload)
|
|
94
|
+
return resp.text
|
|
95
|
+
finally:
|
|
96
|
+
await client.aclose()
|
|
97
|
+
|
|
98
|
+
def run(self, tool_input: str | dict[str, Any] = "") -> str:
|
|
99
|
+
q = tool_input if isinstance(tool_input, str) else str(tool_input.get("query", ""))
|
|
100
|
+
return self._execute(query=q)
|
|
101
|
+
|
|
102
|
+
def _run(self, *args: Any, **kwargs: Any) -> str:
|
|
103
|
+
q = str(kwargs.get("query", args[0] if args else ""))
|
|
104
|
+
return self._execute(query=q)
|
|
105
|
+
|
|
106
|
+
async def _arun(self, *args: Any, **kwargs: Any) -> str:
|
|
107
|
+
q = str(kwargs.get("query", args[0] if args else ""))
|
|
108
|
+
return await self._aexecute(query=q)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if LangchainBaseTool is not None: # pragma: no cover
|
|
112
|
+
|
|
113
|
+
class APayTool(_BaseToolImpl, LangchainBaseTool): # type: ignore[misc]
|
|
114
|
+
args_schema: type[BaseModel] = APayToolInput
|
|
115
|
+
else:
|
|
116
|
+
|
|
117
|
+
class APayTool(_BaseToolImpl): # type: ignore[no-redef]
|
|
118
|
+
args_schema: type[BaseModel] = APayToolInput
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def create_apay_tool(
|
|
122
|
+
name: str,
|
|
123
|
+
description: str,
|
|
124
|
+
target_url: str,
|
|
125
|
+
wallet: AgentWallet,
|
|
126
|
+
method: str = "GET",
|
|
127
|
+
use_eip712: bool = True,
|
|
128
|
+
) -> APayTool:
|
|
129
|
+
"""Convenience factory to create a LangChain-compatible tool for an HTTP 402 API."""
|
|
130
|
+
return APayTool(
|
|
131
|
+
name=name,
|
|
132
|
+
description=description,
|
|
133
|
+
target_url=target_url,
|
|
134
|
+
wallet=wallet,
|
|
135
|
+
method=method,
|
|
136
|
+
use_eip712=use_eip712,
|
|
137
|
+
)
|