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.
@@ -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,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ moonpool/__init__.py
4
+ moonpool_sdk.egg-info/PKG-INFO
5
+ moonpool_sdk.egg-info/SOURCES.txt
6
+ moonpool_sdk.egg-info/dependency_links.txt
7
+ moonpool_sdk.egg-info/requires.txt
8
+ moonpool_sdk.egg-info/top_level.txt
9
+ tests/test_client.py
@@ -0,0 +1,8 @@
1
+ requests>=2.28
2
+ eth-account>=0.10
3
+
4
+ [send]
5
+ web3>=6
6
+
7
+ [test]
8
+ pytest>=7
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"}}