fetch-hackkit 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.
- fetch_hackkit/__init__.py +33 -0
- fetch_hackkit/agent.py +340 -0
- fetch_hackkit/asi1.py +294 -0
- fetch_hackkit/cards.py +526 -0
- fetch_hackkit/cli.py +90 -0
- fetch_hackkit/doctor.py +209 -0
- fetch_hackkit/mcp.py +93 -0
- fetch_hackkit/payments.py +264 -0
- fetch_hackkit/peers.py +40 -0
- fetch_hackkit/py.typed +0 -0
- fetch_hackkit/register.py +240 -0
- fetch_hackkit-0.1.0.dist-info/METADATA +72 -0
- fetch_hackkit-0.1.0.dist-info/RECORD +16 -0
- fetch_hackkit-0.1.0.dist-info/WHEEL +4 -0
- fetch_hackkit-0.1.0.dist-info/entry_points.txt +2 -0
- fetch_hackkit-0.1.0.dist-info/licenses/LICENSE +21 -0
fetch_hackkit/doctor.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Pre-submission checks: ``hackkit doctor``.
|
|
2
|
+
|
|
3
|
+
Each check maps to a hackathon requirement (registered, discoverable via
|
|
4
|
+
ASI:One, chat protocol, README with badges + address) and says how to fix it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from .register import lookup_agent, profile_url, resolve
|
|
17
|
+
|
|
18
|
+
CHAT_PROTOCOL_DIGEST = "30a801ed3a83f9a0ff0a9f1e6fe958cb91da1fc2218b153df7b6cbf87bd33d62"
|
|
19
|
+
BADGES = (
|
|
20
|
+
"",
|
|
21
|
+
"",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Check:
|
|
27
|
+
name: str
|
|
28
|
+
status: str # "ok" | "warn" | "fail"
|
|
29
|
+
detail: str = ""
|
|
30
|
+
fix: str = ""
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def icon(self) -> str:
|
|
34
|
+
return {"ok": "✅", "warn": "⚠️ ", "fail": "❌"}[self.status]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def identity_from_seed(seed: str) -> tuple[str, str]:
|
|
38
|
+
"""``(agent address, FET wallet address)`` for a seed, exactly as uAgents derives them."""
|
|
39
|
+
from cosmpy.aerial.wallet import LocalWallet
|
|
40
|
+
from cosmpy.crypto.keypairs import PrivateKey
|
|
41
|
+
from uagents.config import LEDGER_PREFIX
|
|
42
|
+
from uagents_core.identity import Identity, derive_key_from_seed
|
|
43
|
+
|
|
44
|
+
address = Identity.from_seed(seed, 0).address
|
|
45
|
+
wallet = LocalWallet(
|
|
46
|
+
PrivateKey(derive_key_from_seed(seed, LEDGER_PREFIX, 0)), prefix=LEDGER_PREFIX
|
|
47
|
+
)
|
|
48
|
+
return address, str(wallet.address())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def check_seed() -> tuple[Check, str | None]:
|
|
52
|
+
seed = os.getenv("AGENT_SEED_PHRASE", "")
|
|
53
|
+
if not seed:
|
|
54
|
+
return Check(
|
|
55
|
+
"Seed phrase",
|
|
56
|
+
"fail",
|
|
57
|
+
"AGENT_SEED_PHRASE is not set",
|
|
58
|
+
"Run `hackkit seed` and put the phrase in .env",
|
|
59
|
+
), None
|
|
60
|
+
if len(seed) < 24:
|
|
61
|
+
return Check(
|
|
62
|
+
"Seed phrase",
|
|
63
|
+
"warn",
|
|
64
|
+
"seed is short; anyone who guesses it owns your agent",
|
|
65
|
+
"Use `hackkit seed` for a long random phrase",
|
|
66
|
+
), identity_from_seed(seed)[0]
|
|
67
|
+
address, wallet = identity_from_seed(seed)
|
|
68
|
+
return Check("Seed phrase", "ok", f"address {address}\n wallet {wallet}"), address
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check_asi1() -> Check:
|
|
72
|
+
key = os.getenv("ASI_ONE_API_KEY") or os.getenv("ASI1_API_KEY")
|
|
73
|
+
if not key:
|
|
74
|
+
return Check(
|
|
75
|
+
"ASI:One API key",
|
|
76
|
+
"warn",
|
|
77
|
+
"ASI_ONE_API_KEY is not set",
|
|
78
|
+
"Get one at https://asi1.ai/developer (only needed if you call the LLM)",
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
resp = httpx.post(
|
|
82
|
+
"https://api.asi1.ai/v1/chat/completions",
|
|
83
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
84
|
+
json={
|
|
85
|
+
"model": os.getenv("ASI_ONE_MODEL", "asi1"),
|
|
86
|
+
"max_tokens": 1,
|
|
87
|
+
"messages": [{"role": "user", "content": "ping"}],
|
|
88
|
+
},
|
|
89
|
+
timeout=30.0,
|
|
90
|
+
)
|
|
91
|
+
except httpx.HTTPError as exc:
|
|
92
|
+
return Check("ASI:One API key", "warn", f"could not reach ASI:One: {exc}")
|
|
93
|
+
if resp.status_code in (401, 403):
|
|
94
|
+
return Check(
|
|
95
|
+
"ASI:One API key",
|
|
96
|
+
"fail",
|
|
97
|
+
"key rejected",
|
|
98
|
+
"Create a new key at https://asi1.ai/developer",
|
|
99
|
+
)
|
|
100
|
+
if resp.status_code >= 400:
|
|
101
|
+
return Check("ASI:One API key", "warn", f"HTTP {resp.status_code}: {resp.text[:200]}")
|
|
102
|
+
return Check("ASI:One API key", "ok", "key works")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_registration(address: str) -> Check:
|
|
106
|
+
if not os.getenv("AGENTVERSE_API_KEY"):
|
|
107
|
+
return Check(
|
|
108
|
+
"Agentverse account",
|
|
109
|
+
"warn",
|
|
110
|
+
"AGENTVERSE_API_KEY is not set (cannot check)",
|
|
111
|
+
"Create one at https://agentverse.ai/profile/api-keys",
|
|
112
|
+
)
|
|
113
|
+
try:
|
|
114
|
+
record = lookup_agent(address)
|
|
115
|
+
except RuntimeError as exc:
|
|
116
|
+
return Check("Agentverse account", "fail", str(exc))
|
|
117
|
+
if record is None:
|
|
118
|
+
return Check(
|
|
119
|
+
"Agentverse account",
|
|
120
|
+
"fail",
|
|
121
|
+
"agent is not on your Agentverse account",
|
|
122
|
+
"Run the agent with AGENTVERSE_API_KEY set (auto-connect), or open the "
|
|
123
|
+
"inspector link and click Connect -> Mailbox",
|
|
124
|
+
)
|
|
125
|
+
return Check("Agentverse account", "ok", profile_url(address))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def check_almanac(address: str) -> Check:
|
|
129
|
+
try:
|
|
130
|
+
record = resolve(address)
|
|
131
|
+
except (RuntimeError, httpx.HTTPError) as exc:
|
|
132
|
+
return Check("Almanac / ASI:One discoverability", "warn", f"lookup failed: {exc}")
|
|
133
|
+
if record is None:
|
|
134
|
+
return Check(
|
|
135
|
+
"Almanac / ASI:One discoverability",
|
|
136
|
+
"fail",
|
|
137
|
+
"agent is not on the Almanac",
|
|
138
|
+
"Start the agent and connect it to Agentverse (mailbox)",
|
|
139
|
+
)
|
|
140
|
+
problems = []
|
|
141
|
+
if record.get("status") != "active":
|
|
142
|
+
problems.append(f"status is {record.get('status')!r} (start the agent)")
|
|
143
|
+
if CHAT_PROTOCOL_DIGEST not in (record.get("protocols") or []):
|
|
144
|
+
problems.append("chat protocol not published (include it with publish_manifest=True)")
|
|
145
|
+
expiry = record.get("expiry")
|
|
146
|
+
if expiry:
|
|
147
|
+
when = datetime.fromisoformat(str(expiry).replace("Z", "+00:00"))
|
|
148
|
+
if when < datetime.now(UTC):
|
|
149
|
+
problems.append("registration expired (restart the agent)")
|
|
150
|
+
if problems:
|
|
151
|
+
return Check("Almanac / ASI:One discoverability", "fail", "; ".join(problems))
|
|
152
|
+
return Check(
|
|
153
|
+
"Almanac / ASI:One discoverability",
|
|
154
|
+
"ok",
|
|
155
|
+
f"active, chat protocol published, type {record.get('type')}",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def check_handle(handle: str, address: str) -> Check:
|
|
160
|
+
try:
|
|
161
|
+
record = resolve(handle)
|
|
162
|
+
except (RuntimeError, httpx.HTTPError) as exc:
|
|
163
|
+
return Check("@handle", "warn", f"lookup failed: {exc}")
|
|
164
|
+
if record is None:
|
|
165
|
+
return Check(
|
|
166
|
+
"@handle",
|
|
167
|
+
"fail",
|
|
168
|
+
f"@{handle} is not assigned yet",
|
|
169
|
+
"Pass handle=... to HackAgent and reconnect to Agentverse",
|
|
170
|
+
)
|
|
171
|
+
if record.get("address") != address:
|
|
172
|
+
return Check(
|
|
173
|
+
"@handle", "fail", f"@{handle} belongs to another agent", "Choose a different handle"
|
|
174
|
+
)
|
|
175
|
+
return Check("@handle", "ok", f"@{handle} -> this agent")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def check_readme(path: Path, address: str | None) -> Check:
|
|
179
|
+
if not path.exists():
|
|
180
|
+
return Check("README", "fail", f"{path} not found", "Your repo needs a README")
|
|
181
|
+
text = path.read_text(encoding="utf-8")
|
|
182
|
+
missing = [b for b in BADGES if b not in text]
|
|
183
|
+
problems = []
|
|
184
|
+
if missing:
|
|
185
|
+
problems.append("missing badge(s): " + ", ".join(b.split("]")[0][2:] for b in missing))
|
|
186
|
+
if address and address not in text:
|
|
187
|
+
problems.append("agent address not listed")
|
|
188
|
+
if problems:
|
|
189
|
+
return Check(
|
|
190
|
+
"README",
|
|
191
|
+
"fail",
|
|
192
|
+
"; ".join(problems),
|
|
193
|
+
"Add the innovationlab + hackathon badges and your agent name + address",
|
|
194
|
+
)
|
|
195
|
+
return Check("README", "ok", "badges and address present")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def run(*, readme: Path, handle: str | None) -> list[Check]:
|
|
199
|
+
checks: list[Check] = []
|
|
200
|
+
seed_check, address = check_seed()
|
|
201
|
+
checks.append(seed_check)
|
|
202
|
+
checks.append(check_asi1())
|
|
203
|
+
if address:
|
|
204
|
+
checks.append(check_registration(address))
|
|
205
|
+
checks.append(check_almanac(address))
|
|
206
|
+
if handle:
|
|
207
|
+
checks.append(check_handle(handle.removeprefix("@"), address))
|
|
208
|
+
checks.append(check_readme(readme, address))
|
|
209
|
+
return checks
|
fetch_hackkit/mcp.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Use any remote MCP server's tools from your agent (``pip install fetch-hackkit[mcp]``).
|
|
2
|
+
|
|
3
|
+
tools = await mcp_tools("https://mcp.deepwiki.com/mcp")
|
|
4
|
+
answer = await ASI1().run_tools("How do uAgents handle mailboxes?", tools=tools)
|
|
5
|
+
|
|
6
|
+
Each tool call opens a short-lived connection, so there is nothing to keep
|
|
7
|
+
alive or clean up. To go the other way (publish an MCP server *as* an agent
|
|
8
|
+
on Agentverse), use ``uagents-adapter[mcp]``'s ``MCPServerAdapter``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .asi1 import Tool
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import httpx2
|
|
20
|
+
from mcp import Client
|
|
21
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
22
|
+
except ImportError as exc: # pragma: no cover
|
|
23
|
+
raise ImportError("MCP support needs the extra: pip install 'fetch-hackkit[mcp]'") from exc
|
|
24
|
+
|
|
25
|
+
# The MCP transport logs every HTTP request at INFO; keep agent logs readable.
|
|
26
|
+
logging.getLogger("httpx2").setLevel(logging.WARNING)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _client(url: str, headers: dict[str, str] | None) -> Client:
|
|
30
|
+
if not headers:
|
|
31
|
+
return Client(url)
|
|
32
|
+
http = httpx2.AsyncClient(headers=headers, timeout=httpx2.Timeout(30.0, read=300.0))
|
|
33
|
+
return Client(streamable_http_client(url, http_client=http))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _result_text(result: Any) -> Any:
|
|
37
|
+
if getattr(result, "structured_content", None) is not None:
|
|
38
|
+
return result.structured_content
|
|
39
|
+
parts = [getattr(block, "text", None) or str(block) for block in result.content or []]
|
|
40
|
+
text = "\n".join(p for p in parts if p)
|
|
41
|
+
if getattr(result, "is_error", False):
|
|
42
|
+
return {"error": text or "tool failed"}
|
|
43
|
+
return text
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def call_mcp_tool(
|
|
47
|
+
url: str,
|
|
48
|
+
name: str,
|
|
49
|
+
arguments: dict[str, Any] | None = None,
|
|
50
|
+
*,
|
|
51
|
+
headers: dict[str, str] | None = None,
|
|
52
|
+
) -> Any:
|
|
53
|
+
"""Call one tool on a remote MCP server and return its text/structured result."""
|
|
54
|
+
async with _client(url, headers) as client:
|
|
55
|
+
return _result_text(await client.call_tool(name, arguments or {}))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def mcp_tools(
|
|
59
|
+
url: str,
|
|
60
|
+
*,
|
|
61
|
+
headers: dict[str, str] | None = None,
|
|
62
|
+
include: set[str] | None = None,
|
|
63
|
+
prefix: str = "",
|
|
64
|
+
) -> list[Tool]:
|
|
65
|
+
"""List a server's tools as :class:`~fetch_hackkit.asi1.Tool` objects for ``run_tools``.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
url: Streamable-HTTP MCP endpoint, e.g. ``https://mcp.deepwiki.com/mcp``.
|
|
69
|
+
headers: Extra HTTP headers, e.g. ``{"Authorization": "Bearer ..."}``.
|
|
70
|
+
include: Only expose these tool names (keeps the model focused).
|
|
71
|
+
prefix: Prepended to tool names to avoid clashes between servers.
|
|
72
|
+
"""
|
|
73
|
+
async with _client(url, headers) as client:
|
|
74
|
+
listed = await client.list_tools()
|
|
75
|
+
|
|
76
|
+
tools: list[Tool] = []
|
|
77
|
+
for spec in listed.tools:
|
|
78
|
+
if include and spec.name not in include:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
async def call(arguments: dict[str, Any], _name: str = spec.name) -> Any:
|
|
82
|
+
return await call_mcp_tool(url, _name, arguments, headers=headers)
|
|
83
|
+
|
|
84
|
+
schema = dict(spec.input_schema or {"type": "object", "properties": {}})
|
|
85
|
+
tools.append(
|
|
86
|
+
Tool(
|
|
87
|
+
name=f"{prefix}{spec.name}",
|
|
88
|
+
description=spec.description or spec.name,
|
|
89
|
+
parameters=schema,
|
|
90
|
+
call=call,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
return tools
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Agent Payment Protocol (seller side) with on-chain FET verification.
|
|
2
|
+
|
|
3
|
+
You normally use this through ``@agent.paid(...)``::
|
|
4
|
+
|
|
5
|
+
@agent.paid("brief", amount="0.1", description="AI project brief")
|
|
6
|
+
async def brief(ctx, msg, sel):
|
|
7
|
+
return "Here is your brief..."
|
|
8
|
+
|
|
9
|
+
Flow (``uagents_core.contrib.protocols.payment``)::
|
|
10
|
+
|
|
11
|
+
you -> user : RequestPayment(accepted_funds=[0.1 FET], recipient=<agent wallet>, reference)
|
|
12
|
+
user -> you : CommitPayment(transaction_id, reference) (or RejectPayment)
|
|
13
|
+
you : verify the transfer on-chain (cosmpy)
|
|
14
|
+
you -> user : CompletePayment(transaction_id) and the paid result
|
|
15
|
+
(or CancelPayment(reason) if verification fails)
|
|
16
|
+
|
|
17
|
+
Defaults to the Fetch.ai **stable testnet** (free test FET from the faucet).
|
|
18
|
+
Set ``FET_NETWORK=mainnet`` for real money.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import os
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from decimal import Decimal
|
|
28
|
+
from typing import TYPE_CHECKING, Any
|
|
29
|
+
from uuid import uuid4
|
|
30
|
+
|
|
31
|
+
from uagents import Context, Protocol
|
|
32
|
+
from uagents_core.contrib.protocols.payment import (
|
|
33
|
+
CancelPayment,
|
|
34
|
+
CommitPayment,
|
|
35
|
+
CompletePayment,
|
|
36
|
+
Funds,
|
|
37
|
+
RejectPayment,
|
|
38
|
+
RequestPayment,
|
|
39
|
+
payment_protocol_spec,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from .agent import ActionHandler, HackAgent, Inbound, Reply
|
|
44
|
+
|
|
45
|
+
FET_DECIMALS = 18
|
|
46
|
+
DEFAULT_DEADLINE_SECONDS = 600
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fet_network() -> str:
|
|
50
|
+
"""``stable-testnet`` (default) or ``mainnet``, from ``FET_NETWORK``."""
|
|
51
|
+
return "mainnet" if os.getenv("FET_NETWORK", "").lower() == "mainnet" else "stable-testnet"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Product:
|
|
56
|
+
name: str
|
|
57
|
+
amount: str
|
|
58
|
+
currency: str
|
|
59
|
+
description: str
|
|
60
|
+
handler: ActionHandler
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_request(
|
|
64
|
+
*,
|
|
65
|
+
amount: str,
|
|
66
|
+
recipient_wallet: str,
|
|
67
|
+
description: str,
|
|
68
|
+
currency: str = "FET",
|
|
69
|
+
reference: str | None = None,
|
|
70
|
+
deadline_seconds: int = DEFAULT_DEADLINE_SECONDS,
|
|
71
|
+
network: str | None = None,
|
|
72
|
+
) -> RequestPayment:
|
|
73
|
+
"""A ``RequestPayment`` for a direct FET transfer to ``recipient_wallet``."""
|
|
74
|
+
network = network or fet_network()
|
|
75
|
+
return RequestPayment(
|
|
76
|
+
accepted_funds=[Funds(amount=amount, currency=currency, payment_method="fet_direct")],
|
|
77
|
+
recipient=recipient_wallet,
|
|
78
|
+
deadline_seconds=deadline_seconds,
|
|
79
|
+
reference=reference or str(uuid4()),
|
|
80
|
+
description=description,
|
|
81
|
+
metadata={"fet_network": network, "provider_agent_wallet": recipient_wallet},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _amount_ok(amount: str, expected_base_units: int, denom: str) -> bool:
|
|
86
|
+
if not amount.endswith(denom):
|
|
87
|
+
return False
|
|
88
|
+
try:
|
|
89
|
+
return int(amount[: -len(denom)]) >= expected_base_units
|
|
90
|
+
except ValueError:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def verify_fet_transfer(
|
|
95
|
+
*,
|
|
96
|
+
transaction_id: str,
|
|
97
|
+
amount: str,
|
|
98
|
+
recipient_wallet: str,
|
|
99
|
+
sender_wallet: str | None = None,
|
|
100
|
+
network: str | None = None,
|
|
101
|
+
) -> tuple[bool, str]:
|
|
102
|
+
"""Check on-chain that ``transaction_id`` sent at least ``amount`` FET to us.
|
|
103
|
+
|
|
104
|
+
Blocking (network I/O): call it via ``asyncio.to_thread``.
|
|
105
|
+
Returns ``(ok, reason)``.
|
|
106
|
+
"""
|
|
107
|
+
from cosmpy.aerial.client import LedgerClient, NetworkConfig
|
|
108
|
+
|
|
109
|
+
mainnet = (network or fet_network()) == "mainnet"
|
|
110
|
+
denom = "afet" if mainnet else "atestfet"
|
|
111
|
+
expected = int(Decimal(amount) * (10**FET_DECIMALS))
|
|
112
|
+
config = NetworkConfig.fetchai_mainnet() if mainnet else NetworkConfig.fetchai_stable_testnet()
|
|
113
|
+
try:
|
|
114
|
+
tx = LedgerClient(config).query_tx(transaction_id)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
return False, f"transaction not found yet ({type(exc).__name__})"
|
|
117
|
+
if tx is None:
|
|
118
|
+
return False, "transaction not found"
|
|
119
|
+
if hasattr(tx, "is_successful") and not tx.is_successful():
|
|
120
|
+
return False, "transaction failed on-chain"
|
|
121
|
+
events = getattr(tx, "events", None)
|
|
122
|
+
if not isinstance(events, dict):
|
|
123
|
+
return False, "transaction has no events"
|
|
124
|
+
|
|
125
|
+
transfer = events.get("transfer") or {}
|
|
126
|
+
received = events.get("coin_received") or {}
|
|
127
|
+
spent = events.get("coin_spent") or {}
|
|
128
|
+
to_us = recipient_wallet in (transfer.get("recipient"), received.get("receiver"))
|
|
129
|
+
paid_enough = _amount_ok(str(transfer.get("amount", "")), expected, denom) or _amount_ok(
|
|
130
|
+
str(received.get("amount", "")), expected, denom
|
|
131
|
+
)
|
|
132
|
+
from_buyer = sender_wallet is None or sender_wallet in (
|
|
133
|
+
transfer.get("sender"),
|
|
134
|
+
spent.get("spender"),
|
|
135
|
+
)
|
|
136
|
+
if not to_us:
|
|
137
|
+
return False, "transfer was not to this agent's wallet"
|
|
138
|
+
if not paid_enough:
|
|
139
|
+
return False, f"amount is less than {amount} FET"
|
|
140
|
+
if not from_buyer:
|
|
141
|
+
return False, "transfer did not come from the buyer's wallet"
|
|
142
|
+
return True, "verified"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Paywall:
|
|
146
|
+
"""Seller-side payment protocol attached to a :class:`HackAgent`."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, agent: HackAgent) -> None:
|
|
149
|
+
self.agent = agent
|
|
150
|
+
self.products: dict[str, Product] = {}
|
|
151
|
+
self.protocol = Protocol(spec=payment_protocol_spec, role="seller")
|
|
152
|
+
self.protocol.on_message(CommitPayment)(self._on_commit)
|
|
153
|
+
self.protocol.on_message(RejectPayment)(self._on_reject)
|
|
154
|
+
agent.include(self.protocol, publish_manifest=True)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def wallet(self) -> str:
|
|
158
|
+
return str(self.agent.wallet.address())
|
|
159
|
+
|
|
160
|
+
def add(self, product: Product) -> None:
|
|
161
|
+
self.products[product.name] = product
|
|
162
|
+
|
|
163
|
+
async def request(self, ctx: Context, msg: Inbound, name: str, sel: dict[str, Any]) -> Reply:
|
|
164
|
+
product = self.products[name]
|
|
165
|
+
reference = str(uuid4())
|
|
166
|
+
ctx.storage.set(
|
|
167
|
+
f"hackkit:pay:{reference}",
|
|
168
|
+
{
|
|
169
|
+
"product": name,
|
|
170
|
+
"sender": msg.sender,
|
|
171
|
+
"text": msg.text,
|
|
172
|
+
"session": msg.session,
|
|
173
|
+
"selection": sel,
|
|
174
|
+
"expires": time.time() + DEFAULT_DEADLINE_SECONDS,
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
await ctx.send(
|
|
178
|
+
msg.sender,
|
|
179
|
+
build_request(
|
|
180
|
+
amount=product.amount,
|
|
181
|
+
currency=product.currency,
|
|
182
|
+
recipient_wallet=self.wallet,
|
|
183
|
+
description=product.description,
|
|
184
|
+
reference=reference,
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
net = "testnet " if fet_network() != "mainnet" else ""
|
|
188
|
+
return (
|
|
189
|
+
f"💳 **{product.description}** costs **{product.amount} {net}{product.currency}**. "
|
|
190
|
+
"Approve the payment request to continue."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
async def _on_reject(self, ctx: Context, sender: str, msg: RejectPayment) -> None:
|
|
194
|
+
reason = f" ({msg.reason})" if msg.reason else ""
|
|
195
|
+
await self.agent.reply(ctx, sender, f"No problem, payment cancelled{reason}.")
|
|
196
|
+
|
|
197
|
+
async def _on_commit(self, ctx: Context, sender: str, msg: CommitPayment) -> None:
|
|
198
|
+
from .agent import Inbound, text_message
|
|
199
|
+
|
|
200
|
+
tx = msg.transaction_id
|
|
201
|
+
key = f"hackkit:pay:{msg.reference}"
|
|
202
|
+
pending = ctx.storage.get(key) if msg.reference else None
|
|
203
|
+
if ctx.storage.get(f"hackkit:paid-tx:{tx}"):
|
|
204
|
+
await ctx.send(sender, CompletePayment(transaction_id=tx)) # duplicate commit
|
|
205
|
+
return
|
|
206
|
+
if not isinstance(pending, dict) or pending.get("sender") != sender:
|
|
207
|
+
await ctx.send(
|
|
208
|
+
sender, CancelPayment(transaction_id=tx, reason="Unknown payment reference")
|
|
209
|
+
)
|
|
210
|
+
return
|
|
211
|
+
if time.time() > float(pending.get("expires", 0)):
|
|
212
|
+
ctx.storage.remove(key)
|
|
213
|
+
await ctx.send(
|
|
214
|
+
sender, CancelPayment(transaction_id=tx, reason="Payment request expired")
|
|
215
|
+
)
|
|
216
|
+
await self.agent.reply(ctx, sender, "That payment request expired. Please try again.")
|
|
217
|
+
return
|
|
218
|
+
product = self.products.get(str(pending["product"]))
|
|
219
|
+
if product is None:
|
|
220
|
+
await ctx.send(sender, CancelPayment(transaction_id=tx, reason="Unknown product"))
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
buyer = (msg.metadata or {}).get("buyer_fet_wallet") or (msg.metadata or {}).get(
|
|
224
|
+
"buyer_fet_address"
|
|
225
|
+
)
|
|
226
|
+
ok, reason = False, "not verified"
|
|
227
|
+
for attempt in range(3): # the tx may take a few seconds to be indexed
|
|
228
|
+
ok, reason = await asyncio.to_thread(
|
|
229
|
+
verify_fet_transfer,
|
|
230
|
+
transaction_id=tx,
|
|
231
|
+
amount=product.amount,
|
|
232
|
+
recipient_wallet=self.wallet,
|
|
233
|
+
sender_wallet=buyer if isinstance(buyer, str) else None,
|
|
234
|
+
)
|
|
235
|
+
if ok or "not found" not in reason:
|
|
236
|
+
break
|
|
237
|
+
await asyncio.sleep(3 * (attempt + 1))
|
|
238
|
+
if not ok:
|
|
239
|
+
ctx.logger.warning(f"payment {tx} rejected: {reason}")
|
|
240
|
+
await ctx.send(sender, CancelPayment(transaction_id=tx, reason=reason))
|
|
241
|
+
await self.agent.reply(ctx, sender, f"I couldn't verify that payment: {reason}.")
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
ctx.storage.set(f"hackkit:paid-tx:{tx}", True)
|
|
245
|
+
ctx.storage.remove(key)
|
|
246
|
+
await ctx.send(sender, CompletePayment(transaction_id=tx))
|
|
247
|
+
ctx.logger.info(f"payment {tx} verified for {product.name}")
|
|
248
|
+
|
|
249
|
+
inbound = Inbound(
|
|
250
|
+
sender=sender,
|
|
251
|
+
text=str(pending.get("text", "")),
|
|
252
|
+
session=str(pending.get("session", "")),
|
|
253
|
+
raw=text_message(str(pending.get("text", ""))),
|
|
254
|
+
selection=dict(pending.get("selection") or {}),
|
|
255
|
+
)
|
|
256
|
+
try:
|
|
257
|
+
reply = await product.handler(ctx, inbound, inbound.selection)
|
|
258
|
+
except Exception:
|
|
259
|
+
ctx.logger.exception("paid handler failed")
|
|
260
|
+
reply = (
|
|
261
|
+
"Payment received, but something failed while preparing your result. "
|
|
262
|
+
f"Please contact the agent owner with transaction id {tx}."
|
|
263
|
+
)
|
|
264
|
+
await self.agent.reply(ctx, sender, reply)
|
fetch_hackkit/peers.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Talk to other agents over the chat protocol (multi-agent patterns).
|
|
2
|
+
|
|
3
|
+
answer = await ask_agent(ctx, "@weather-agent", "Forecast for Berkeley tomorrow?")
|
|
4
|
+
|
|
5
|
+
Works with any chat-protocol agent on Agentverse, including ones you did not write.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
|
|
13
|
+
from uagents import Context
|
|
14
|
+
from uagents_core.contrib.protocols.chat import ChatAcknowledgement, ChatMessage
|
|
15
|
+
|
|
16
|
+
from .agent import text_message
|
|
17
|
+
from .register import resolve_address
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PeerError(RuntimeError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def ask_agent(ctx: Context, agent: str, text: str, *, timeout: int = 60) -> str:
|
|
25
|
+
"""Send ``text`` to ``agent`` (address or ``@handle``) and wait for its text reply.
|
|
26
|
+
|
|
27
|
+
The peer's ``ChatAcknowledgement`` is skipped; its next ``ChatMessage`` in this
|
|
28
|
+
session is the answer. Raises :class:`PeerError` on delivery failure or timeout.
|
|
29
|
+
"""
|
|
30
|
+
address = await asyncio.to_thread(resolve_address, agent)
|
|
31
|
+
reply, status = await ctx.send_and_receive(
|
|
32
|
+
address, text_message(text), response_type=ChatMessage, timeout=timeout
|
|
33
|
+
)
|
|
34
|
+
if not isinstance(reply, ChatMessage):
|
|
35
|
+
raise PeerError(f"{agent} did not answer: {getattr(status, 'detail', status)}")
|
|
36
|
+
await ctx.send(
|
|
37
|
+
address,
|
|
38
|
+
ChatAcknowledgement(timestamp=datetime.now(UTC), acknowledged_msg_id=reply.msg_id),
|
|
39
|
+
)
|
|
40
|
+
return reply.text()
|
fetch_hackkit/py.typed
ADDED
|
File without changes
|