autonomo 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,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .pytest_cache/
autonomo-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Autonomo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.5
2
+ Name: autonomo
3
+ Version: 0.1.0
4
+ Summary: One call for AI agents: find the right paid API on Autonomo, pay in USDG or $AUTONOMO (privately through Gloam, or on Borro credit), within spending caps, and get a receipt.
5
+ Project-URL: Homepage, https://autonomo.fun
6
+ Author: Autonomo
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: ai-agents,autonomo,borro,gloam,payments,robinhood-chain,usdg,x402
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: eth-account>=0.13
12
+ Requires-Dist: httpx>=0.27
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Autonomo SDK for Python
16
+
17
+ One call for AI agents. Say what you need, and the SDK:
18
+
19
+ 1. **Finds the right API** on [Autonomo](https://autonomo.fun), from your words and the kind of input you give
20
+ (a token or wallet address, a transaction hash, a link).
21
+ 2. **Pays for it** with x402 on Robinhood Chain, in USDG or $AUTONOMO, whichever the wallet can afford.
22
+ 3. **Pays privately through [Gloam](https://gloam.trade)** when you ask, so the payment doesn't link to your wallet.
23
+ 4. **Borrows from a [Borro](https://borro.markets) credit line** when the wallet is short of USDG.
24
+ 5. **Keeps to your caps**: a most-per-call and a daily budget, checked before anything is signed.
25
+ 6. **Returns the answer and a receipt**: which API, how it was paid, the amount and the transaction link.
26
+
27
+ ```bash
28
+ pip install autonomo
29
+ ```
30
+
31
+ Full examples for each way to pay: [autonomo.fun/docs/sdk](https://autonomo.fun/docs/sdk).
32
+
33
+ ```python
34
+ import os
35
+ from autonomo import Autonomo
36
+
37
+ autonomo = Autonomo(private_key=os.environ["AUTONOMO_WALLET_KEY"])
38
+
39
+ result = autonomo.call("is this token safe", input="0x86b57bcb9d249cf096cf8e21426c9dc9cafdec49")
40
+ print(result.data)
41
+ print(result.receipt.route, result.receipt.amount, result.receipt.token, result.receipt.explorer)
42
+ ```
43
+
44
+ Use a **new wallet just for the agent**, holding only what it may spend. A little USDG covers many calls (most APIs
45
+ cost 0.01 USDG). Paying in $AUTONOMO needs a little ETH once, for a Permit2 approval.
46
+
47
+ ## Finding APIs without paying
48
+
49
+ ```python
50
+ autonomo = Autonomo() # no key needed to look
51
+ autonomo.find("wallet holdings") # best matches first
52
+ autonomo.quote("explain this transaction", input="0x…") # the API, the URL and every price
53
+ ```
54
+
55
+ To skip the search, name the API: `autonomo.call(slug="token-risk", path="/risk/0x…")`.
56
+
57
+ ## Limits
58
+
59
+ ```python
60
+ Autonomo(
61
+ private_key=...,
62
+ pay_with="auto", # or "usdg" / "autonomo"
63
+ per_call={"usdg": "0.05", "autonomo": "5000"}, # the defaults
64
+ daily={"usdg": "1", "autonomo": "10000"}, # per UTC day; Gloam and Borro count as USDG
65
+ )
66
+ ```
67
+
68
+ A call over a limit is refused before anything is signed, with the reason. Spending is kept in
69
+ `~/.autonomo/spend.json` (shared with the TypeScript SDK); pass `spend_store=MemoryStore()` or your own store to change
70
+ that.
71
+
72
+ ## Private payments with Gloam
73
+
74
+ `private=True` pays from a private Gloam balance through Gloam's own MCP server, `@gloamtrade/mcp`, running on your
75
+ machine with its key and limits in `~/.gloam/agent.env`. The SDK calls its `gloam_fetch_paid` tool; Gloam cashes out
76
+ exactly the price through its relay and calls the API with it. The SDK holds no Gloam keys, and no wallet key is needed.
77
+ See the [SDK guide](https://autonomo.fun/docs/sdk) for the setup and a full example (it uses `pip install mcp`).
78
+
79
+ ```python
80
+ def fetch_paid(args): # runs gloam_fetch_paid on an MCP session with @gloamtrade/mcp
81
+ out = asyncio.run_coroutine_threadsafe(gloam.call_tool("gloam_fetch_paid", args), loop).result()
82
+ return json.loads(out.content[0].text)
83
+
84
+ autonomo = Autonomo(gloam_fetch_paid=fetch_paid)
85
+ autonomo.call("newest launches", private=True)
86
+ ```
87
+
88
+ A private call only ever pays through Gloam. If the API doesn't offer it, or Gloam isn't set up, it's refused, never
89
+ sent as a public payment. The price includes Gloam's 0.5% cash-out fee.
90
+
91
+ ## Borrowing with Borro
92
+
93
+ ```python
94
+ autonomo = Autonomo(private_key=..., borro_account="0xYourCreditLine")
95
+ ```
96
+
97
+ When the wallet can't afford a call, the SDK asks Borro's MCP server to prepare a borrow of just what it's short,
98
+ sends it from the wallet (this needs a little ETH for gas), waits for the USDG and pays. The line needs x402 bills
99
+ turned on; see [Pay with a Borro credit line](https://autonomo.fun/docs/borro). Borro's own daily limit still applies.
100
+
101
+ ## The receipt
102
+
103
+ | Field | |
104
+ | --- | --- |
105
+ | `api` | The API's slug and name |
106
+ | `route` | `usdg`, `autonomo`, `gloam` or `borro` |
107
+ | `amount`, `token` | What was paid |
108
+ | `payer`, `pay_to` | The wallet (or "private (Gloam)") and the API's payment address |
109
+ | `tx`, `explorer` | The payment transaction and its Blockscout link |
110
+ | `borrow_txs`, `approve_tx` | Borro borrows and the one-time Permit2 approval, when there were any |
111
+ | `spent_today` | Today's total against the daily budget |
112
+
113
+ Errors are `AutonomoError` with a `code` such as `cannot_pay`, `over_limit`, `no_private_option` or `payment_refused`.
114
+ If an API fails after a payment is signed, the payment doesn't settle, so you aren't charged.
@@ -0,0 +1,100 @@
1
+ # Autonomo SDK for Python
2
+
3
+ One call for AI agents. Say what you need, and the SDK:
4
+
5
+ 1. **Finds the right API** on [Autonomo](https://autonomo.fun), from your words and the kind of input you give
6
+ (a token or wallet address, a transaction hash, a link).
7
+ 2. **Pays for it** with x402 on Robinhood Chain, in USDG or $AUTONOMO, whichever the wallet can afford.
8
+ 3. **Pays privately through [Gloam](https://gloam.trade)** when you ask, so the payment doesn't link to your wallet.
9
+ 4. **Borrows from a [Borro](https://borro.markets) credit line** when the wallet is short of USDG.
10
+ 5. **Keeps to your caps**: a most-per-call and a daily budget, checked before anything is signed.
11
+ 6. **Returns the answer and a receipt**: which API, how it was paid, the amount and the transaction link.
12
+
13
+ ```bash
14
+ pip install autonomo
15
+ ```
16
+
17
+ Full examples for each way to pay: [autonomo.fun/docs/sdk](https://autonomo.fun/docs/sdk).
18
+
19
+ ```python
20
+ import os
21
+ from autonomo import Autonomo
22
+
23
+ autonomo = Autonomo(private_key=os.environ["AUTONOMO_WALLET_KEY"])
24
+
25
+ result = autonomo.call("is this token safe", input="0x86b57bcb9d249cf096cf8e21426c9dc9cafdec49")
26
+ print(result.data)
27
+ print(result.receipt.route, result.receipt.amount, result.receipt.token, result.receipt.explorer)
28
+ ```
29
+
30
+ Use a **new wallet just for the agent**, holding only what it may spend. A little USDG covers many calls (most APIs
31
+ cost 0.01 USDG). Paying in $AUTONOMO needs a little ETH once, for a Permit2 approval.
32
+
33
+ ## Finding APIs without paying
34
+
35
+ ```python
36
+ autonomo = Autonomo() # no key needed to look
37
+ autonomo.find("wallet holdings") # best matches first
38
+ autonomo.quote("explain this transaction", input="0x…") # the API, the URL and every price
39
+ ```
40
+
41
+ To skip the search, name the API: `autonomo.call(slug="token-risk", path="/risk/0x…")`.
42
+
43
+ ## Limits
44
+
45
+ ```python
46
+ Autonomo(
47
+ private_key=...,
48
+ pay_with="auto", # or "usdg" / "autonomo"
49
+ per_call={"usdg": "0.05", "autonomo": "5000"}, # the defaults
50
+ daily={"usdg": "1", "autonomo": "10000"}, # per UTC day; Gloam and Borro count as USDG
51
+ )
52
+ ```
53
+
54
+ A call over a limit is refused before anything is signed, with the reason. Spending is kept in
55
+ `~/.autonomo/spend.json` (shared with the TypeScript SDK); pass `spend_store=MemoryStore()` or your own store to change
56
+ that.
57
+
58
+ ## Private payments with Gloam
59
+
60
+ `private=True` pays from a private Gloam balance through Gloam's own MCP server, `@gloamtrade/mcp`, running on your
61
+ machine with its key and limits in `~/.gloam/agent.env`. The SDK calls its `gloam_fetch_paid` tool; Gloam cashes out
62
+ exactly the price through its relay and calls the API with it. The SDK holds no Gloam keys, and no wallet key is needed.
63
+ See the [SDK guide](https://autonomo.fun/docs/sdk) for the setup and a full example (it uses `pip install mcp`).
64
+
65
+ ```python
66
+ def fetch_paid(args): # runs gloam_fetch_paid on an MCP session with @gloamtrade/mcp
67
+ out = asyncio.run_coroutine_threadsafe(gloam.call_tool("gloam_fetch_paid", args), loop).result()
68
+ return json.loads(out.content[0].text)
69
+
70
+ autonomo = Autonomo(gloam_fetch_paid=fetch_paid)
71
+ autonomo.call("newest launches", private=True)
72
+ ```
73
+
74
+ A private call only ever pays through Gloam. If the API doesn't offer it, or Gloam isn't set up, it's refused, never
75
+ sent as a public payment. The price includes Gloam's 0.5% cash-out fee.
76
+
77
+ ## Borrowing with Borro
78
+
79
+ ```python
80
+ autonomo = Autonomo(private_key=..., borro_account="0xYourCreditLine")
81
+ ```
82
+
83
+ When the wallet can't afford a call, the SDK asks Borro's MCP server to prepare a borrow of just what it's short,
84
+ sends it from the wallet (this needs a little ETH for gas), waits for the USDG and pays. The line needs x402 bills
85
+ turned on; see [Pay with a Borro credit line](https://autonomo.fun/docs/borro). Borro's own daily limit still applies.
86
+
87
+ ## The receipt
88
+
89
+ | Field | |
90
+ | --- | --- |
91
+ | `api` | The API's slug and name |
92
+ | `route` | `usdg`, `autonomo`, `gloam` or `borro` |
93
+ | `amount`, `token` | What was paid |
94
+ | `payer`, `pay_to` | The wallet (or "private (Gloam)") and the API's payment address |
95
+ | `tx`, `explorer` | The payment transaction and its Blockscout link |
96
+ | `borrow_txs`, `approve_tx` | Borro borrows and the one-time Permit2 approval, when there were any |
97
+ | `spent_today` | Today's total against the daily budget |
98
+
99
+ Errors are `AutonomoError` with a `code` such as `cannot_pay`, `over_limit`, `no_private_option` or `payment_refused`.
100
+ If an API fails after a payment is signed, the payment doesn't settle, so you aren't charged.
@@ -0,0 +1,14 @@
1
+ """Borrow what the wallet is short from a Borro credit line, then pay."""
2
+ import os
3
+
4
+ from autonomo import Autonomo
5
+
6
+ autonomo = Autonomo(
7
+ private_key=os.environ["AUTONOMO_WALLET_KEY"],
8
+ pay_with="usdg",
9
+ borro_account=os.environ["BORRO_CREDIT_LINE"],
10
+ )
11
+
12
+ result = autonomo.call("explain this transaction", input="0x0f1635d41a8fbbde7d61ad39e045e067780b921329112c16955bc26ad3423041")
13
+ print(result.data)
14
+ print(result.receipt.route, result.receipt.borrow_txs, result.receipt.explorer)
@@ -0,0 +1,28 @@
1
+ """Pay privately from a Gloam balance, through Gloam's own MCP server running on this machine.
2
+ It reads its key and limits from ~/.gloam/agent.env (GLOAM_NETWORK=robinhood-mainnet). Needs `pip install mcp`."""
3
+ import asyncio
4
+ import json
5
+
6
+ from mcp import ClientSession, StdioServerParameters
7
+ from mcp.client.stdio import stdio_client
8
+
9
+ from autonomo import Autonomo
10
+
11
+
12
+ async def main():
13
+ server = StdioServerParameters(command="npx", args=["-y", "@gloamtrade/mcp"])
14
+ async with stdio_client(server) as (read, write), ClientSession(read, write) as gloam:
15
+ await gloam.initialize()
16
+ loop = asyncio.get_running_loop()
17
+
18
+ def fetch_paid(args):
19
+ out = asyncio.run_coroutine_threadsafe(gloam.call_tool("gloam_fetch_paid", args), loop).result()
20
+ return json.loads(out.content[0].text)
21
+
22
+ autonomo = Autonomo(gloam_fetch_paid=fetch_paid)
23
+ result = await asyncio.to_thread(autonomo.call, "newest launches", private=True)
24
+ print(result.data)
25
+ print(result.receipt.route, result.receipt.amount, result.receipt.explorer)
26
+
27
+
28
+ asyncio.run(main())
@@ -0,0 +1,14 @@
1
+ """Pay from the agent's own wallet: USDG first, then $AUTONOMO."""
2
+ import os
3
+
4
+ from autonomo import Autonomo
5
+
6
+ autonomo = Autonomo(
7
+ private_key=os.environ["AUTONOMO_WALLET_KEY"],
8
+ per_call={"usdg": "0.02"},
9
+ daily={"usdg": "0.50"},
10
+ )
11
+
12
+ result = autonomo.call("is this token safe", input="0x86b57bcb9d249cf096cf8e21426c9dc9cafdec49")
13
+ print(result.data)
14
+ print(result.receipt.route, result.receipt.amount, result.receipt.token, result.receipt.explorer)
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "autonomo"
7
+ version = "0.1.0"
8
+ description = "One call for AI agents: find the right paid API on Autonomo, pay in USDG or $AUTONOMO (privately through Gloam, or on Borro credit), within spending caps, and get a receipt."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Autonomo" }]
13
+ keywords = ["x402", "autonomo", "ai-agents", "robinhood-chain", "usdg", "payments", "gloam", "borro"]
14
+ dependencies = ["eth-account>=0.13", "httpx>=0.27"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://autonomo.fun"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/autonomo"]
@@ -0,0 +1,720 @@
1
+ """Autonomo SDK: one call that finds the right paid API on Autonomo, pays for it on Robinhood Chain and returns the
2
+ answer with a receipt.
3
+
4
+ from autonomo import Autonomo
5
+ autonomo = Autonomo(private_key=os.environ["AUTONOMO_WALLET_KEY"])
6
+ result = autonomo.call("is this token safe", input="0x…")
7
+ result.data, result.receipt
8
+
9
+ How a call is paid, in order:
10
+ private=True Only through Gloam, from a private balance, so the payment doesn't link to the wallet. Never falls
11
+ back to a public payment.
12
+ otherwise USDG, then $AUTONOMO (or just the one set in pay_with), from the wallet, whichever it can afford.
13
+ If it can't afford either and a Borro credit line is set, it borrows the USDG it's short and pays.
14
+ Every route checks the per-call cap and the daily budget before anything is signed or sent.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import json
21
+ import os
22
+ import re
23
+ import secrets
24
+ import time
25
+ from dataclasses import asdict, dataclass, field
26
+ from datetime import datetime, timezone
27
+ from decimal import Decimal
28
+ from pathlib import Path
29
+ from typing import Any, Callable, Literal, Optional, Protocol
30
+ from urllib.parse import quote as urlquote, urlparse
31
+
32
+ import httpx
33
+ from eth_account import Account
34
+ from eth_utils import to_checksum_address
35
+
36
+ __version__ = "0.1.0"
37
+ __all__ = ["Autonomo", "AutonomoError", "Api", "Receipt", "CallResult", "MemoryStore", "FileStore", "rank_apis", "find_transactions"]
38
+
39
+ NETWORK = "eip155:4663"
40
+ CHAIN_ID = 4663
41
+ EXPLORER = "https://robinhoodchain.blockscout.com"
42
+ PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3"
43
+ PERMIT2_PROXY = "0x402085c248EeA27D92E8b30b2C58ed07f9E20001" # x402's exact-scheme Permit2 proxy
44
+ BORRO_MCP = "https://borro.markets/mcp"
45
+ MAX_UINT256 = 2**256 - 1
46
+
47
+ TokenName = Literal["usdg", "autonomo"]
48
+ Route = Literal["usdg", "autonomo", "gloam", "borro"]
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Token:
53
+ name: str
54
+ symbol: str
55
+ address: str
56
+ decimals: int
57
+
58
+
59
+ TOKENS = {
60
+ "usdg": Token("usdg", "USDG", "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", 6),
61
+ "autonomo": Token("autonomo", "AUTONOMO", "0xe83c85e1ead16fcfe85401e2b337fb4fb5b5cc96", 18),
62
+ }
63
+ DEFAULT_PER_CALL = {"usdg": "0.05", "autonomo": "5000"}
64
+ DEFAULT_DAILY = {"usdg": "1", "autonomo": "10000"}
65
+
66
+
67
+ class AutonomoError(Exception):
68
+ def __init__(self, code: str, message: str):
69
+ super().__init__(message)
70
+ self.code = code
71
+
72
+
73
+ def _fail(code: str, message: str):
74
+ raise AutonomoError(code, message)
75
+
76
+
77
+ def _units(amount: str, decimals: int) -> int:
78
+ return int(Decimal(amount) * (10**decimals))
79
+
80
+
81
+ def _fmt(amount: int, decimals: int) -> str:
82
+ s = format(Decimal(amount) / (Decimal(10) ** decimals), "f")
83
+ return s.rstrip("0").rstrip(".") if "." in s else s
84
+
85
+
86
+ def _today() -> str:
87
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d")
88
+
89
+
90
+ def _b64(obj: Any) -> str:
91
+ return base64.b64encode(json.dumps(obj, separators=(",", ":")).encode()).decode()
92
+
93
+
94
+ def _unb64(h: Optional[str]) -> Optional[dict]:
95
+ if not h:
96
+ return None
97
+ try:
98
+ return json.loads(base64.b64decode(h))
99
+ except Exception:
100
+ return None
101
+
102
+
103
+ # ---------- Public types ----------
104
+
105
+
106
+ @dataclass
107
+ class Api:
108
+ slug: str
109
+ name: str
110
+ description: str
111
+ path: str
112
+ price: dict
113
+ verified: bool
114
+ success_rate: Optional[float]
115
+ paid_calls_30d: int
116
+
117
+
118
+ @dataclass
119
+ class Receipt:
120
+ api: dict
121
+ url: str
122
+ route: str
123
+ amount: str
124
+ token: str
125
+ payer: str
126
+ pay_to: str
127
+ tx: Optional[str]
128
+ explorer: Optional[str]
129
+ borrow_txs: list = field(default_factory=list)
130
+ approve_tx: Optional[str] = None
131
+ at: str = ""
132
+ spent_today: str = ""
133
+
134
+ def to_dict(self) -> dict:
135
+ return asdict(self)
136
+
137
+
138
+ @dataclass
139
+ class CallResult:
140
+ data: Any
141
+ receipt: Receipt
142
+
143
+
144
+ class SpendStore(Protocol):
145
+ def get(self, day: str) -> dict: ...
146
+ def add(self, day: str, token: str, amount: int) -> None: ...
147
+
148
+
149
+ class MemoryStore:
150
+ """Keeps spending in memory only, for tests or short-lived agents."""
151
+
152
+ def __init__(self):
153
+ self.days: dict[str, dict[str, int]] = {}
154
+
155
+ def get(self, day: str) -> dict:
156
+ return dict(self.days.get(day, {}))
157
+
158
+ def add(self, day: str, token: str, amount: int) -> None:
159
+ d = self.days.setdefault(day, {})
160
+ d[token] = d.get(token, 0) + amount
161
+
162
+
163
+ class FileStore:
164
+ """Today's spending in a JSON file, ~/.autonomo/spend.json by default (shared with the TypeScript SDK)."""
165
+
166
+ def __init__(self, path: Optional[str] = None):
167
+ self.path = Path(path) if path else Path.home() / ".autonomo" / "spend.json"
168
+
169
+ def _read(self) -> dict:
170
+ try:
171
+ return json.loads(self.path.read_text())
172
+ except Exception:
173
+ return {}
174
+
175
+ def get(self, day: str) -> dict:
176
+ s = self._read()
177
+ if s.get("date") != day:
178
+ return {}
179
+ return {k: int(s.get(k, 0)) for k in ("usdg", "autonomo")}
180
+
181
+ def add(self, day: str, token: str, amount: int) -> None:
182
+ s = self._read()
183
+ cur = s if s.get("date") == day else {"date": day}
184
+ cur[token] = str(int(cur.get(token, 0)) + amount)
185
+ self.path.parent.mkdir(parents=True, exist_ok=True)
186
+ self.path.write_text(json.dumps(cur))
187
+
188
+
189
+ # ---------- Finding the right API ----------
190
+
191
+
192
+ def _kind_of(value: Optional[str]) -> Optional[str]:
193
+ if value is None:
194
+ return None
195
+ if re.fullmatch(r"0x[0-9a-fA-F]{64}", value):
196
+ return "tx"
197
+ if re.fullmatch(r"0x[0-9a-fA-F]{40}", value):
198
+ return "address"
199
+ if re.match(r"https?://", value, re.I):
200
+ return "url"
201
+ return "text"
202
+
203
+
204
+ def _placeholders(path: str) -> list[str]:
205
+ return [m.lower() for m in re.findall(r"<([^>]+)>", path)]
206
+
207
+
208
+ def _fits(path: str, kind: Optional[str]) -> bool:
209
+ ph = _placeholders(path)
210
+ if kind is None:
211
+ return not ph
212
+ if len(ph) != 1:
213
+ return False
214
+ p = ph[0]
215
+ if kind == "tx":
216
+ return bool(re.search(r"transaction|hash", p))
217
+ if kind == "address":
218
+ return "address" in p
219
+ if kind == "url":
220
+ return bool(re.search(r"url|link", p))
221
+ return not re.search(r"address|hash|url|link", p)
222
+
223
+
224
+ def _fill(path: str, value: Optional[str]) -> str:
225
+ if value is None:
226
+ return path
227
+
228
+ def repl(m: re.Match) -> str:
229
+ return urlquote(value, safe="") if re.search(r"url|link", m.group(1), re.I) else value
230
+
231
+ return re.sub(r"<([^>]+)>", repl, path, count=1)
232
+
233
+
234
+ def rank_apis(apis: list[Api], query: str, input: Optional[str] = None) -> list[Api]:
235
+ """Ranks APIs for a query and input: word matches first, then a fitting path, then verified and the track record."""
236
+ words = [w for w in re.split(r"[^a-z0-9$]+", query.lower()) if len(w) > 2]
237
+ kind = _kind_of(input)
238
+ scored = []
239
+ for a in apis:
240
+ if not _fits(a.path, kind):
241
+ continue
242
+ hay = f"{a.slug} {a.name} {a.description}".lower()
243
+ hits = sum(1 for w in words if w in hay)
244
+ if hits == 0 and words:
245
+ continue
246
+ rate = a.success_rate if a.success_rate is not None else 0.5
247
+ trust = (1 if a.verified else 0) + rate + min(a.paid_calls_30d, 100) / 1000
248
+ scored.append((hits, trust, a))
249
+ scored.sort(key=lambda s: (-s[0], -s[1]))
250
+ return [s[2] for s in scored]
251
+
252
+
253
+ # ---------- Typed data ----------
254
+
255
+
256
+ def eip3009_typed_data(auth: dict, asset: str, name: str, version: str) -> dict:
257
+ return {
258
+ "domain": {"name": name, "version": version, "chainId": CHAIN_ID, "verifyingContract": to_checksum_address(asset)},
259
+ "types": {
260
+ "TransferWithAuthorization": [
261
+ {"name": "from", "type": "address"},
262
+ {"name": "to", "type": "address"},
263
+ {"name": "value", "type": "uint256"},
264
+ {"name": "validAfter", "type": "uint256"},
265
+ {"name": "validBefore", "type": "uint256"},
266
+ {"name": "nonce", "type": "bytes32"},
267
+ ]
268
+ },
269
+ "message": {
270
+ "from": to_checksum_address(auth["from"]),
271
+ "to": to_checksum_address(auth["to"]),
272
+ "value": int(auth["value"]),
273
+ "validAfter": int(auth["validAfter"]),
274
+ "validBefore": int(auth["validBefore"]),
275
+ "nonce": bytes.fromhex(auth["nonce"][2:]),
276
+ },
277
+ }
278
+
279
+
280
+ def permit2_typed_data(auth: dict) -> dict:
281
+ return {
282
+ "domain": {"name": "Permit2", "chainId": CHAIN_ID, "verifyingContract": PERMIT2},
283
+ "types": {
284
+ "PermitWitnessTransferFrom": [
285
+ {"name": "permitted", "type": "TokenPermissions"},
286
+ {"name": "spender", "type": "address"},
287
+ {"name": "nonce", "type": "uint256"},
288
+ {"name": "deadline", "type": "uint256"},
289
+ {"name": "witness", "type": "Witness"},
290
+ ],
291
+ "TokenPermissions": [{"name": "token", "type": "address"}, {"name": "amount", "type": "uint256"}],
292
+ "Witness": [{"name": "to", "type": "address"}, {"name": "validAfter", "type": "uint256"}],
293
+ },
294
+ "message": {
295
+ "permitted": {"token": to_checksum_address(auth["permitted"]["token"]), "amount": int(auth["permitted"]["amount"])},
296
+ "spender": to_checksum_address(auth["spender"]),
297
+ "nonce": int(auth["nonce"]),
298
+ "deadline": int(auth["deadline"]),
299
+ "witness": {"to": to_checksum_address(auth["witness"]["to"]), "validAfter": int(auth["witness"]["validAfter"])},
300
+ },
301
+ }
302
+
303
+
304
+ def _sign_typed(key: str, typed: dict) -> str:
305
+ signed = Account.sign_typed_data(key, domain_data=typed["domain"], message_types=typed["types"], message_data=typed["message"])
306
+ return "0x" + bytes(signed.signature).hex()
307
+
308
+
309
+ def find_transactions(value: Any) -> list[dict]:
310
+ """Every transaction ({to, data}) inside a tool result, in order, skipping the typed data to sign."""
311
+ out: list[dict] = []
312
+
313
+ def walk(v: Any) -> None:
314
+ if isinstance(v, list):
315
+ for x in v:
316
+ walk(x)
317
+ return
318
+ if not isinstance(v, dict):
319
+ return
320
+ to, data = v.get("to"), v.get("data")
321
+ if isinstance(to, str) and re.fullmatch(r"0x[0-9a-fA-F]{40}", to) and isinstance(data, str) and re.fullmatch(r"0x[0-9a-fA-F]+", data):
322
+ out.append({"to": to, "data": data, "value": None if v.get("value") is None else str(v["value"])})
323
+ return
324
+ for k, x in v.items():
325
+ if k != "typed_data":
326
+ walk(x)
327
+
328
+ walk(value)
329
+ return out
330
+
331
+
332
+ # ---------- The client ----------
333
+
334
+ GloamFetchPaid = Callable[[dict], dict]
335
+ """Runs Gloam's gloam_fetch_paid tool (from `npx -y @gloamtrade/mcp`) with these arguments ({url, maxAmount, prefer})
336
+ and returns its result: Gloam cashes out exactly the quoted USDG from your private balance through its relay, retries the
337
+ request with the payment, and answers {"status": "ok", "hash": ..., "body": ..., "httpStatus": ...}. The SDK holds no
338
+ Gloam keys."""
339
+
340
+
341
+ class Autonomo:
342
+ def __init__(
343
+ self,
344
+ private_key: Optional[str] = None,
345
+ pay_with: str = "auto",
346
+ per_call: Optional[dict] = None,
347
+ daily: Optional[dict] = None,
348
+ gloam_fetch_paid: Optional[GloamFetchPaid] = None,
349
+ borro_account: Optional[str] = None,
350
+ borro_mcp_url: str = BORRO_MCP,
351
+ spend_store: Optional[SpendStore] = None,
352
+ base_url: str = "https://autonomo.fun",
353
+ rpc_url: str = "https://rpc.mainnet.chain.robinhood.com",
354
+ http: Optional[httpx.Client] = None,
355
+ ):
356
+ key = (private_key or "").strip()
357
+ if key and not re.fullmatch(r"0x[0-9a-fA-F]{64}", key):
358
+ _fail("bad_key", "private_key should be 0x followed by 64 hex characters.")
359
+ self._key = key or None
360
+ self.address: Optional[str] = Account.from_key(key).address if key else None
361
+ if pay_with not in ("auto", "usdg", "autonomo"):
362
+ _fail("bad_option", 'pay_with should be "auto", "usdg" or "autonomo".')
363
+ self.pay_with = pay_with
364
+ pc = {**DEFAULT_PER_CALL, **(per_call or {})}
365
+ dl = {**DEFAULT_DAILY, **(daily or {})}
366
+ self.per_call = {t: _units(pc[t], TOKENS[t].decimals) for t in TOKENS}
367
+ self.daily = {t: _units(dl[t], TOKENS[t].decimals) for t in TOKENS}
368
+ self.gloam_fetch_paid = gloam_fetch_paid
369
+ self.borro_account = to_checksum_address(borro_account) if borro_account else None
370
+ self.borro_mcp_url = borro_mcp_url
371
+ self.store: SpendStore = spend_store or FileStore()
372
+ self.base = base_url.rstrip("/")
373
+ self.rpc_url = rpc_url
374
+ self.http = http or httpx.Client(timeout=30, follow_redirects=False)
375
+
376
+ # ----- Public -----
377
+
378
+ def find(self, query: str = "", input: Optional[str] = None) -> list[Api]:
379
+ """Every Autonomo API, or the best matches for a query and input. Free."""
380
+ res = self.http.get(f"{self.base}/api/v1/gateways", timeout=15)
381
+ if res.status_code == 503:
382
+ _fail("updating", "Autonomo is being updated right now. Try again in a few minutes.")
383
+ if res.status_code != 200:
384
+ _fail("http_error", f"/api/v1/gateways answered {res.status_code}.")
385
+ apis = [
386
+ Api(
387
+ slug=g["slug"],
388
+ name=g["name"],
389
+ description=g.get("description") or "",
390
+ path=g.get("usage") or "/",
391
+ price=g.get("price") or {},
392
+ verified=bool(g.get("verified")),
393
+ success_rate=(g.get("trackRecord") or {}).get("successRate"),
394
+ paid_calls_30d=(g.get("trackRecord") or {}).get("paidCalls") or 0,
395
+ )
396
+ for g in res.json()["data"]
397
+ ]
398
+ return rank_apis(apis, query, input) if (query or input is not None) else apis
399
+
400
+ def quote(self, query: str = "", input: Optional[str] = None, slug: Optional[str] = None, path: Optional[str] = None) -> dict:
401
+ """What a call would cost, without paying."""
402
+ api, url = self._resolve(query, input, slug, path)
403
+ required = self._price_list(url)
404
+ offers = []
405
+ for a in required["accepts"]:
406
+ t = _token_by_asset(a["asset"])
407
+ offers.append(
408
+ {
409
+ "scheme": a["scheme"],
410
+ "token": t.symbol if t else a["asset"],
411
+ "amount": _fmt(int(a["amount"]), t.decimals if t else 18),
412
+ "private": a["scheme"] == "gloam-cashout",
413
+ }
414
+ )
415
+ return {"api": api, "url": url, "offers": offers}
416
+
417
+ def call(
418
+ self,
419
+ query: str = "",
420
+ input: Optional[str] = None,
421
+ slug: Optional[str] = None,
422
+ path: Optional[str] = None,
423
+ private: bool = False,
424
+ pay_with: Optional[str] = None,
425
+ ) -> CallResult:
426
+ """Finds the right API, pays for one call and returns its answer with a receipt."""
427
+ if not private and not self._key:
428
+ _fail("no_wallet", "No wallet set. Pass private_key (a wallet just for this agent).")
429
+ api, url = self._resolve(query, input, slug, path)
430
+ required = self._price_list(url)
431
+ day = _today()
432
+ spent = self.store.get(day)
433
+
434
+ def within(token: Token, amount: int) -> Optional[str]:
435
+ price = f"{_fmt(amount, token.decimals)} {token.symbol}"
436
+ if amount > self.per_call[token.name]:
437
+ return f"{price} is above the per-call cap of {_fmt(self.per_call[token.name], token.decimals)} {token.symbol}"
438
+ so_far = spent.get(token.name, 0)
439
+ if so_far + amount > self.daily[token.name]:
440
+ return f"{price} would pass today's {token.symbol} budget ({_fmt(so_far, token.decimals)} of {_fmt(self.daily[token.name], token.decimals)} spent)"
441
+ return None
442
+
443
+ def finish(route: str, token: Token, amount: int, res: httpx.Response, tx: Optional[str], extra: dict) -> CallResult:
444
+ if res.status_code != 200:
445
+ reason = (_unb64(res.headers.get("payment-required")) or {}).get("error")
446
+ detail = ""
447
+ try:
448
+ j = res.json()
449
+ detail = j.get("message") or j.get("error") or ""
450
+ except Exception:
451
+ pass
452
+ _fail(
453
+ "payment_refused",
454
+ f"Payment refused: {reason}" if reason else f"The API answered {res.status_code}{': ' + detail if detail else ''}. You aren't charged unless it settled.",
455
+ )
456
+ self.store.add(day, token.name, amount)
457
+ after = self.store.get(day).get(token.name, 0)
458
+ try:
459
+ data = res.json()
460
+ except Exception:
461
+ data = res.text
462
+ receipt = Receipt(
463
+ api={"slug": api.slug, "name": api.name},
464
+ url=url,
465
+ route=route,
466
+ amount=_fmt(amount, token.decimals),
467
+ token=token.symbol,
468
+ payer="private (Gloam)" if route == "gloam" else self.address,
469
+ pay_to=extra.get("pay_to", ""),
470
+ tx=tx,
471
+ explorer=f"{EXPLORER}/tx/{tx}" if tx else None,
472
+ borrow_txs=extra.get("borrow_txs", []),
473
+ approve_tx=extra.get("approve_tx"),
474
+ at=datetime.now(timezone.utc).isoformat(),
475
+ spent_today=f"{_fmt(after, token.decimals)} of {_fmt(self.daily[token.name], token.decimals)} {token.symbol}",
476
+ )
477
+ return CallResult(data=data, receipt=receipt)
478
+
479
+ # Private: Gloam only.
480
+ if private:
481
+ if not self.gloam_fetch_paid:
482
+ _fail("no_gloam", "private=True needs Gloam set up (gloam_fetch_paid=...).")
483
+ offer = next((a for a in required["accepts"] if a["scheme"] == "gloam-cashout"), None)
484
+ if not offer:
485
+ _fail("no_private_option", f"{api.name} doesn't offer private payment.")
486
+ amount = int(offer["amount"])
487
+ why = within(TOKENS["usdg"], amount)
488
+ if why:
489
+ _fail("over_limit", f"Not paid: {why}.")
490
+ # Gloam asks for its own quote, whose tag can differ by under 0.001 USDG; never more than the per-call cap.
491
+ most = min(amount + 1000, self.per_call["usdg"])
492
+ r = self.gloam_fetch_paid({"url": url, "maxAmount": float(_fmt(most, 6)), "prefer": "gloam-cashout"})
493
+ if r.get("status") == "paid_not_served":
494
+ self.store.add(day, "usdg", amount)
495
+ _fail(
496
+ "paid_not_served",
497
+ f"Paid through Gloam, but the API didn't answer yet: {r.get('message', '')} Retry gloam_fetch_paid with this paymentHeader, don't pay again: {r.get('paymentHeader')}",
498
+ )
499
+ if r.get("status") != "ok":
500
+ _fail("payment_refused", f"Gloam didn't pay: {r.get('message') or r.get('error') or r.get('code') or r.get('status')}")
501
+ res = httpx.Response(r.get("httpStatus") or 200, content=(r.get("body") or "").encode())
502
+ return finish("gloam", TOKENS["usdg"], amount, res, r.get("hash"), {"pay_to": to_checksum_address(offer["payTo"])})
503
+
504
+ # Public: the wallet's tokens, then Borro.
505
+ want = pay_with or self.pay_with
506
+ order = ["usdg", "autonomo"] if want == "auto" else [want]
507
+ reasons: list[str] = []
508
+ usdg_short: Optional[tuple[dict, int]] = None
509
+ for name in order:
510
+ token = TOKENS[name]
511
+ offer = next(
512
+ (a for a in required["accepts"] if a["scheme"] == "exact" and a["network"] == NETWORK and a["asset"].lower() == token.address.lower()),
513
+ None,
514
+ )
515
+ if not offer:
516
+ reasons.append(f"{api.name} doesn't take {token.symbol}")
517
+ continue
518
+ amount = int(offer["amount"])
519
+ why = within(token, amount)
520
+ if why:
521
+ reasons.append(why)
522
+ continue
523
+ balance = self._balance(token.address, self.address)
524
+ if balance < amount:
525
+ reasons.append(f"the wallet holds {_fmt(balance, token.decimals)} {token.symbol}, the call costs {_fmt(amount, token.decimals)}")
526
+ if name == "usdg":
527
+ usdg_short = (offer, amount)
528
+ continue
529
+ res, tx, extra = self._pay_exact(url, required, offer)
530
+ return finish(name, token, amount, res, tx, extra)
531
+
532
+ if self.borro_account and usdg_short:
533
+ offer, amount = usdg_short
534
+ borrow_txs = self._borrow(url, offer)
535
+ res, tx, extra = self._pay_exact(url, required, offer)
536
+ extra["borrow_txs"] = [f"{EXPLORER}/tx/{h}" for h in borrow_txs]
537
+ return finish("borro", TOKENS["usdg"], amount, res, tx, extra)
538
+ _fail("cannot_pay", f"Not paid: {'; '.join(reasons)}.")
539
+
540
+ # ----- Internals -----
541
+
542
+ def _gateway_url(self, slug: str, path: str) -> str:
543
+ if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,63}", slug):
544
+ _fail("bad_slug", "slug should look like token-risk.")
545
+ p = path if path.startswith("/") else f"/{path}"
546
+ if ".." in p or p.startswith("//") or re.search(r"[\s\\<>]", p):
547
+ _fail("bad_path", f"The path {p} isn't a plain, filled-in path.")
548
+ url = f"{self.base}/x/{slug}{p}"
549
+ u, b = urlparse(url), urlparse(self.base)
550
+ if (u.scheme, u.netloc) != (b.scheme, b.netloc) or not (u.path == f"/x/{slug}" or u.path.startswith(f"/x/{slug}/")):
551
+ _fail("bad_path", "That path leaves the API.")
552
+ return url
553
+
554
+ def _resolve(self, query: str, input: Optional[str], slug: Optional[str], path: Optional[str]) -> tuple[Api, str]:
555
+ if slug:
556
+ api = next((a for a in self.find() if a.slug == slug), None)
557
+ if not api:
558
+ _fail("not_found", f"No Autonomo API called {slug}.")
559
+ else:
560
+ matches = self.find(query, input)
561
+ if not matches:
562
+ kind = _kind_of(input)
563
+ _fail("not_found", f'No Autonomo API matches "{query}"' + (f" for a {kind}" if kind and kind != "text" else "") + ". Use find() to see them all.")
564
+ api = matches[0]
565
+ return api, self._gateway_url(api.slug, path or _fill(api.path, input))
566
+
567
+ def _price_list(self, url: str) -> dict:
568
+ res = self.http.get(url, timeout=20)
569
+ required = _unb64(res.headers.get("payment-required"))
570
+ if not required and res.status_code == 402:
571
+ try:
572
+ required = res.json()
573
+ except Exception:
574
+ required = None
575
+ if res.status_code == 503:
576
+ _fail("updating", "This API is paused or being updated. Try again later.")
577
+ if res.status_code != 402 or not isinstance((required or {}).get("accepts"), list):
578
+ _fail("no_price", f"Expected a 402 price list from {url}, got {res.status_code}.")
579
+ return required
580
+
581
+ def _pay_exact(self, url: str, required: dict, offer: dict) -> tuple[httpx.Response, Optional[str], dict]:
582
+ """Signs an x402 exact payment (EIP-3009 for USDG, Permit2 for $AUTONOMO) and sends the request with it."""
583
+ now = int(time.time())
584
+ timeout = int(offer.get("maxTimeoutSeconds") or 300)
585
+ extra_in = offer.get("extra") or {}
586
+ approve_tx = None
587
+ if extra_in.get("assetTransferMethod") == "permit2":
588
+ if self._allowance(offer["asset"], self.address, PERMIT2) < int(offer["amount"]):
589
+ data = "0x095ea7b3" + _word(PERMIT2) + format(MAX_UINT256, "064x")
590
+ approve_tx = f"{EXPLORER}/tx/{self._send({'to': to_checksum_address(offer['asset']), 'data': data})}"
591
+ auth = {
592
+ "from": self.address,
593
+ "permitted": {"token": to_checksum_address(offer["asset"]), "amount": offer["amount"]},
594
+ "spender": PERMIT2_PROXY,
595
+ "nonce": str(int.from_bytes(secrets.token_bytes(32), "big")),
596
+ "deadline": str(now + timeout),
597
+ "witness": {"to": to_checksum_address(offer["payTo"]), "validAfter": "0"},
598
+ }
599
+ payload = {"signature": _sign_typed(self._key, permit2_typed_data(auth)), "permit2Authorization": auth}
600
+ else:
601
+ name, version = extra_in.get("name"), extra_in.get("version")
602
+ if not name or not version:
603
+ _fail("bad_offer", "The price list is missing the token's EIP-712 name and version.")
604
+ auth = {
605
+ "from": self.address,
606
+ "to": to_checksum_address(offer["payTo"]),
607
+ "value": offer["amount"],
608
+ "validAfter": "0",
609
+ "validBefore": str(now + timeout),
610
+ "nonce": "0x" + secrets.token_hex(32),
611
+ }
612
+ payload = {"authorization": auth, "signature": _sign_typed(self._key, eip3009_typed_data(auth, offer["asset"], name, version))}
613
+
614
+ if required.get("x402Version") == 1:
615
+ envelope = {"x402Version": 1, "scheme": offer["scheme"], "network": offer["network"], "payload": payload}
616
+ headers = {"X-PAYMENT": _b64(envelope)}
617
+ else:
618
+ envelope = {"x402Version": required["x402Version"], "payload": payload, "resource": required.get("resource"), "accepted": offer}
619
+ if required.get("extensions") is not None:
620
+ envelope["extensions"] = required["extensions"]
621
+ headers = {"PAYMENT-SIGNATURE": _b64(envelope)}
622
+ res = self.http.get(url, headers=headers, timeout=60)
623
+ settled = _unb64(res.headers.get("payment-response") or res.headers.get("x-payment-response")) or {}
624
+ return res, settled.get("transaction"), {"approve_tx": approve_tx, "pay_to": to_checksum_address(offer["payTo"])}
625
+
626
+ def _borrow(self, url: str, offer: dict) -> list[str]:
627
+ """Asks Borro for the borrow that covers what the wallet is short, sends it and waits for the USDG to land."""
628
+ required = self._price_list(url)
629
+ result = _mcp_call(
630
+ self.http,
631
+ self.borro_mcp_url,
632
+ "credit_bill",
633
+ {"wallet": self.address, "account": self.borro_account, "payment_required": _b64(required), "max_usdg": _fmt(self.per_call["usdg"], 6)},
634
+ )
635
+ hashes = [self._send(t) for t in find_transactions(result)]
636
+ need = int(offer["amount"])
637
+ for _ in range(20):
638
+ if self._balance(TOKENS["usdg"].address, self.address) >= need:
639
+ return hashes
640
+ time.sleep(1)
641
+ _fail("borrow_failed", "The borrowed USDG hasn't reached the wallet yet. Nothing was paid; try again shortly.")
642
+
643
+ # ----- Chain -----
644
+
645
+ def _rpc(self, method: str, params: list) -> Any:
646
+ res = self.http.post(self.rpc_url, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, timeout=20)
647
+ body = res.json()
648
+ if "error" in body:
649
+ _fail("rpc_error", f"{method}: {body['error'].get('message')}")
650
+ return body["result"]
651
+
652
+ def _balance(self, token: str, owner: str) -> int:
653
+ return int(self._rpc("eth_call", [{"to": token, "data": "0x70a08231" + _word(owner)}, "latest"]), 16)
654
+
655
+ def _allowance(self, token: str, owner: str, spender: str) -> int:
656
+ return int(self._rpc("eth_call", [{"to": token, "data": "0xdd62ed3e" + _word(owner) + _word(spender)}, "latest"]), 16)
657
+
658
+ def _send(self, tx: dict) -> str:
659
+ """Signs and sends a transaction from the wallet, waits for it, and returns its hash. Needs a little ETH for gas."""
660
+ value = int(tx.get("value") or 0, 0) if isinstance(tx.get("value"), str) else int(tx.get("value") or 0)
661
+ base = {"from": self.address, "to": to_checksum_address(tx["to"]), "data": tx["data"], "value": hex(value)}
662
+ gas = int(int(self._rpc("eth_estimateGas", [base]), 16) * 1.2)
663
+ signed = Account.sign_transaction(
664
+ {
665
+ "to": base["to"],
666
+ "data": tx["data"],
667
+ "value": value,
668
+ "gas": gas,
669
+ "gasPrice": int(self._rpc("eth_gasPrice", []), 16),
670
+ "nonce": int(self._rpc("eth_getTransactionCount", [self.address, "pending"]), 16),
671
+ "chainId": CHAIN_ID,
672
+ },
673
+ self._key,
674
+ )
675
+ h = self._rpc("eth_sendRawTransaction", ["0x" + bytes(signed.raw_transaction).hex()])
676
+ for _ in range(60):
677
+ r = self._rpc("eth_getTransactionReceipt", [h])
678
+ if r:
679
+ if r.get("status") != "0x1":
680
+ _fail("tx_failed", f"Transaction failed: {EXPLORER}/tx/{h}")
681
+ return h
682
+ time.sleep(1)
683
+ _fail("tx_timeout", f"Transaction not confirmed yet: {EXPLORER}/tx/{h}")
684
+
685
+
686
+ def _word(address: str) -> str:
687
+ return address.lower().replace("0x", "").rjust(64, "0")
688
+
689
+
690
+ def _token_by_asset(asset: str) -> Optional[Token]:
691
+ return next((t for t in TOKENS.values() if t.address.lower() == asset.lower()), None)
692
+
693
+
694
+ def _mcp_call(http: httpx.Client, url: str, name: str, args: dict) -> Any:
695
+ """Calls one tool on an MCP server over plain JSON-RPC and returns its result, parsed from JSON when it's JSON."""
696
+ res = http.post(
697
+ url,
698
+ json={"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}},
699
+ headers={"accept": "application/json, text/event-stream"},
700
+ timeout=30,
701
+ )
702
+ text = res.text
703
+ if text.startswith("event:") or text.startswith("data:"):
704
+ text = "".join(line[5:] for line in text.splitlines() if line.startswith("data:"))
705
+ try:
706
+ body = json.loads(text)
707
+ except Exception:
708
+ _fail("borro_error", f"Borro answered {res.status_code}.")
709
+ if body.get("error"):
710
+ _fail("borro_error", f"Borro: {body['error'].get('message', 'error')}")
711
+ result = body.get("result") or {}
712
+ out = "".join(c.get("text", "") for c in result.get("content") or [])
713
+ if result.get("isError"):
714
+ _fail("borro_refused", f"Borro: {out}")
715
+ if result.get("structuredContent") is not None:
716
+ return result["structuredContent"]
717
+ try:
718
+ return json.loads(out)
719
+ except Exception:
720
+ return out
@@ -0,0 +1,173 @@
1
+ """Offline tests: no network, no wallet balance, no payments. Run with `pytest`."""
2
+
3
+ import base64
4
+ import json
5
+ from datetime import datetime, timezone
6
+
7
+ import httpx
8
+ import pytest
9
+ from eth_account import Account
10
+ from eth_account.messages import encode_typed_data
11
+
12
+ from autonomo import Api, Autonomo, AutonomoError, MemoryStore, find_transactions, rank_apis
13
+ from autonomo import eip3009_typed_data, permit2_typed_data
14
+
15
+ KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" # a throwaway test key
16
+ USDG = "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168"
17
+ AUTONOMO = "0xe83c85e1ead16fcfe85401e2b337fb4fb5b5cc96"
18
+ PAY_TO = "0x00000000000000000000000000000000000000aa"
19
+ ADDR = "0x86b57bcb9d249cf096cf8e21426c9dc9cafdec49"
20
+ TX = "0x" + "ab" * 32
21
+
22
+ APIS = [
23
+ ("token-price", "Token Price", "show live $autonomo token price", "/price/autonomo", 38),
24
+ ("new-launches", "New Launches", "Newest token launches on Robinhood Chain", "/launches?limit=10", 24),
25
+ ("token-risk", "Token Risk", "Rug-risk check for a launchpad token: holders, creator, safety score", "/risk/<token contract address>", 10),
26
+ ("token-lookup", "Token Lookup", "Price and market cap of any launchpad token", "/price/<token contract address>", 5),
27
+ ("tx-explainer", "Transaction Explainer", "Explains a transaction in plain words", "/tx/<transaction hash>", 3),
28
+ ("wallet-portfolio", "Wallet Portfolio", "What a wallet holds, with USD values", "/portfolio/<wallet address>", 3),
29
+ ("page-reader", "Page Reader", "Reads a web page and returns its text", "/page?url=<url-encoded https:// link>", 2),
30
+ ]
31
+ API_OBJS = [Api(s, n, d, p, {}, True, 1.0, c) for s, n, d, p, c in APIS]
32
+
33
+
34
+ def test_finds_the_right_api():
35
+ assert rank_apis(API_OBJS, "is this token a rug risk", ADDR)[0].slug == "token-risk"
36
+ assert rank_apis(API_OBJS, "token price", ADDR)[0].slug == "token-lookup"
37
+ assert rank_apis(API_OBJS, "what does this wallet hold", ADDR)[0].slug == "wallet-portfolio"
38
+ assert rank_apis(API_OBJS, "explain this transaction", TX)[0].slug == "tx-explainer"
39
+ assert rank_apis(API_OBJS, "read this page", "https://example.com/a?b=c")[0].slug == "page-reader"
40
+ assert rank_apis(API_OBJS, "newest launches")[0].slug == "new-launches"
41
+ assert rank_apis(API_OBJS, "explain this transaction", ADDR) == []
42
+
43
+
44
+ def _recover(typed, sig):
45
+ msg = encode_typed_data(domain_data=typed["domain"], message_types=typed["types"], message_data=typed["message"])
46
+ return Account.recover_message(msg, signature=sig)
47
+
48
+
49
+ def test_typed_data_recovers_to_the_wallet():
50
+ acct = Account.from_key(KEY)
51
+ usdg = eip3009_typed_data(
52
+ {"from": acct.address, "to": PAY_TO, "value": "10000", "validAfter": "0", "validBefore": "9999999999", "nonce": "0x" + "11" * 32}, USDG, "Global Dollar", "1"
53
+ )
54
+ sig = Account.sign_typed_data(KEY, domain_data=usdg["domain"], message_types=usdg["types"], message_data=usdg["message"]).signature
55
+ assert _recover(usdg, sig) == acct.address
56
+ p2 = permit2_typed_data(
57
+ {"permitted": {"token": AUTONOMO, "amount": "100"}, "spender": "0x402085c248EeA27D92E8b30b2C58ed07f9E20001", "nonce": "5", "deadline": "9999999999", "witness": {"to": PAY_TO, "validAfter": "0"}}
58
+ )
59
+ sig = Account.sign_typed_data(KEY, domain_data=p2["domain"], message_types=p2["types"], message_data=p2["message"]).signature
60
+ assert _recover(p2, sig) == acct.address
61
+
62
+
63
+ def test_finds_borro_borrow_transaction():
64
+ result = {
65
+ "borrow": {"to": "0x1111111111111111111111111111111111111111", "data": "0xabcdef", "value": "0"},
66
+ "typed_data": {"message": {"from": "0x2222222222222222222222222222222222222222", "to": PAY_TO, "value": "10000"}},
67
+ "authorization": {"to": PAY_TO, "value": "10000"},
68
+ }
69
+ assert find_transactions(result) == [{"to": "0x1111111111111111111111111111111111111111", "data": "0xabcdef", "value": "0"}]
70
+
71
+
72
+ def _exact(asset, amount, extra=None):
73
+ return {"scheme": "exact", "network": "eip155:4663", "asset": asset, "amount": amount, "payTo": PAY_TO, "maxTimeoutSeconds": 300, "extra": extra or {}}
74
+
75
+
76
+ def _fake(accepts, on_paid=None):
77
+ sent = []
78
+
79
+ def handler(request: httpx.Request):
80
+ sent.append(request)
81
+ if request.url.path == "/api/v1/gateways":
82
+ data = [{"slug": s, "name": n, "description": d, "usage": p, "verified": True, "trackRecord": {"successRate": 1, "paidCalls": c}} for s, n, d, p, c in APIS]
83
+ return httpx.Response(200, json={"data": data})
84
+ if on_paid and (request.headers.get("x-payment") or request.headers.get("payment-signature")):
85
+ return on_paid(request)
86
+ required = {"x402Version": 2, "accepts": accepts, "resource": {"url": str(request.url)}}
87
+ return httpx.Response(402, json={}, headers={"payment-required": base64.b64encode(json.dumps(required).encode()).decode()})
88
+
89
+ return httpx.Client(transport=httpx.MockTransport(handler)), sent
90
+
91
+
92
+ def test_refuses_over_the_per_call_cap_before_signing():
93
+ client, sent = _fake([_exact(USDG, "100000", {"name": "Global Dollar", "version": "1"})]) # 0.10 USDG
94
+ a = Autonomo(private_key=KEY, pay_with="usdg", http=client, spend_store=MemoryStore())
95
+ with pytest.raises(AutonomoError) as e:
96
+ a.call("newest launches")
97
+ assert e.value.code == "cannot_pay" and "per-call cap" in str(e.value)
98
+ assert not any(r.headers.get("payment-signature") for r in sent)
99
+
100
+
101
+ def test_refuses_when_todays_budget_is_used():
102
+ store = MemoryStore()
103
+ store.add(datetime.now(timezone.utc).strftime("%Y-%m-%d"), "usdg", 995_000)
104
+ client, _ = _fake([_exact(USDG, "10000", {"name": "Global Dollar", "version": "1"})])
105
+ a = Autonomo(private_key=KEY, pay_with="usdg", http=client, spend_store=store)
106
+ with pytest.raises(AutonomoError) as e:
107
+ a.call("newest launches")
108
+ assert e.value.code == "cannot_pay" and "budget" in str(e.value)
109
+
110
+
111
+ def test_private_never_falls_back_to_public():
112
+ client, _ = _fake([_exact(USDG, "10000", {"name": "Global Dollar", "version": "1"})])
113
+ a = Autonomo(private_key=KEY, http=client, spend_store=MemoryStore(), gloam_fetch_paid=lambda args: pytest.fail("no Gloam option"))
114
+ with pytest.raises(AutonomoError) as e:
115
+ a.call("newest launches", private=True)
116
+ assert e.value.code == "no_private_option"
117
+ b = Autonomo(private_key=KEY, http=client, spend_store=MemoryStore())
118
+ with pytest.raises(AutonomoError) as e:
119
+ b.call("newest launches", private=True)
120
+ assert e.value.code == "no_gloam"
121
+
122
+
123
+ def test_pays_privately_through_gloam():
124
+ gloam = {"scheme": "gloam-cashout", "network": "eip155:4663", "asset": USDG, "amount": "10051", "payTo": PAY_TO, "extra": {"quoteId": "q1"}}
125
+ client, _ = _fake([_exact(USDG, "10000", {"name": "Global Dollar", "version": "1"}), gloam])
126
+ store = MemoryStore()
127
+ calls = []
128
+ h = "0x" + "cd" * 32
129
+
130
+ def fetch_paid(args):
131
+ calls.append(args)
132
+ return {"status": "ok", "hash": h, "httpStatus": 200, "body": json.dumps({"ok": True})}
133
+
134
+ a = Autonomo(http=client, spend_store=store, gloam_fetch_paid=fetch_paid) # no wallet key needed
135
+ result = a.call("newest launches", private=True)
136
+ assert result.data == {"ok": True}
137
+ assert calls[0] == {"url": "https://autonomo.fun/x/new-launches/launches?limit=10", "maxAmount": 0.011051, "prefer": "gloam-cashout"}
138
+ r = result.receipt
139
+ assert (r.route, r.payer, r.amount, r.tx) == ("gloam", "private (Gloam)", "0.010051", h)
140
+ assert store.get(datetime.now(timezone.utc).strftime("%Y-%m-%d"))["usdg"] == 10051
141
+
142
+
143
+ def test_gloam_refusal_pays_and_records_nothing():
144
+ gloam = {"scheme": "gloam-cashout", "network": "eip155:4663", "asset": USDG, "amount": "10051", "payTo": PAY_TO, "extra": {"quoteId": "q1"}}
145
+ client, _ = _fake([gloam])
146
+ store = MemoryStore()
147
+ a = Autonomo(private_key=KEY, http=client, spend_store=store, gloam_fetch_paid=lambda args: {"status": "refused", "code": "over_daily", "message": "over the daily limit"})
148
+ with pytest.raises(AutonomoError) as e:
149
+ a.call("newest launches", private=True)
150
+ assert e.value.code == "payment_refused" and "over the daily limit" in str(e.value)
151
+ assert store.get(datetime.now(timezone.utc).strftime("%Y-%m-%d")) == {}
152
+
153
+
154
+ def test_signed_usdg_payment_header():
155
+ """The wallet has the USDG (faked), so the SDK signs and sends a PAYMENT-SIGNATURE header that recovers to it."""
156
+ seen = {}
157
+
158
+ def paid(request):
159
+ seen["env"] = json.loads(base64.b64decode(request.headers["payment-signature"]))
160
+ settled = base64.b64encode(json.dumps({"transaction": "0x" + "ef" * 32}).encode()).decode()
161
+ return httpx.Response(200, json={"ok": 1}, headers={"payment-response": settled})
162
+
163
+ client, _ = _fake([_exact(USDG, "10000", {"name": "Global Dollar", "version": "1"})], on_paid=paid)
164
+ a = Autonomo(private_key=KEY, pay_with="usdg", http=client, spend_store=MemoryStore())
165
+ a._balance = lambda token, owner: 10**9
166
+ result = a.call("newest launches")
167
+ env = seen["env"]
168
+ assert env["x402Version"] == 2 and env["accepted"]["amount"] == "10000" and "extensions" not in env
169
+ auth = env["payload"]["authorization"]
170
+ assert auth["from"] == a.address and auth["value"] == "10000"
171
+ typed = eip3009_typed_data(auth, USDG, "Global Dollar", "1")
172
+ assert _recover(typed, env["payload"]["signature"]) == a.address
173
+ assert result.receipt.route == "usdg" and result.receipt.tx == "0x" + "ef" * 32