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.
- autonomo-0.1.0/.gitignore +5 -0
- autonomo-0.1.0/LICENSE +21 -0
- autonomo-0.1.0/PKG-INFO +114 -0
- autonomo-0.1.0/README.md +100 -0
- autonomo-0.1.0/examples/borro.py +14 -0
- autonomo-0.1.0/examples/gloam.py +28 -0
- autonomo-0.1.0/examples/pay.py +14 -0
- autonomo-0.1.0/pyproject.toml +20 -0
- autonomo-0.1.0/src/autonomo/__init__.py +720 -0
- autonomo-0.1.0/tests/test_sdk.py +173 -0
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.
|
autonomo-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
autonomo-0.1.0/README.md
ADDED
|
@@ -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
|