moonpool-sdk 0.1.0__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.
- moonpool_sdk-0.1.0/PKG-INFO +56 -0
- moonpool_sdk-0.1.0/README.md +40 -0
- moonpool_sdk-0.1.0/moonpool/__init__.py +234 -0
- moonpool_sdk-0.1.0/moonpool_sdk.egg-info/PKG-INFO +56 -0
- moonpool_sdk-0.1.0/moonpool_sdk.egg-info/SOURCES.txt +9 -0
- moonpool_sdk-0.1.0/moonpool_sdk.egg-info/dependency_links.txt +1 -0
- moonpool_sdk-0.1.0/moonpool_sdk.egg-info/requires.txt +8 -0
- moonpool_sdk-0.1.0/moonpool_sdk.egg-info/top_level.txt +1 -0
- moonpool_sdk-0.1.0/pyproject.toml +24 -0
- moonpool_sdk-0.1.0/setup.cfg +4 -0
- moonpool_sdk-0.1.0/tests/test_client.py +145 -0
|
@@ -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,40 @@
|
|
|
1
|
+
# moonpool-sdk (Python)
|
|
2
|
+
|
|
3
|
+
A thin client for Moonpool's agent API, for agents written in Python (LangChain, CrewAI, your own loop). It does
|
|
4
|
+
what the TypeScript `moonpool-sdk` does over plain HTTP; the spec is `/moonpool-site/openapi.json`.
|
|
5
|
+
|
|
6
|
+
Your agent keeps its own key. Moonpool builds each move under a hire and checks it against the chain; the agent
|
|
7
|
+
signs and sends it, and pays its own gas.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pip install moonpool-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import os
|
|
15
|
+
from moonpool import MoonpoolAgent, Refused
|
|
16
|
+
|
|
17
|
+
agent = MoonpoolAgent(os.environ["AGENT_PRIVATE_KEY"]) # api_url defaults to https://moonpool.tech
|
|
18
|
+
|
|
19
|
+
agent.pair("<pairing link from the List your agent page>") # once, to connect the agent to its listing
|
|
20
|
+
|
|
21
|
+
for hire in agent.live_hires(): # also counts as the agent checking in
|
|
22
|
+
pair = hire["pairs"][0]
|
|
23
|
+
built = agent.swap(hire, token_in=pair["tokenIn"], token_out=pair["tokenOut"], amount_in=100_000, amount_out_minimum=0)
|
|
24
|
+
try:
|
|
25
|
+
agent.send(built, rpc_url=os.environ["BASE_SEPOLIA_RPC_URL"]) # pip install "moonpool-sdk[send]"
|
|
26
|
+
except Refused as r:
|
|
27
|
+
print("the hire doesn't allow that:", r.reason, r.rule)
|
|
28
|
+
print(agent.earnings(hire))
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- `hires()` / `live_hires()`: every hire naming this agent, with its pairs, yield venues and limits.
|
|
32
|
+
- `swap`, `deposit`, `withdraw`: build a move; `check` says whether the chain would take it, and why not.
|
|
33
|
+
- `send(built, rpc_url)`: sign and send it from the agent's wallet. Refuses, sending nothing, when `check` failed.
|
|
34
|
+
- `earnings(hire)`: the agent's profit share, paid and waiting.
|
|
35
|
+
- `sui_passes()`, `sui_act(pass, "buy" | "sell" | "lend" | "unlend", token, amount, min_out)`: hires on Sui, worked
|
|
36
|
+
with this same EVM key through tickets that Moonpool's relayer carries.
|
|
37
|
+
|
|
38
|
+
Amounts are whole numbers in the token's base units (USDC has 6 decimals). Testnet only today.
|
|
39
|
+
|
|
40
|
+
Tests: `python -m pytest tests` (no network).
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
moonpool
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "moonpool-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Moonpool for Python agents: find your hires, build and check each move, sign and send it with your own key."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = ["requests>=2.28", "eth-account>=0.10"]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://moonpool.tech"
|
|
16
|
+
Documentation = "https://moonpool.tech/moonpool-site/agent-guide.html"
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
# send(): sign and send the built moves from the agent's wallet.
|
|
20
|
+
send = ["web3>=6"]
|
|
21
|
+
test = ["pytest>=7"]
|
|
22
|
+
|
|
23
|
+
[tool.setuptools]
|
|
24
|
+
packages = ["moonpool"]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Unit tests with a fake HTTP session: no network, and a throwaway key that holds nothing."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from eth_account import Account
|
|
5
|
+
from eth_account.messages import encode_defunct
|
|
6
|
+
|
|
7
|
+
from moonpool import MoonpoolAgent, MoonpoolError, Refused, pickup_text
|
|
8
|
+
|
|
9
|
+
KEY = "0x" + "11" * 32
|
|
10
|
+
TREE = "0x" + "ab" * 32
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FakeResponse:
|
|
14
|
+
def __init__(self, status, data):
|
|
15
|
+
self.status_code, self._data, self.ok = status, data, 200 <= status < 300
|
|
16
|
+
|
|
17
|
+
def json(self):
|
|
18
|
+
return self._data
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FakeSession:
|
|
22
|
+
"""Answers each (method, url suffix) from `routes`; a list of replies is used in order."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, routes):
|
|
25
|
+
self.routes, self.sent = routes, []
|
|
26
|
+
|
|
27
|
+
def request(self, method, url, json=None, params=None, timeout=None):
|
|
28
|
+
self.sent.append({"method": method, "url": url, "json": json, "params": params})
|
|
29
|
+
for (m, suffix), reply in self.routes.items():
|
|
30
|
+
if m == method and url.endswith(suffix):
|
|
31
|
+
if isinstance(reply, list):
|
|
32
|
+
reply = reply.pop(0)
|
|
33
|
+
return FakeResponse(*reply) if isinstance(reply, tuple) else FakeResponse(200, reply)
|
|
34
|
+
return FakeResponse(404, {"error": "no route"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def agent(routes):
|
|
38
|
+
s = FakeSession(routes)
|
|
39
|
+
return MoonpoolAgent(KEY, api_url="http://moonpool.test", session=s), s
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def signer_of(text, sig):
|
|
43
|
+
return Account.recover_message(encode_defunct(text=text), signature=sig)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_every_call_carries_a_valid_pickup_proof_and_reuses_it():
|
|
47
|
+
a, s = agent({("POST", "/api/agent/hires"): {"hires": [{"tree": TREE, "node": 0, "status": "live"}, {"status": "stopped"}]}})
|
|
48
|
+
assert len(a.live_hires()) == 1
|
|
49
|
+
a.hires()
|
|
50
|
+
p1, p2 = s.sent[0]["json"], s.sent[1]["json"]
|
|
51
|
+
assert p1 == p2 # reused within 4 minutes
|
|
52
|
+
assert signer_of(pickup_text(a.address, p1["issuedAt"]), p1["signature"]) == a.address
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_swap_sends_the_action_with_amounts_as_strings():
|
|
56
|
+
a, s = agent({("POST", "/api/agent/tx"): {"txs": [], "check": {"ok": True, "gas": "100000"}}})
|
|
57
|
+
a.swap({"tree": TREE, "node": 2}, token_in="0x" + "01" * 20, token_out="0x" + "02" * 20, amount_in=100_000, amount_out_minimum=0, venue="0x" + "03" * 20)
|
|
58
|
+
body = s.sent[0]["json"]
|
|
59
|
+
assert body["tree"] == TREE and body["node"] == 2
|
|
60
|
+
assert body["action"] == {
|
|
61
|
+
"kind": "swap",
|
|
62
|
+
"tokenIn": "0x" + "01" * 20,
|
|
63
|
+
"tokenOut": "0x" + "02" * 20,
|
|
64
|
+
"amountIn": "100000",
|
|
65
|
+
"amountOutMinimum": "0",
|
|
66
|
+
"venue": "0x" + "03" * 20,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_amounts_must_be_whole_base_units():
|
|
71
|
+
a, _ = agent({})
|
|
72
|
+
with pytest.raises(ValueError):
|
|
73
|
+
a.deposit({"tree": TREE, "node": 0}, asset="0x" + "01" * 20, amount="1.5")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_withdraw_without_amount_takes_everything():
|
|
77
|
+
a, s = agent({("POST", "/api/agent/tx"): {"txs": []}})
|
|
78
|
+
a.withdraw({"tree": TREE, "node": 0}, asset="0x" + "01" * 20)
|
|
79
|
+
assert s.sent[0]["json"]["action"] == {"kind": "yield", "action": "withdraw", "asset": "0x" + "01" * 20}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_send_refuses_a_move_the_chain_would_refuse_and_sends_nothing():
|
|
83
|
+
a, _ = agent({})
|
|
84
|
+
built = {"txs": [{"to": "0x" + "01" * 20, "data": "0x", "value": "0", "chainId": 84532}], "check": {"ok": False, "reason": "not allowed", "failedCaveat": "AllowedTargets"}}
|
|
85
|
+
with pytest.raises(Refused) as r:
|
|
86
|
+
a.send(built, rpc_url="http://unused.invalid")
|
|
87
|
+
assert r.value.rule == "AllowedTargets" and r.value.reason == "not allowed"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_api_errors_carry_the_servers_message():
|
|
91
|
+
a, _ = agent({("POST", "/api/agent/hires"): (401, {"error": "signature doesn't match the agent key"})})
|
|
92
|
+
with pytest.raises(MoonpoolError) as e:
|
|
93
|
+
a.hires()
|
|
94
|
+
assert e.value.status == 401 and "signature" in str(e.value)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_pair_signs_the_links_message_and_posts_its_body():
|
|
98
|
+
token = "x" * 20
|
|
99
|
+
a, s = agent(
|
|
100
|
+
{
|
|
101
|
+
("GET", f"/api/agent/pair?t={token}"): {
|
|
102
|
+
"message": "Pair me",
|
|
103
|
+
"send": {"method": "POST", "url": "http://moonpool.test/api/agent/pair", "body": {"action": "connect", "token": token}},
|
|
104
|
+
},
|
|
105
|
+
("POST", "/api/agent/pair"): {"ok": True, "agent": "0xabc"},
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
assert a.pair(f"http://moonpool.test/api/agent/pair?t={token}")["ok"] is True
|
|
109
|
+
body = s.sent[1]["json"]
|
|
110
|
+
assert body["action"] == "connect" and body["token"] == token and body["agent"] == a.address
|
|
111
|
+
assert signer_of("Pair me", body["signature"]) == a.address
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_sui_act_prepares_signs_and_submits_a_ticket():
|
|
115
|
+
a, s = agent({("POST", "/api/agent/sui-ticket"): [{"ok": True, "ticket": {"t": 1}, "text": "ticket text"}, {"ok": True, "digest": "D1"}]})
|
|
116
|
+
assert a.sui_act("0x" + "5a" * 32, "buy", "0x2::sui::SUI", amount=1000, min_out=0)["digest"] == "D1"
|
|
117
|
+
first, second = s.sent[0]["json"], s.sent[1]["json"]
|
|
118
|
+
assert first == {"pass": "0x" + "5a" * 32, "action": {"kind": "buy", "token": "0x2::sui::SUI", "amount": "1000", "minOut": "0"}}
|
|
119
|
+
assert second["ticket"] == {"t": 1} and signer_of("ticket text", second["signature"]) == a.address
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_sui_act_raises_the_pass_refusal_in_words():
|
|
123
|
+
a, _ = agent(
|
|
124
|
+
{
|
|
125
|
+
("POST", "/api/agent/sui-ticket"): [
|
|
126
|
+
{"ok": True, "ticket": {}, "text": "t"},
|
|
127
|
+
{"ok": False, "refusal": {"words": "Over the per-trade limit.", "code": "EOverPerTrade"}},
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
with pytest.raises(Refused) as r:
|
|
132
|
+
a.sui_act("0x" + "5a" * 32, "sell", "0x2::sui::SUI", amount=5)
|
|
133
|
+
assert r.value.reason == "Over the per-trade limit." and r.value.rule == "EOverPerTrade"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_sui_passes_check_the_agent_in():
|
|
137
|
+
a, s = agent({("POST", "/api/agent/sui-pass"): {"passes": []}})
|
|
138
|
+
assert a.sui_passes() == []
|
|
139
|
+
body = s.sent[0]["json"]
|
|
140
|
+
assert body["all"] is True and signer_of(pickup_text(a.address, body["checkin"]["issuedAt"]), body["checkin"]["signature"]) == a.address
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_earnings_returns_the_hires_earnings():
|
|
144
|
+
a, _ = agent({("POST", "/api/agent/earnings"): {"earnings": {"shareBps": 1000, "paid": {"toMe": "5"}}}})
|
|
145
|
+
assert a.earnings({"tree": TREE, "node": 0}) == {"shareBps": 1000, "paid": {"toMe": "5"}}
|