aster-agent-gateway 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,189 @@
1
+ Metadata-Version: 2.5
2
+ Name: aster-agent-gateway
3
+ Version: 0.1.0
4
+ Summary: MCP gateway for AI agents to Aster DEX public data: futures and spot market overviews, order books, klines, funding and TradFi-perp screeners, vault deposit flows, public account views. Read-only, keyless.
5
+ Project-URL: Homepage, https://github.com/alekskram/aster-agent-gateway
6
+ Project-URL: Repository, https://github.com/alekskram/aster-agent-gateway
7
+ Project-URL: Issues, https://github.com/alekskram/aster-agent-gateway/issues
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai-agents,aster,asterdex,defi,funding,mcp,perps
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Office/Business :: Financial :: Investment
18
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: fastmcp>=3.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: hatchling; extra == 'dev'
23
+ Requires-Dist: pip-audit; extra == 'dev'
24
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
25
+ Requires-Dist: pytest>=8; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # aster-agent-gateway
29
+
30
+ [![CI](https://github.com/alekskram/aster-agent-gateway/actions/workflows/tests.yml/badge.svg)](https://github.com/alekskram/aster-agent-gateway/actions/workflows/tests.yml)
31
+ [![PyPI](https://img.shields.io/pypi/v/aster-agent-gateway.svg)](https://pypi.org/project/aster-agent-gateway/)
32
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
33
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](pyproject.toml)
34
+
35
+ An MCP (Model Context Protocol) server that gives AI agents read-only,
36
+ keyless access to **Aster DEX** public data - ~580 futures symbols
37
+ (including 24/7 TradFi perps: metals, equity indices, energy,
38
+ treasuries), ~68 spot pairs, funding, order books, klines, vault
39
+ deposit flows and tapi account views. No API keys, no auth, no
40
+ signing, no writes: every tool reads public endpoints only
41
+ (`fapi.asterdex.com/fapi/v3`, `sapi.asterdex.com/api/v3`,
42
+ `tapi.asterdex.com/info`, `api.mainnet-beta.solana.com`, plus optional
43
+ EVM RPCs), cached and rate-limited so an enthusiastic agent cannot
44
+ hammer the upstream.
45
+
46
+ ## Use cases
47
+
48
+ - **Screen 731 funding rates in one call** — ranked by annualized rate with funding regime and mark/index spread; the 793% outliers are visible instantly
49
+ - **Trade TradFi 24/7** — metals, equity indices, energy, treasuries perps next to crypto, one consistent API
50
+ - **Follow the vault money** — deposit flows per vault: who is parking capital where
51
+ - **Spot index dislocations** — mark vs index divergence ranking across the whole board
52
+ - **Morning scan** — market overview + funding overview + OI snapshot, three cheap calls
53
+
54
+ Full walkthroughs: [examples/use-cases.md](examples/use-cases.md).
55
+
56
+ ## Quickstart
57
+
58
+ stdio (default, for local agents):
59
+
60
+ ```bash
61
+ uvx aster-agent-gateway
62
+ ```
63
+
64
+ or from a checkout:
65
+
66
+ ```bash
67
+ git clone https://github.com/alekskram/aster-agent-gateway
68
+ cd aster-agent-gateway
69
+ uv sync
70
+ uv run aster-agent-gateway
71
+ ```
72
+
73
+ Claude Desktop / Cursor config:
74
+
75
+ ```json
76
+ {
77
+ "mcpServers": {
78
+ "aster": {
79
+ "command": "uvx",
80
+ "args": ["--from",
81
+ "git+https://github.com/alekskram/aster-agent-gateway",
82
+ "aster-agent-gateway"]
83
+ }
84
+ }
85
+ }
86
+ ```
87
+
88
+ Hosted form - streamable HTTP on port **8904**:
89
+
90
+ ```bash
91
+ uv run aster-agent-gateway --http # 127.0.0.1:8904
92
+ curl http://127.0.0.1:8904/health # -> {"ok": true, "service": "aster-agent-gateway", ...}
93
+ ```
94
+
95
+ <details>
96
+ <summary><b>Codex</b> (~/.codex/config.toml)</summary>
97
+
98
+ ```toml
99
+ [mcp_servers.aster]
100
+ command = "uvx"
101
+ args = ["aster-agent-gateway"]
102
+ ```
103
+ </details>
104
+
105
+ <details>
106
+ <summary><b>ZCode</b> — register the server (copy-paste)</summary>
107
+
108
+ ```bash
109
+ # 1) start the gateway (keep it running)
110
+ uvx aster-agent-gateway --http --port 8904 &
111
+
112
+ # 2) register it (merges into ~/.zcode/cli/config.json)
113
+ python3 - <<'PY'
114
+ import json, os
115
+ p = os.path.expanduser("~/.zcode/cli/config.json")
116
+ os.makedirs(os.path.dirname(p), exist_ok=True)
117
+ cfg = json.load(open(p)) if os.path.exists(p) else {}
118
+ cfg.setdefault("mcp", {}).setdefault("servers", {})["aster"] = {
119
+ "type": "http", "url": "http://127.0.0.1:8904/mcp"}
120
+ json.dump(cfg, open(p, "w"), indent=2)
121
+ print("aster-agent-gateway registered:", p)
122
+ PY
123
+ ```
124
+ </details>
125
+
126
+ Hosted form — streamable HTTP on port **8904**:
127
+
128
+ ```bash
129
+ uvx aster-agent-gateway --http
130
+ ```
131
+
132
+ ## Tools
133
+
134
+ All 13 tools are read-only (annotated `readOnlyHint: true,
135
+ destructiveHint: false`).
136
+
137
+ | # | Tool | Signature | What it does |
138
+ |---|------|-----------|--------------|
139
+ | 1 | `market_overview` | `market_overview(limit=20, sort="volume")` | Futures panel from ONE ticker/24hr ALL call joined with exchangeInfo: TRADING markets, top volumes, status counts, fresh listings (onboardDate). |
140
+ | 2 | `exchange_symbols` | `exchange_symbols(venue="futures", symbol=None, include_junk=False)` | Symbol universe with filters/precisions incl. MIN_NOTIONAL, stepSize, leverageFilter; TEST*/SETTLING junk filtered by default. |
141
+ | 3 | `order_book` | `order_book(symbol, venue="futures", depth=10)` | fapi depth / sapi api/v3 depth; limit snapped to a priced tier (weight-aware). |
142
+ | 4 | `klines` | `klines(symbol, interval="1h", limit=100, market="futures", price_type="last")` | last/mark/index klines on fapi; spot via sapi. |
143
+ | 5 | `trades` | `trades(symbol, limit=20, venue="futures")` | Fresh keyless trades; spot path probed live (honest error dict if dead). |
144
+ | 6 | `spot_overview` | `spot_overview(limit=20)` | Spot pairs from sapi ticker/24hr + exchangeInfo; TEST* junk filtered. |
145
+ | 7 | `funding_overview` | `funding_overview(limit=20, sort="rate")` | All ~730 rates from premiumIndex + fundingInfo: mixed 1/2/4/8h intervals (flagged), cap/floor, interestRate, nextFundingTime. |
146
+ | 8 | `tradfi_markets` | `tradfi_markets(limit=20, window=None)` | TradFi-perp screener by asset class (metals/equity/energy/treasuries/forex) + `tradfi_crypto_corr` sub-block: local TradFi-vs-BTC correlation from klines. |
147
+ | 9 | `funding_screener` | `funding_screener(top=10, direction="both")` | One-call ranking by annualized funding, premium, mark-index spread + `funding_regime` headroom to cap/floor. |
148
+ | 10 | `oi_snapshot` | `oi_snapshot(symbols=None, top=10)` | Per-symbol openInterest (max 10 symbols/call). No keyless OI history (404) - stated honestly. |
149
+ | 11 | `deposit_flows` | `deposit_flows(chain="all", limit=20)` | Solana vault signatures (keyless) + EVM vault Transfers when `ASTER_EVM_RPC_URL*` set + `deposit_stats` hourly/chain buckets. |
150
+ | 12 | `account_view` | `account_view(address, data="balance")` | tapi `aster_getBalance`/`openOrders`/`userFills` keyless for any address; privacy-empty returns an honest error dict explaining why. |
151
+ | 13 | `mark_index_divergence` | `mark_index_divergence(limit=20)` | mark vs index spread screener from ONE premiumIndex call + markPriceKlines-vs-klines crosscheck on the top 3. |
152
+
153
+ ## Rate limits
154
+
155
+ Two REST buckets, locally enforced and weight-aware:
156
+
157
+ - **fapi 2400 weight/min**, **sapi 6000/min** (header
158
+ `X-MBX-USED-WEIGHT-1M` read after every call). Above 80% of a
159
+ bucket the client self-throttles; 429 backs off honoring
160
+ `Retry-After`; a 418 (repeated 429 = IP ban) triggers a 60s refusal
161
+ cooldown. Depth/kline limits snap to priced tiers so callers cannot
162
+ accidentally burn weight.
163
+ - **Solana 10 req/min, tapi 30/min, EVM 20/min** local budgets
164
+ (separate ledgers, fail-fast honest errors).
165
+
166
+ TTL caches: exchangeInfo 3600s, fundingInfo 600s, premiumIndex and
167
+ tickers 15s, depth 5s, klines 60s, trades 10s, openInterest 30s.
168
+
169
+ ## Data notes
170
+
171
+ - Every numeric from the API is a STRING upstream; parsed with a
172
+ never-raising helper - `null` always means "not available", never
173
+ zero.
174
+ - Every upstream failure returns an error dict
175
+ `{"error", "source", "reason"}`, never a traceback.
176
+ - **No OI history exists keyless** (`/futures/data/openInterestHist`
177
+ 404s) - the gateway says so instead of inventing data.
178
+ - **EVM vault logs** need `ASTER_EVM_RPC_URL` (or per-chain
179
+ `ASTER_EVM_RPC_URL_{BSC,ETH,ARB}`); free public RPCs reject vault
180
+ queries with -32005. Unset -> honest "no RPC configured".
181
+ - **tapi account privacy**: most accounts are private; empty results
182
+ are reported as privacy, not as data.
183
+ - Cached responses carry `age_seconds` / `fetched_at` freshness
184
+ fields.
185
+
186
+
187
+ ## License
188
+
189
+ MIT.
@@ -0,0 +1,10 @@
1
+ aster_mcp/API_NOTES.md,sha256=LDIPy0Tdoktsb8y6jGML_5apzvK-yWKoPXBRRNU2fTE,4885
2
+ aster_mcp/__init__.py,sha256=l12zxeqIENwd4xbClVO1kfAuZ-wmfJIqFZCetiDAfoE,533
3
+ aster_mcp/chain.py,sha256=GMDMow4P1IGTuqdrIx-Ord82ZBWfY8GmKEl54x6fkfM,11256
4
+ aster_mcp/rest.py,sha256=ZrApV4gzKraqsaOYoWYsCrQab3HN_WdImmcQ6S_VHig,16251
5
+ aster_mcp/server.py,sha256=OD3_LxH9Y4ABVwTvb_cNhWwzyelIvT1p-dN18XtZjDo,48059
6
+ aster_agent_gateway-0.1.0.dist-info/METADATA,sha256=aFo1iJ_35Y7PkebdCchPkx8kzwuzzctEWmihmcs2pfA,8558
7
+ aster_agent_gateway-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ aster_agent_gateway-0.1.0.dist-info/entry_points.txt,sha256=VxYNjpiJndfRIL2_0ocomSfJiVVWd_2zw0q65BvrNYM,62
9
+ aster_agent_gateway-0.1.0.dist-info/licenses/LICENSE,sha256=ViMP6VKf71rJhWk_KyCZFUL_8oOL-_Dpq3SRfpO4vJU,1089
10
+ aster_agent_gateway-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aster-agent-gateway = aster_mcp.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 aster-agent-gateway contributors
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.
aster_mcp/API_NOTES.md ADDED
@@ -0,0 +1,99 @@
1
+ # API notes - Aster public surfaces (as verified 2026-09-05/06)
2
+
3
+ Facts the gateway depends on. Every claim below was checked against the
4
+ live endpoints before v0.1.0 was coded; fixtures in `tests/fixtures/`
5
+ mirror these shapes.
6
+
7
+ ## fapi - futures (https://fapi.asterdex.com/fapi/v3/*)
8
+
9
+ - Binance-compatible v3 REST, keyless for market data. ALL numerics
10
+ arrive as STRINGS ("0.0001"); parse with a never-raising `_f()`.
11
+ - **Rate limits: weight-based per IP, 2400 weight/min** on fapi
12
+ (header `X-MBX-USED-WEIGHT-1M` on every response). 429 -> backoff
13
+ honoring `Retry-After`; repeated 429 -> **418 IP ban** (the client
14
+ cools down 60s and refuses calls).
15
+ - `exchangeInfo` (weight 1): ~580 symbols; statuses ~559 TRADING /
16
+ 15 SETTLING / 6 PENDING; quotes USDT 568 / USD1 10; symbol rows
17
+ carry `onboardDate`, `contractType`, `status`, `filters`
18
+ (PRICE_FILTER tickSize, LOT_SIZE stepSize, MIN_NOTIONAL,
19
+ LEVERAGE_FILTER).
20
+ - `ticker/24hr` with NO symbol param (weight ~40): ALL ~574 rows in
21
+ ONE call - symbol, lastPrice, priceChangePercent, quoteVolume,
22
+ volume, highPrice, lowPrice, weightedAvgPrice, count.
23
+ - `premiumIndex` no param (weight ~10): ALL ~730 rows - symbol,
24
+ markPrice, indexPrice, lastFundingRate, nextFundingTime,
25
+ interestRate.
26
+ - `fundingInfo` (weight 1): ~730 rows - fundingIntervalHours MIXED
27
+ (1/2/4/8h histogram ~351/3/103/273), cap 0.003-0.03, floor -0.03,
28
+ interestRate.
29
+ - `depth?symbol=&limit=` - weight by limit: 5/10/20/50 = 2, 100 = 5,
30
+ 500 = 10, 1000 = 20 (client snaps requests down to a priced tier).
31
+ - `klines` / `markPriceKlines` / `indexPriceKlines` -
32
+ `?symbol=&interval=&limit=`; weight 1 (limit<=100) .. 5.
33
+ - `/trades?symbol=` - fresh keyless trades.
34
+ - `openInterest?symbol=` - per-symbol snapshot ONLY (weight 1).
35
+ **`/futures/data/openInterestHist` returns 404** - no keyless OI
36
+ history exists; the gateway says so instead of inventing data.
37
+
38
+ ## sapi - spot (https://sapi.asterdex.com/api/v3/*)
39
+
40
+ - MIGRATION note: the old `/sapi/v1/*` paths **404**; live base is
41
+ `/api/v3/*`.
42
+ - Rate limit 6000 weight/min (separate bucket; same header).
43
+ - `exchangeInfo`: 68 TRADING pairs plus TEST*-named junk symbols -
44
+ filtered by name in this gateway (flag to include).
45
+ - `klines`, `depth`: live 200, keyless.
46
+ - Spot `/trades`: path was uncertain at recon; probed in the smoke
47
+ script - if it 404s the tool degrades to an honest error dict.
48
+
49
+ ## tapi - Aster Chain JSON-RPC (https://tapi.asterdex.com/info)
50
+
51
+ - Body `{"jsonrpc":"2.0","id":1,"method":"aster_getBalance","params":
52
+ ["0x...", "latest"]}` (PARAMS IS AN ARRAY: address + blockTag; the
53
+ object form `{"userAddress": ...}` returns HTTP 400 - live-verified)
54
+ - answers KEYLESS, but **account privacy hides most accounts**:
55
+ private accounts return a result with only {address,
56
+ accountPrivacy: "enabled"} and no balances (observed on the BSC vault
57
+ address and a burn address). Empty = privacy, not an API failure -
58
+ the gateway returns an honest error-dict explaining why.
59
+ - `aster_openOrders` on a privacy-hidden account returns JSON-RPC
60
+ -32603 (internal error) - also mapped to the privacy degradation
61
+ path.
62
+ - Methods used (read-only): `aster_getBalance`, `aster_openOrders`,
63
+ `aster_userFills` (+ `aster_spotGetBalance` /
64
+ `aster_spotOpenOrders` / `aster_spotUserFills` variants).
65
+ - `eth_*` NOT supported (eth_chainId -> -32601 method not found): the
66
+ Aster Chain EVM-RPC is closed.
67
+
68
+ ## Solana vault (https://api.mainnet-beta.solana.com)
69
+
70
+ - `getSignaturesForAddress` for the Aster vault program
71
+ `EhUtRgu9iEbZXXRpEvDj6n1wnQRjMi2SERDo3c6bmN2c` - live 200 keyless,
72
+ fresh signatures (signature / slot / blockTime / err). Treated
73
+ politely (10 req/min local cap) - it is a free public node.
74
+
75
+ ## EVM vaults (BSC / ETH / ARB)
76
+
77
+ - Vaults: BSC `0x128463A60784c4D3f46c23Af3f65Ed859Ba87974`, ETH
78
+ `0x604DD02d620633Ae427888d41bfd15e38483736E`, ARB
79
+ `0x9E36CB86a159d479cEd94Fa05036f235Ac40E1d5`.
80
+ - Deposit flow = ERC-20 `Transfer` (topic0 `0xddf252ad...523b3ef`)
81
+ with `to` = vault, via `eth_getLogs`.
82
+ - Free public RPCs reject even 300-block windows with **-32005
83
+ (limit exceeded)**, so the RPC URL comes from env:
84
+ `ASTER_EVM_RPC_URL` (generic) or `ASTER_EVM_RPC_URL_{BSC,ETH,ARB}`
85
+ (per-chain). Unset -> honest `"no RPC configured"` error dict -
86
+ never a traceback, never invented logs.
87
+
88
+ ## Endpoints NOT used, and why
89
+
90
+ - **`/futures/data/*` history endpoints** - 404 on Aster (only
91
+ openInterestHist was probed; the futures/data tree is absent).
92
+ - **WebSocket streams** (`fstream`/`sstream`) - deferred; the MCP
93
+ request/response model gains little from push feeds and the TTL
94
+ caches already cover freshness.
95
+ - **Signed/trading endpoints** - out of scope by design: this gateway
96
+ is strictly read-only and keyless; no API keys ever enter the
97
+ process.
98
+ - **aster-scan.com / asterscan.io explorers** - dead/unreachable at
99
+ recon time; not a dependency.
aster_mcp/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """aster_mcp - read-only, keyless MCP gateway to Aster DEX public data.
2
+
3
+ Package layout:
4
+ rest.py stdlib-only REST client for fapi.asterdex.com/fapi/v3
5
+ and sapi.asterdex.com/api/v3 (weight-aware, TTL caches)
6
+ chain.py stdlib-only clients for tapi (Aster Chain JSON-RPC),
7
+ Solana vault signatures and EVM vault Transfer logs
8
+ server.py FastMCP server wiring the 13 read-only tools
9
+
10
+ No API keys, no auth headers, no signing, no order placement - public
11
+ data only.
12
+ """
13
+
14
+ __version__ = "0.1.0"
aster_mcp/chain.py ADDED
@@ -0,0 +1,287 @@
1
+ """Stdlib-only clients for Aster on-chain surfaces: tapi (Aster Chain
2
+ JSON-RPC), Solana vault signatures, and EVM vault Transfer logs.
3
+
4
+ tapi POST https://tapi.asterdex.com/info {"jsonrpc":"2.0","id":1,
5
+ "method":"aster_getBalance","params":{...}} - answers keyless,
6
+ but account privacy hides most accounts: private accounts return
7
+ an EMPTY result (or accountPrivacy:"enabled") - that is an honest
8
+ degradation, never an error-dict pretending data exists. Methods
9
+ used (read-only): aster_getBalance / aster_openOrders /
10
+ aster_userFills (+ spot variants when they exist).
11
+
12
+ solana RPC https://api.mainnet-beta.solana.com getSignaturesForAddress
13
+ for the Aster vault program
14
+ EhUtRgu9iEbZXXRpEvDj6n1wnQRjMi2SERDo3c6bmN2c (keyless, fresh
15
+ signatures: signature/slot/blockTime/err).
16
+
17
+ evm eth_getLogs Transfer(topic0=0xddf252ad...) to the Aster vault
18
+ contracts on BSC/ETH/ARB. Free public RPCs reject even 300-block
19
+ windows with -32005 (limit exceeded), so the RPC URL comes from
20
+ env ASTER_EVM_RPC_URL / ASTER_EVM_RPC_URL_{BSC,ETH,ARB}; when
21
+ unset the tools return an honest "no RPC configured" error dict -
22
+ NEVER an invented log, NEVER a traceback.
23
+
24
+ Each surface keeps its own small rate ledger (solana 10 req/min is
25
+ polite for a free public node; tapi 30/min) and raises RpcError with a
26
+ taxonomy kind so tools can degrade honestly.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import itertools
31
+ import json
32
+ import os
33
+ import threading
34
+ import time
35
+ import urllib.error
36
+ import urllib.request
37
+ from typing import Any
38
+
39
+ TAPI_URL = "https://tapi.asterdex.com/info"
40
+ SOLANA_RPC = "https://api.mainnet-beta.solana.com"
41
+ ASTER_SOLANA_VAULT = "EhUtRgu9iEbZXXRpEvDj6n1wnQRjMi2SERDo3c6bmN2c"
42
+
43
+ # EVM vaults (official Aster docs; chain -> vault address)
44
+ EVM_VAULTS = {
45
+ "bsc": "0x128463a60784c4d3f46c23af3f65ed859ba87974",
46
+ "eth": "0x604dd02d620633ae427888d41bfd15e38483736e",
47
+ "arb": "0x9e36cb86a159d479ced94fa05036f235ac40e1d5",
48
+ }
49
+ TRANSFER_TOPIC = ("0xddf252ad1be2c89b69c2b068fc378daa952ba7f1"
50
+ "63c4a11628f55a4df523b3ef")
51
+
52
+ UA = {"User-Agent": "aster-agent-gateway/0.1",
53
+ "Content-Type": "application/json"}
54
+
55
+ _TIMEOUT = 15.0
56
+ _RETRIES = 2 # these are secondary surfaces: fail fast, degrade honestly
57
+
58
+ _IDS = itertools.count(1)
59
+
60
+
61
+ class RpcError(Exception):
62
+ """On-chain RPC failure with a taxonomy kind.
63
+
64
+ kind: "rpc-limit" (local request budget / server 429), "rpc-http"
65
+ (JSON-RPC error payload, e.g. -32005 range limit), "rpc-timeout",
66
+ "rpc-network", "no-rpc" (env not configured - EVM only).
67
+ """
68
+
69
+ def __init__(self, message: str, *, kind: str,
70
+ status: int | None = None, code: int | None = None):
71
+ super().__init__(message)
72
+ self.kind = kind
73
+ self.status = status
74
+ self.code = code
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Shared rolling-window request budgets (one per surface)
79
+
80
+ _BUDGETS: dict[str, dict] = {
81
+ "tapi": {"limit": 30, "ts": []},
82
+ "solana": {"limit": 10, "ts": []},
83
+ "evm": {"limit": 20, "ts": []},
84
+ }
85
+ _BUDGET_LOCK = threading.Lock()
86
+
87
+
88
+ def requests_last_minute(surface: str) -> int:
89
+ """Requests admitted on a surface in the rolling 60s (test hook)."""
90
+ with _BUDGET_LOCK:
91
+ _prune(surface, time.monotonic())
92
+ return len(_BUDGETS[surface]["ts"])
93
+
94
+
95
+ def _prune(surface: str, now: float) -> None:
96
+ cutoff = now - 60.0
97
+ lst = _BUDGETS[surface]["ts"]
98
+ while lst and lst[0] <= cutoff:
99
+ lst.pop(0)
100
+
101
+
102
+ def _admit(surface: str) -> None:
103
+ with _BUDGET_LOCK:
104
+ now = time.monotonic()
105
+ _prune(surface, now)
106
+ b = _BUDGETS[surface]
107
+ if len(b["ts"]) >= b["limit"]:
108
+ raise RpcError(
109
+ f"{surface} local request budget exhausted "
110
+ f"({b['limit']}/min); wait for the window to roll",
111
+ kind="rpc-limit")
112
+ b["ts"].append(now)
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Generic JSON-RPC POST (tapi / solana / evm share the shape)
117
+
118
+ def rpc_post(url: str, method: str, params: list | dict | None = None,
119
+ surface: str = "tapi", retries: int = _RETRIES) -> Any:
120
+ """POST one JSON-RPC request; returns the `result` field.
121
+
122
+ Honors JSON-RPC error payloads (code/message) and retries 429/5xx
123
+ twice. Raises RpcError on final failure."""
124
+ body = json.dumps({"jsonrpc": "2.0", "id": next(_IDS),
125
+ "method": method, "params": params or {}}).encode()
126
+ for attempt in range(retries):
127
+ _admit(surface)
128
+ req = urllib.request.Request(url, data=body, headers=UA)
129
+ try:
130
+ with urllib.request.urlopen(req, timeout=_TIMEOUT) as r:
131
+ payload = json.loads(r.read())
132
+ except urllib.error.HTTPError as e:
133
+ if (e.code in (429, 418) or 500 <= e.code <= 599) \
134
+ and attempt < retries - 1:
135
+ ra = e.headers.get("Retry-After")
136
+ try:
137
+ delay = min(30.0, float(ra)) if ra else 2.0 * (attempt + 1)
138
+ except ValueError:
139
+ delay = 2.0 * (attempt + 1)
140
+ time.sleep(delay)
141
+ continue
142
+ try:
143
+ detail = e.read(300).decode("utf-8", "replace")
144
+ except Exception:
145
+ detail = ""
146
+ raise RpcError(
147
+ f"{surface} rpc {e.code} on {method}: "
148
+ f"{detail or e.reason}",
149
+ kind="rpc-limit" if e.code in (429, 418) else "rpc-http",
150
+ status=e.code) from e
151
+ except TimeoutError as e:
152
+ raise RpcError(f"{surface} rpc timeout on {method} after "
153
+ f"{_TIMEOUT}s", kind="rpc-timeout") from e
154
+ except (urllib.error.URLError, ConnectionError, OSError) as e:
155
+ if isinstance(e, urllib.error.URLError) and isinstance(
156
+ getattr(e, "reason", None), TimeoutError):
157
+ raise RpcError(f"{surface} rpc timeout on {method} after "
158
+ f"{_TIMEOUT}s", kind="rpc-timeout") from e
159
+ if attempt < retries - 1:
160
+ time.sleep(2.0 * (attempt + 1))
161
+ continue
162
+ raise RpcError(f"{surface} rpc unreachable on {method}: {e}",
163
+ kind="rpc-network") from e
164
+ except ValueError as e:
165
+ raise RpcError(f"{surface} rpc invalid JSON on {method}: {e}",
166
+ kind="rpc-http") from e
167
+ if "error" in payload:
168
+ err = payload.get("error") or {}
169
+ raise RpcError(
170
+ f"{surface} rpc error on {method}: "
171
+ f"{err.get('code')} {err.get('message')}",
172
+ kind="rpc-http", code=err.get("code")) from None
173
+ if "result" not in payload:
174
+ raise RpcError(f"{surface} rpc malformed response on "
175
+ f"{method}: no result", kind="rpc-http")
176
+ return payload["result"]
177
+ raise RpcError(f"{surface} rpc failed on {method}", kind="rpc-network")
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # tapi: Aster Chain account views (read-only, keyless, privacy-aware)
182
+
183
+ TAPI_METHODS = {
184
+ "balance": "aster_getBalance",
185
+ "openOrders": "aster_openOrders",
186
+ "userFills": "aster_userFills",
187
+ "spotBalance": "aster_spotGetBalance",
188
+ "spotOpenOrders": "aster_spotOpenOrders",
189
+ "spotUserFills": "aster_spotUserFills",
190
+ }
191
+
192
+
193
+ def tapi_call(method: str, address: str, extra: dict | None = None) -> Any:
194
+ """One tapi read for `address`. Params use the DOCUMENTED array form
195
+ [address, blockTag] (live-verified: the object {"userAddress": ...}
196
+ form returns HTTP 400). extra params are ignored for now - tapi
197
+ methods take only the two positional params. Returns the raw
198
+ result; the SERVER layer decides privacy-empty vs data (see
199
+ server.account_view)."""
200
+ params: list = [(address or "").strip(), "latest"]
201
+ return rpc_post(TAPI_URL, method, params, surface="tapi")
202
+
203
+
204
+ def tapi_balance(address: str) -> Any:
205
+ return tapi_call("aster_getBalance", address)
206
+
207
+
208
+ def tapi_open_orders(address: str) -> Any:
209
+ return tapi_call("aster_openOrders", address)
210
+
211
+
212
+ def tapi_user_fills(address: str) -> Any:
213
+ return tapi_call("aster_userFills", address)
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Solana vault signatures
218
+
219
+ def solana_vault_signatures(limit: int = 20,
220
+ vault: str = ASTER_SOLANA_VAULT) -> list:
221
+ """getSignaturesForAddress on the Aster Solana vault program.
222
+ Rows: signature/slot/blockTime/err (newest first)."""
223
+ n = max(1, min(100, int(limit)))
224
+ return rpc_post(SOLANA_RPC, "getSignaturesForAddress",
225
+ [vault, {"limit": n}], surface="solana") or []
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # EVM vault Transfer logs (env-gated, honest when unconfigured)
230
+
231
+ def evm_rpc_url(chain: str) -> str | None:
232
+ """ASTER_EVM_RPC_URL_{CHAIN} wins over the generic
233
+ ASTER_EVM_RPC_URL; None when nothing is configured."""
234
+ chain = (chain or "").lower()
235
+ specific = os.environ.get(f"ASTER_EVM_RPC_URL_{chain.upper()}")
236
+ if specific:
237
+ return specific
238
+ return os.environ.get("ASTER_EVM_RPC_URL")
239
+
240
+
241
+ def evm_vault_transfers(chain: str, from_block: int, to_block: int,
242
+ limit_blocks: int = 300) -> list:
243
+ """eth_getLogs Transfer->vault on `chain`. The window is clamped to
244
+ `limit_blocks` (free RPCs reject wide ranges with -32005). Raises
245
+ RpcError(kind="no-rpc") when no RPC is configured."""
246
+ chain = (chain or "").lower()
247
+ if chain not in EVM_VAULTS:
248
+ raise ValueError(f"unknown chain {chain!r}: use one of "
249
+ f"{', '.join(sorted(EVM_VAULTS))}")
250
+ url = evm_rpc_url(chain)
251
+ if not url:
252
+ raise RpcError(
253
+ f"no RPC configured for {chain}: set ASTER_EVM_RPC_URL or "
254
+ f"ASTER_EVM_RPC_URL_{chain.upper()} (free public RPCs reject "
255
+ f"vault log queries with -32005, so a real endpoint is "
256
+ f"required)", kind="no-rpc")
257
+ vault = EVM_VAULTS[chain]
258
+ lo = max(0, min(from_block, to_block - limit_blocks))
259
+ hi = to_block
260
+ logs = rpc_post(url, "eth_getLogs", [{
261
+ "fromBlock": hex(lo), "toBlock": hex(hi),
262
+ "address": vault,
263
+ "topics": [TRANSFER_TOPIC, None,
264
+ "0x" + "0" * 24 + vault[2:]], # Transfer(_, vault)
265
+ }], surface="evm")
266
+ return logs or []
267
+
268
+
269
+ def evm_latest_block(chain: str) -> int:
270
+ """eth_blockNumber on the configured RPC (int)."""
271
+ url = evm_rpc_url(chain)
272
+ if not url:
273
+ raise RpcError(f"no RPC configured for {chain}",
274
+ kind="no-rpc")
275
+ res = rpc_post(url, "eth_blockNumber", [], surface="evm")
276
+ try:
277
+ return int(res, 16)
278
+ except (TypeError, ValueError):
279
+ raise RpcError(f"eth_blockNumber returned non-hex: {res!r}",
280
+ kind="rpc-http")
281
+
282
+
283
+ def reset_budgets() -> None:
284
+ """Clear the per-surface request ledgers (test hook)."""
285
+ with _BUDGET_LOCK:
286
+ for b in _BUDGETS.values():
287
+ b["ts"].clear()