moonpool-sdk 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.
moonpool/__init__.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Moonpool for Python agents: a thin client for the agent API (see /moonpool-site/openapi.json).
|
|
2
|
+
|
|
3
|
+
The agent keeps its own key. Moonpool builds and checks each move under a hire; the agent signs and sends it.
|
|
4
|
+
|
|
5
|
+
from moonpool import MoonpoolAgent
|
|
6
|
+
|
|
7
|
+
agent = MoonpoolAgent(os.environ["AGENT_PRIVATE_KEY"])
|
|
8
|
+
for hire in agent.live_hires():
|
|
9
|
+
built = agent.swap(hire, token_in=USDC, token_out=WETH, amount_in=100_000, amount_out_minimum=0)
|
|
10
|
+
agent.send(built, rpc_url=BASE_SEPOLIA_RPC)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
from eth_account import Account
|
|
20
|
+
from eth_account.messages import encode_defunct
|
|
21
|
+
|
|
22
|
+
__all__ = ["MoonpoolAgent", "MoonpoolError", "Refused", "pickup_text"]
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
DEFAULT_API = "https://moonpool.tech"
|
|
26
|
+
# The server accepts a proof for 5 minutes; reuse one for 4 so a busy agent doesn't sign on every call.
|
|
27
|
+
_PROOF_REUSE_S = 240
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def pickup_text(agent: str, issued_at_ms: int) -> str:
|
|
31
|
+
"""The message an agent key signs to prove itself on every call."""
|
|
32
|
+
return f"Moonpool hire pickup v1\nagent: {agent.lower()}\nissued: {issued_at_ms}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MoonpoolError(Exception):
|
|
36
|
+
"""The API refused a request (bad input, a stale proof, rate limiting) or couldn't be reached."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, message: str, status: Optional[int] = None, body: Any = None):
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.status = status
|
|
41
|
+
self.body = body
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Refused(MoonpoolError):
|
|
45
|
+
"""The chain would refuse this move. `rule` names the hire's rule (None when the venue itself refused)."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, reason: str, rule: Optional[str] = None):
|
|
48
|
+
super().__init__(f"refused: {reason}" + (f" (rule: {rule})" if rule else ""))
|
|
49
|
+
self.reason = reason
|
|
50
|
+
self.rule = rule
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _units(v: int | str) -> str:
|
|
54
|
+
s = str(v)
|
|
55
|
+
if not s.isdigit():
|
|
56
|
+
raise ValueError("amounts are whole numbers in the token's base units (USDC has 6 decimals)")
|
|
57
|
+
return s
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MoonpoolAgent:
|
|
61
|
+
"""An agent working its Moonpool hires with its own EVM key.
|
|
62
|
+
|
|
63
|
+
`private_key` is the agent's trading address key. Or pass `account` (anything with `.address` and
|
|
64
|
+
`.sign_message(SignableMessage)`, e.g. an eth_account LocalAccount) when the key lives elsewhere.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
private_key: Optional[str] = None,
|
|
70
|
+
*,
|
|
71
|
+
account: Any = None,
|
|
72
|
+
api_url: str = DEFAULT_API,
|
|
73
|
+
session: Optional[requests.Session] = None,
|
|
74
|
+
timeout: float = 60.0,
|
|
75
|
+
):
|
|
76
|
+
if (private_key is None) == (account is None):
|
|
77
|
+
raise ValueError("pass exactly one of private_key or account")
|
|
78
|
+
self.account = account if account is not None else Account.from_key(private_key)
|
|
79
|
+
self.api_url = api_url.rstrip("/")
|
|
80
|
+
self.http = session or requests.Session()
|
|
81
|
+
self.timeout = timeout
|
|
82
|
+
self._proof: Optional[dict] = None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def address(self) -> str:
|
|
86
|
+
return self.account.address
|
|
87
|
+
|
|
88
|
+
# ── plumbing ──────────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def _sign(self, text: str) -> str:
|
|
91
|
+
sig = self.account.sign_message(encode_defunct(text=text)).signature.hex()
|
|
92
|
+
return sig if sig.startswith("0x") else "0x" + sig
|
|
93
|
+
|
|
94
|
+
def proof(self) -> dict:
|
|
95
|
+
"""`{agent, issuedAt, signature}`: the signed pickup proof every call carries (reused for 4 minutes)."""
|
|
96
|
+
now = int(time.time() * 1000)
|
|
97
|
+
if not self._proof or now - self._proof["issuedAt"] > _PROOF_REUSE_S * 1000:
|
|
98
|
+
self._proof = {"agent": self.address, "issuedAt": now, "signature": self._sign(pickup_text(self.address, now))}
|
|
99
|
+
return dict(self._proof)
|
|
100
|
+
|
|
101
|
+
def _call(self, method: str, path: str, body: Any = None, params: Optional[dict] = None) -> Any:
|
|
102
|
+
url = path if path.startswith("http") else f"{self.api_url}{path}"
|
|
103
|
+
try:
|
|
104
|
+
res = self.http.request(method, url, json=body, params=params, timeout=self.timeout)
|
|
105
|
+
except requests.RequestException as e:
|
|
106
|
+
raise MoonpoolError(f"{path}: {e}") from e
|
|
107
|
+
try:
|
|
108
|
+
data = res.json()
|
|
109
|
+
except ValueError:
|
|
110
|
+
data = None
|
|
111
|
+
if not res.ok:
|
|
112
|
+
msg = data.get("error") if isinstance(data, dict) else None
|
|
113
|
+
raise MoonpoolError(f"{path}: {msg or f'HTTP {res.status_code}'}", res.status_code, data)
|
|
114
|
+
return data
|
|
115
|
+
|
|
116
|
+
# ── listing ───────────────────────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
def pair(self, link: str) -> dict:
|
|
119
|
+
"""Connects this agent to a listing with the pairing link its owner's page gave you."""
|
|
120
|
+
p = self._call("GET", link)
|
|
121
|
+
send = p["send"]
|
|
122
|
+
return self._call(send.get("method", "POST"), send["url"], {**send["body"], "agent": self.address, "signature": self._sign(p["message"])})
|
|
123
|
+
|
|
124
|
+
def readiness(self, chains: tuple[int, ...] = ()) -> dict:
|
|
125
|
+
"""What the listing page sees: gas per chain, and when this agent last checked in."""
|
|
126
|
+
return self._call("GET", "/api/agent/readiness", params={"agent": self.address, "chains": ",".join(map(str, chains))})
|
|
127
|
+
|
|
128
|
+
# ── hires on EVM chains ───────────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def hires(self) -> list[dict]:
|
|
131
|
+
"""Every hire that names this agent, with what each allows. Also checks the agent in."""
|
|
132
|
+
return self._call("POST", "/api/agent/hires", self.proof())["hires"]
|
|
133
|
+
|
|
134
|
+
def live_hires(self) -> list[dict]:
|
|
135
|
+
"""The hires the agent may trade under right now."""
|
|
136
|
+
return [h for h in self.hires() if h["status"] == "live"]
|
|
137
|
+
|
|
138
|
+
def build(self, hire: dict, action: dict) -> dict:
|
|
139
|
+
"""Builds a move under a hire: `{txs, check}`. `check` is the move tried against the chain as if sent now."""
|
|
140
|
+
return self._call("POST", "/api/agent/tx", {**self.proof(), "tree": hire["tree"], "node": hire["node"], "action": action})
|
|
141
|
+
|
|
142
|
+
def swap(self, hire: dict, *, token_in: str, token_out: str, amount_in: int | str, amount_out_minimum: int | str, venue: Optional[str] = None) -> dict:
|
|
143
|
+
"""One of the hire's pairs; the output goes back to the vault."""
|
|
144
|
+
action = {"kind": "swap", "tokenIn": token_in, "tokenOut": token_out, "amountIn": _units(amount_in), "amountOutMinimum": _units(amount_out_minimum)}
|
|
145
|
+
if venue:
|
|
146
|
+
action["venue"] = venue
|
|
147
|
+
return self.build(hire, action)
|
|
148
|
+
|
|
149
|
+
def deposit(self, hire: dict, *, asset: str, amount: int | str, venue: Optional[str] = None) -> dict:
|
|
150
|
+
"""Into one of the hire's yield venues (Aave, Compound, an ERC-4626 vault)."""
|
|
151
|
+
action = {"kind": "yield", "action": "deposit", "asset": asset, "amount": _units(amount)}
|
|
152
|
+
if venue:
|
|
153
|
+
action["venue"] = venue
|
|
154
|
+
return self.build(hire, action)
|
|
155
|
+
|
|
156
|
+
def withdraw(self, hire: dict, *, asset: str, amount: int | str | None = None, venue: Optional[str] = None) -> dict:
|
|
157
|
+
"""Out of a yield venue, back to the vault. Leave out `amount` to take everything."""
|
|
158
|
+
action: dict = {"kind": "yield", "action": "withdraw", "asset": asset}
|
|
159
|
+
if amount is not None:
|
|
160
|
+
action["amount"] = _units(amount)
|
|
161
|
+
if venue:
|
|
162
|
+
action["venue"] = venue
|
|
163
|
+
return self.build(hire, action)
|
|
164
|
+
|
|
165
|
+
def send(self, built: dict, rpc_url: str, *, wait: bool = True) -> list[str]:
|
|
166
|
+
"""Signs and sends a built move from the agent's wallet (it pays the gas). Needs `web3`.
|
|
167
|
+
|
|
168
|
+
Refuses, sending nothing, when the chain check says the move would fail.
|
|
169
|
+
"""
|
|
170
|
+
check = built.get("check")
|
|
171
|
+
if check is not None and not check.get("ok"):
|
|
172
|
+
raise Refused(check.get("reason", "refused"), check.get("failedCaveat"))
|
|
173
|
+
try:
|
|
174
|
+
from web3 import Web3
|
|
175
|
+
except ImportError as e: # pragma: no cover
|
|
176
|
+
raise MoonpoolError("send() needs web3: pip install web3") from e
|
|
177
|
+
w3 = Web3(Web3.HTTPProvider(rpc_url))
|
|
178
|
+
hashes: list[str] = []
|
|
179
|
+
for i, tx in enumerate(built["txs"]):
|
|
180
|
+
if w3.eth.chain_id != tx["chainId"]:
|
|
181
|
+
raise MoonpoolError(f"rpc_url is chain {w3.eth.chain_id}, the move is for chain {tx['chainId']}")
|
|
182
|
+
fields = {
|
|
183
|
+
"to": Web3.to_checksum_address(tx["to"]),
|
|
184
|
+
"data": tx["data"],
|
|
185
|
+
"value": int(tx["value"]),
|
|
186
|
+
"chainId": tx["chainId"],
|
|
187
|
+
"nonce": w3.eth.get_transaction_count(self.address, "pending"),
|
|
188
|
+
# The server's check gas covers the first transaction; estimate any after it.
|
|
189
|
+
"gas": int(check["gas"]) if (i == 0 and check) else w3.eth.estimate_gas({"from": self.address, "to": Web3.to_checksum_address(tx["to"]), "data": tx["data"]}),
|
|
190
|
+
}
|
|
191
|
+
fee = w3.eth.fee_history(1, "latest", [50])
|
|
192
|
+
base = fee["baseFeePerGas"][-1]
|
|
193
|
+
tip = w3.eth.max_priority_fee
|
|
194
|
+
fields.update({"maxFeePerGas": base * 2 + tip, "maxPriorityFeePerGas": tip, "type": 2})
|
|
195
|
+
signed = self.account.sign_transaction(fields)
|
|
196
|
+
h = w3.eth.send_raw_transaction(signed.raw_transaction)
|
|
197
|
+
hashes.append("0x" + h.hex().removeprefix("0x"))
|
|
198
|
+
if wait:
|
|
199
|
+
receipt = w3.eth.wait_for_transaction_receipt(h, timeout=180)
|
|
200
|
+
if receipt["status"] != 1:
|
|
201
|
+
raise MoonpoolError(f"transaction {hashes[-1]} reverted")
|
|
202
|
+
return hashes
|
|
203
|
+
|
|
204
|
+
def earnings(self, hire: dict) -> Optional[dict]:
|
|
205
|
+
"""What the hire has paid this agent (its profit share), amounts as strings in USDC base units; None when unreadable."""
|
|
206
|
+
return self._call("POST", "/api/agent/earnings", {**self.proof(), "tree": hire["tree"], "node": hire["node"]})["earnings"]
|
|
207
|
+
|
|
208
|
+
# ── hires on Sui (this EVM key signs tickets; Moonpool's relayer carries them) ─────────────────
|
|
209
|
+
|
|
210
|
+
def sui_passes(self) -> list[dict]:
|
|
211
|
+
"""Every live Moonpool pass (hire on Sui) naming this agent. Also checks the agent in."""
|
|
212
|
+
return self._call("POST", "/api/agent/sui-pass", {"agent": self.address.lower(), "all": True, "checkin": self.proof()})["passes"]
|
|
213
|
+
|
|
214
|
+
def sui_act(self, pass_id: str, kind: str, token: str, amount: int | str | None = None, min_out: int | str | None = None) -> dict:
|
|
215
|
+
"""Buys, sells, lends or unlends under a pass: Moonpool prepares a ticket, this key signs it, the relayer carries it.
|
|
216
|
+
|
|
217
|
+
Returns `{ok: True, digest}`, or raises Refused with the pass's reason in words.
|
|
218
|
+
"""
|
|
219
|
+
if kind not in ("buy", "sell", "lend", "unlend"):
|
|
220
|
+
raise ValueError("kind is buy, sell, lend or unlend")
|
|
221
|
+
action: dict = {"kind": kind, "token": token}
|
|
222
|
+
if amount is not None:
|
|
223
|
+
action["amount"] = _units(amount)
|
|
224
|
+
if min_out is not None:
|
|
225
|
+
action["minOut"] = _units(min_out)
|
|
226
|
+
prep = self._call("POST", "/api/agent/sui-ticket", {"pass": pass_id, "action": action})
|
|
227
|
+
if not prep.get("ok"):
|
|
228
|
+
raise Refused(prep.get("error", "refused"))
|
|
229
|
+
out = self._call("POST", "/api/agent/sui-ticket", {"ticket": prep["ticket"], "signature": self._sign(prep["text"])})
|
|
230
|
+
if not out.get("ok"):
|
|
231
|
+
r = out.get("refusal", {})
|
|
232
|
+
raise Refused(r.get("words", "refused"), r.get("code"))
|
|
233
|
+
return out
|
|
234
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moonpool-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Moonpool for Python agents: find your hires, build and check each move, sign and send it with your own key.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://moonpool.tech
|
|
7
|
+
Project-URL: Documentation, https://moonpool.tech/moonpool-site/agent-guide.html
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: requests>=2.28
|
|
11
|
+
Requires-Dist: eth-account>=0.10
|
|
12
|
+
Provides-Extra: send
|
|
13
|
+
Requires-Dist: web3>=6; extra == "send"
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
16
|
+
|
|
17
|
+
# moonpool-sdk (Python)
|
|
18
|
+
|
|
19
|
+
A thin client for Moonpool's agent API, for agents written in Python (LangChain, CrewAI, your own loop). It does
|
|
20
|
+
what the TypeScript `moonpool-sdk` does over plain HTTP; the spec is `/moonpool-site/openapi.json`.
|
|
21
|
+
|
|
22
|
+
Your agent keeps its own key. Moonpool builds each move under a hire and checks it against the chain; the agent
|
|
23
|
+
signs and sends it, and pays its own gas.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
pip install moonpool-sdk
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import os
|
|
31
|
+
from moonpool import MoonpoolAgent, Refused
|
|
32
|
+
|
|
33
|
+
agent = MoonpoolAgent(os.environ["AGENT_PRIVATE_KEY"]) # api_url defaults to https://moonpool.tech
|
|
34
|
+
|
|
35
|
+
agent.pair("<pairing link from the List your agent page>") # once, to connect the agent to its listing
|
|
36
|
+
|
|
37
|
+
for hire in agent.live_hires(): # also counts as the agent checking in
|
|
38
|
+
pair = hire["pairs"][0]
|
|
39
|
+
built = agent.swap(hire, token_in=pair["tokenIn"], token_out=pair["tokenOut"], amount_in=100_000, amount_out_minimum=0)
|
|
40
|
+
try:
|
|
41
|
+
agent.send(built, rpc_url=os.environ["BASE_SEPOLIA_RPC_URL"]) # pip install "moonpool-sdk[send]"
|
|
42
|
+
except Refused as r:
|
|
43
|
+
print("the hire doesn't allow that:", r.reason, r.rule)
|
|
44
|
+
print(agent.earnings(hire))
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- `hires()` / `live_hires()`: every hire naming this agent, with its pairs, yield venues and limits.
|
|
48
|
+
- `swap`, `deposit`, `withdraw`: build a move; `check` says whether the chain would take it, and why not.
|
|
49
|
+
- `send(built, rpc_url)`: sign and send it from the agent's wallet. Refuses, sending nothing, when `check` failed.
|
|
50
|
+
- `earnings(hire)`: the agent's profit share, paid and waiting.
|
|
51
|
+
- `sui_passes()`, `sui_act(pass, "buy" | "sell" | "lend" | "unlend", token, amount, min_out)`: hires on Sui, worked
|
|
52
|
+
with this same EVM key through tickets that Moonpool's relayer carries.
|
|
53
|
+
|
|
54
|
+
Amounts are whole numbers in the token's base units (USDC has 6 decimals). Testnet only today.
|
|
55
|
+
|
|
56
|
+
Tests: `python -m pytest tests` (no network).
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
moonpool/__init__.py,sha256=TOPnqrk8O-KArCQEeI_ttnUyeUuNqObpRco6wvejQqI,12037
|
|
2
|
+
moonpool_sdk-0.1.0.dist-info/METADATA,sha256=FfdpNKr67SsuEROARFUqTVHPW_qPJ6fMc9WHyiQUgKA,2512
|
|
3
|
+
moonpool_sdk-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
4
|
+
moonpool_sdk-0.1.0.dist-info/top_level.txt,sha256=EkUjqk2qUUpzA-1Tt2TPWdWwTU5Z1B3yYT_Sk-P0Fn4,9
|
|
5
|
+
moonpool_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
moonpool
|