curator-data 0.2.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.
- curator_data/__init__.py +25 -0
- curator_data/chain/__init__.py +12 -0
- curator_data/chain/rpc.py +121 -0
- curator_data/cli.py +204 -0
- curator_data/config.py +175 -0
- curator_data/default.py +34 -0
- curator_data/facts.py +143 -0
- curator_data/graph/__init__.py +11 -0
- curator_data/graph/errors.py +42 -0
- curator_data/graph/factory.py +48 -0
- curator_data/graph/gateway.py +172 -0
- curator_data/ports.py +106 -0
- curator_data/queries.py +266 -0
- curator_data/registry.py +294 -0
- curator_data/sources/__init__.py +59 -0
- curator_data/sources/aave.py +209 -0
- curator_data/sources/chainlink.py +206 -0
- curator_data/sources/feeds.py +69 -0
- curator_data/sources/messari.py +390 -0
- curator_data/sources/pools.py +44 -0
- curator_data/sources/protocols.py +152 -0
- curator_data/sources/token_api.py +315 -0
- curator_data/sources/tokens.py +42 -0
- curator_data/verify.py +270 -0
- curator_data/x402/__init__.py +27 -0
- curator_data/x402/client.py +167 -0
- curator_data/x402/payment.py +291 -0
- curator_data-0.2.0.dist-info/METADATA +381 -0
- curator_data-0.2.0.dist-info/RECORD +31 -0
- curator_data-0.2.0.dist-info/WHEEL +4 -0
- curator_data-0.2.0.dist-info/entry_points.txt +2 -0
curator_data/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Market data for the curator agent.
|
|
2
|
+
|
|
3
|
+
The public surface is deliberately small: build a registry, ask it for a
|
|
4
|
+
snapshot. Everything else is an implementation detail of some source.
|
|
5
|
+
|
|
6
|
+
from curator_data import build_registry
|
|
7
|
+
|
|
8
|
+
registry = build_registry()
|
|
9
|
+
snapshot = await registry.snapshot(["messari", "token_api"], ["USDC", "WETH"])
|
|
10
|
+
|
|
11
|
+
No name in this package's public API refers to a data provider. Sources are
|
|
12
|
+
addressed by registry key, and those keys come from the mandate.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .facts import FactBuilder
|
|
16
|
+
from .registry import Registry, build_registry
|
|
17
|
+
from .sources import SOURCE_FACTORIES, available_sources
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Registry",
|
|
21
|
+
"build_registry",
|
|
22
|
+
"available_sources",
|
|
23
|
+
"SOURCE_FACTORIES",
|
|
24
|
+
"FactBuilder",
|
|
25
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Reading data straight off a chain.
|
|
2
|
+
|
|
3
|
+
Every other source in this package speaks HTTP to somebody's API. This one
|
|
4
|
+
speaks JSON-RPC to a node, which is the point: the `DataSource` port abstracts
|
|
5
|
+
*kinds of provider*, not just endpoints. A source that reads a contract and a
|
|
6
|
+
source that queries a subgraph merge into the same `MarketSnapshot` without
|
|
7
|
+
either knowing the other exists.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .rpc import RpcClient, RpcError
|
|
11
|
+
|
|
12
|
+
__all__ = ["RpcClient", "RpcError"]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""A minimal `eth_call` client.
|
|
2
|
+
|
|
3
|
+
Deliberately not web3.py. This lane needs exactly one JSON-RPC method against
|
|
4
|
+
two view functions with fixed, argument-free selectors — that is a POST and
|
|
5
|
+
some slicing, on the `httpx` client already in the tree. Pulling in a full node
|
|
6
|
+
library for it would add a large dependency to a package whose whole selling
|
|
7
|
+
point is that a new provider is cheap to add. (The root `pyproject.toml` also
|
|
8
|
+
records a broken global `web3` that breaks pytest collection, and Lane D
|
|
9
|
+
reached the same conclusion independently for the same reasons.)
|
|
10
|
+
|
|
11
|
+
ABI decoding here is limited to what static-word returns need: fixed 32-byte
|
|
12
|
+
slots, signed and unsigned. Anything more would be the point to reconsider.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
WORD = 32
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RpcError(RuntimeError):
|
|
27
|
+
"""The node was unreachable, or returned an error or unusable payload."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def decode_word(data: bytes, index: int, *, signed: bool = False) -> int:
|
|
31
|
+
"""The `index`-th 32-byte word of an ABI return, as an integer."""
|
|
32
|
+
start = index * WORD
|
|
33
|
+
end = start + WORD
|
|
34
|
+
if len(data) < end:
|
|
35
|
+
raise RpcError(f"response too short for word {index} ({len(data)} bytes)")
|
|
36
|
+
return int.from_bytes(data[start:end], "big", signed=signed)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def decode_string(data: bytes) -> str:
|
|
40
|
+
"""A single dynamically-sized `string` return.
|
|
41
|
+
|
|
42
|
+
Layout is offset, then length, then the bytes. Only used for
|
|
43
|
+
`description()`, which is how a price feed states its own identity.
|
|
44
|
+
"""
|
|
45
|
+
offset = decode_word(data, 0)
|
|
46
|
+
if len(data) < offset + WORD:
|
|
47
|
+
raise RpcError("string offset points past the end of the response")
|
|
48
|
+
length = int.from_bytes(data[offset : offset + WORD], "big")
|
|
49
|
+
start = offset + WORD
|
|
50
|
+
if len(data) < start + length:
|
|
51
|
+
raise RpcError("string length runs past the end of the response")
|
|
52
|
+
return data[start : start + length].decode("utf-8", errors="replace")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RpcClient:
|
|
56
|
+
"""`eth_call` against one JSON-RPC endpoint."""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
url: str,
|
|
61
|
+
*,
|
|
62
|
+
timeout_s: float = 15.0,
|
|
63
|
+
client: httpx.AsyncClient | None = None,
|
|
64
|
+
):
|
|
65
|
+
if not url:
|
|
66
|
+
raise ValueError("an RPC url is required")
|
|
67
|
+
self.url = url
|
|
68
|
+
self._timeout = timeout_s
|
|
69
|
+
self._client = client
|
|
70
|
+
self._owns_client = client is None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def client(self) -> httpx.AsyncClient:
|
|
74
|
+
if self._client is None:
|
|
75
|
+
self._client = httpx.AsyncClient(timeout=self._timeout)
|
|
76
|
+
return self._client
|
|
77
|
+
|
|
78
|
+
async def call(self, to: str, selector: str) -> bytes:
|
|
79
|
+
"""`eth_call` a no-argument view function. Returns raw return data."""
|
|
80
|
+
payload = {
|
|
81
|
+
"jsonrpc": "2.0",
|
|
82
|
+
"id": 1,
|
|
83
|
+
"method": "eth_call",
|
|
84
|
+
"params": [{"to": to, "data": selector}, "latest"],
|
|
85
|
+
}
|
|
86
|
+
try:
|
|
87
|
+
response = await self.client.post(self.url, json=payload)
|
|
88
|
+
except httpx.HTTPError as exc:
|
|
89
|
+
raise RpcError(f"node unreachable at {self.url}: {type(exc).__name__}") from exc
|
|
90
|
+
|
|
91
|
+
if response.status_code >= 400:
|
|
92
|
+
raise RpcError(f"node returned HTTP {response.status_code}")
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
body = response.json()
|
|
96
|
+
except ValueError as exc:
|
|
97
|
+
raise RpcError("node returned non-JSON") from exc
|
|
98
|
+
|
|
99
|
+
if isinstance(body, dict) and body.get("error"):
|
|
100
|
+
raise RpcError(f"eth_call failed: {body['error'].get('message', body['error'])}")
|
|
101
|
+
|
|
102
|
+
result = body.get("result") if isinstance(body, dict) else None
|
|
103
|
+
if not isinstance(result, str) or not result.startswith("0x"):
|
|
104
|
+
raise RpcError(f"unusable eth_call result: {result!r}")
|
|
105
|
+
# `0x` means the call reverted or the address holds no code. Callers
|
|
106
|
+
# must not read that as a zero value.
|
|
107
|
+
if len(result) <= 2:
|
|
108
|
+
raise RpcError(f"empty return from {to} - no contract, or the call reverted")
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
return bytes.fromhex(result[2:])
|
|
112
|
+
except ValueError as exc:
|
|
113
|
+
raise RpcError("eth_call result was not valid hex") from exc
|
|
114
|
+
|
|
115
|
+
async def aclose(self) -> None:
|
|
116
|
+
if self._client is not None and self._owns_client:
|
|
117
|
+
await self._client.aclose()
|
|
118
|
+
self._client = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
__all__ = ["RpcClient", "RpcError", "decode_word", "decode_string", "WORD"]
|
curator_data/cli.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""`curator-data` — one parameterised tool, not a folder of one-off scripts.
|
|
2
|
+
|
|
3
|
+
Four subcommands covering everything this lane needs operationally:
|
|
4
|
+
|
|
5
|
+
curator-data sources what can be granted in a mandate
|
|
6
|
+
curator-data protocols what is configured, and where to add more
|
|
7
|
+
curator-data snapshot take a real snapshot and print it
|
|
8
|
+
curator-data verify-live prove the demo path hits live data
|
|
9
|
+
|
|
10
|
+
`snapshot --json` emits a schema-valid `MarketSnapshot`, so it doubles as a way
|
|
11
|
+
to hand another lane real data without them running any of this code.
|
|
12
|
+
|
|
13
|
+
Output is ASCII: this runs in PowerShell, WSL and macOS terminals, and a
|
|
14
|
+
cp1252 console turns box-drawing characters into noise.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
from .config import Settings
|
|
26
|
+
from .queries import MARKET_KINDS, errors_as_dicts, pivot_markets, pivot_pools, prices
|
|
27
|
+
from .registry import build_registry
|
|
28
|
+
from .sources.protocols import ALL
|
|
29
|
+
from .verify import report, summarise, verify_live
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _fmt_usd(value: float | None) -> str:
|
|
33
|
+
if value is None:
|
|
34
|
+
return "-"
|
|
35
|
+
for threshold, suffix in ((1e9, "B"), (1e6, "M"), (1e3, "K")):
|
|
36
|
+
if abs(value) >= threshold:
|
|
37
|
+
return f"${value / threshold:,.1f}{suffix}"
|
|
38
|
+
return f"${value:,.2f}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _fmt_pct(value: float | None) -> str:
|
|
42
|
+
return "-" if value is None else f"{value * 100:.2f}%"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── subcommands ───────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_sources(args: argparse.Namespace) -> int:
|
|
49
|
+
"""What a mandate may name in `permitted_data_sources`."""
|
|
50
|
+
registry = build_registry(Settings.from_env())
|
|
51
|
+
described = registry.describe()
|
|
52
|
+
if args.json:
|
|
53
|
+
print(json.dumps(described, indent=2))
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
print("Registered data sources (name these in Mandate.permitted_data_sources):\n")
|
|
57
|
+
for entry in described:
|
|
58
|
+
print(f" {entry['key']}")
|
|
59
|
+
print(f" {entry.get('description', '')}")
|
|
60
|
+
if entry.get("provides"):
|
|
61
|
+
print(f" provides: {entry['provides']}")
|
|
62
|
+
print("\nAdd one: implement BaseSource in curator_data/sources/, then add a single")
|
|
63
|
+
print("line to SOURCE_FACTORIES in curator_data/sources/__init__.py.")
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_protocols(args: argparse.Namespace) -> int:
|
|
68
|
+
"""The protocol table — the "adding a protocol is one line" claim, printed."""
|
|
69
|
+
if args.json:
|
|
70
|
+
print(json.dumps([p.__dict__ for p in ALL], indent=2))
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
print("Configured protocols:\n")
|
|
74
|
+
print(f" {'key':<14} {'family':<10} {'chain':<7} {'on':<4} label")
|
|
75
|
+
print(f" {'-' * 14} {'-' * 10} {'-' * 7} {'-' * 4} {'-' * 24}")
|
|
76
|
+
for p in ALL:
|
|
77
|
+
print(
|
|
78
|
+
f" {p.key:<14} {p.family:<10} {p.chain:<7} "
|
|
79
|
+
f"{'yes' if p.enabled else 'no':<4} {p.label}"
|
|
80
|
+
)
|
|
81
|
+
print("\nAdding a protocol is one Protocol(...) line in")
|
|
82
|
+
print("curator_data/sources/protocols.py - Messari's standardized schema means")
|
|
83
|
+
print("every lending market answers the same query, so no adapter is needed.")
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def _snapshot(args: argparse.Namespace) -> int:
|
|
88
|
+
settings = Settings.from_env()
|
|
89
|
+
assets = [a.strip().upper() for a in args.assets.split(",") if a.strip()]
|
|
90
|
+
registry = build_registry(settings)
|
|
91
|
+
try:
|
|
92
|
+
if args.sources:
|
|
93
|
+
keys = [s.strip() for s in args.sources.split(",") if s.strip()]
|
|
94
|
+
else:
|
|
95
|
+
keys = registry.sources_providing(*MARKET_KINDS, "price")
|
|
96
|
+
snapshot = await registry.snapshot(keys, assets)
|
|
97
|
+
finally:
|
|
98
|
+
await registry.aclose()
|
|
99
|
+
|
|
100
|
+
if args.json:
|
|
101
|
+
# Schema-valid MarketSnapshot: pipe it straight into another lane.
|
|
102
|
+
print(snapshot.model_dump_json(indent=2))
|
|
103
|
+
return 0 if snapshot.facts else 1
|
|
104
|
+
|
|
105
|
+
print(f"Snapshot at {snapshot.taken_at.isoformat(timespec='seconds')}")
|
|
106
|
+
print(f"Sources: {', '.join(keys) or 'none'} Assets: {', '.join(assets)}\n")
|
|
107
|
+
|
|
108
|
+
markets = pivot_markets(snapshot)
|
|
109
|
+
if markets:
|
|
110
|
+
print(f" {'protocol':<14} {'market':<8} {'APY':>8} {'TVL':>10} {'util':>7}")
|
|
111
|
+
print(f" {'-' * 14} {'-' * 8} {'-' * 8} {'-' * 10} {'-' * 7}")
|
|
112
|
+
for row in markets:
|
|
113
|
+
print(
|
|
114
|
+
f" {row.protocol:<14} {row.market:<8} {_fmt_pct(row.supply_apy):>8} "
|
|
115
|
+
f"{_fmt_usd(row.tvl_usd):>10} {_fmt_pct(row.utilization):>7}"
|
|
116
|
+
)
|
|
117
|
+
print()
|
|
118
|
+
|
|
119
|
+
pools = pivot_pools(snapshot)
|
|
120
|
+
for pool in pools:
|
|
121
|
+
print(f" pool {pool.protocol} {'/'.join(pool.pair)}: {_fmt_usd(pool.liquidity_usd)}")
|
|
122
|
+
if pools:
|
|
123
|
+
print()
|
|
124
|
+
|
|
125
|
+
for symbol, price in prices(snapshot).items():
|
|
126
|
+
via = ", ".join(price["sources"])
|
|
127
|
+
line = f" price {symbol}: ${price['price_usd']:,.2f} (via {via}"
|
|
128
|
+
if len(price["sources"]) > 1:
|
|
129
|
+
# Independent mechanisms agreeing is worth showing, not just
|
|
130
|
+
# asserting — and disagreeing is worth showing even more.
|
|
131
|
+
line += f", spread {price['spread_pct']:.2f}%"
|
|
132
|
+
if price["disagreement"]:
|
|
133
|
+
line += " DISAGREEMENT"
|
|
134
|
+
print(line + ")")
|
|
135
|
+
|
|
136
|
+
errors = errors_as_dicts(snapshot)
|
|
137
|
+
if errors:
|
|
138
|
+
# Printed last and labelled plainly: a partial snapshot must look
|
|
139
|
+
# partial, or the number above it gets read as the whole market.
|
|
140
|
+
print("\n Degraded - these sources could not be read:")
|
|
141
|
+
for error in errors:
|
|
142
|
+
print(f" [{error['source']}] {error['message']}")
|
|
143
|
+
|
|
144
|
+
print(f"\n{len(snapshot.facts)} facts, {len(errors)} errors")
|
|
145
|
+
return 0 if snapshot.facts else 1
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def _verify(args: argparse.Namespace) -> int:
|
|
149
|
+
results = await verify_live(Settings.from_env(), only=args.protocol)
|
|
150
|
+
print(report(results))
|
|
151
|
+
_, failed, skipped = summarise(results)
|
|
152
|
+
# Skipped counts as failure here: this command exists to prove the live
|
|
153
|
+
# path works, and "we did not check" is not proof.
|
|
154
|
+
return 1 if (failed or skipped) else 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── entry point ───────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
161
|
+
parser = argparse.ArgumentParser(
|
|
162
|
+
prog="curator-data",
|
|
163
|
+
description="Market data registry: inspect sources, take snapshots, verify live data.",
|
|
164
|
+
)
|
|
165
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="debug logging to stderr")
|
|
166
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
167
|
+
|
|
168
|
+
p_sources = subparsers.add_parser("sources", help="list registered data sources")
|
|
169
|
+
p_sources.add_argument("--json", action="store_true")
|
|
170
|
+
p_sources.set_defaults(func=lambda a: cmd_sources(a))
|
|
171
|
+
|
|
172
|
+
p_protocols = subparsers.add_parser("protocols", help="list configured protocols")
|
|
173
|
+
p_protocols.add_argument("--json", action="store_true")
|
|
174
|
+
p_protocols.set_defaults(func=lambda a: cmd_protocols(a))
|
|
175
|
+
|
|
176
|
+
p_snapshot = subparsers.add_parser("snapshot", help="take a live MarketSnapshot")
|
|
177
|
+
p_snapshot.add_argument("--assets", default="USDC,WETH", help="comma-separated symbols")
|
|
178
|
+
p_snapshot.add_argument("--sources", default="", help="comma-separated keys (default: all)")
|
|
179
|
+
p_snapshot.add_argument(
|
|
180
|
+
"--json", action="store_true", help="emit a schema-valid MarketSnapshot"
|
|
181
|
+
)
|
|
182
|
+
p_snapshot.set_defaults(func=lambda a: asyncio.run(_snapshot(a)))
|
|
183
|
+
|
|
184
|
+
p_verify = subparsers.add_parser(
|
|
185
|
+
"verify-live", help="prove the demo path reaches live gateway data"
|
|
186
|
+
)
|
|
187
|
+
p_verify.add_argument("--protocol", default=None, help="check only this protocol key")
|
|
188
|
+
p_verify.set_defaults(func=lambda a: asyncio.run(_verify(a)))
|
|
189
|
+
|
|
190
|
+
return parser
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def main(argv: list[str] | None = None) -> int:
|
|
194
|
+
args = build_parser().parse_args(argv)
|
|
195
|
+
logging.basicConfig(
|
|
196
|
+
level=logging.DEBUG if args.verbose else logging.WARNING,
|
|
197
|
+
format="%(levelname)s %(name)s: %(message)s",
|
|
198
|
+
stream=sys.stderr,
|
|
199
|
+
)
|
|
200
|
+
return args.func(args)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__": # pragma: no cover
|
|
204
|
+
raise SystemExit(main())
|
curator_data/config.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Every environment-dependent value, resolved in one place.
|
|
2
|
+
|
|
3
|
+
Scattered `os.getenv` calls are how a lane ends up with three different
|
|
4
|
+
opinions about which gateway URL is current. One `Settings` object, loaded
|
|
5
|
+
once, passed explicitly.
|
|
6
|
+
|
|
7
|
+
Loading `.env` is opt-in via `Settings.from_env()` so importing this module
|
|
8
|
+
never has a side effect on a caller's process environment.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
# The Graph's decentralised gateway. The API key travels as a Bearer header
|
|
18
|
+
# rather than in the path (`/api/{key}/subgraphs/id/{id}`, also supported) so
|
|
19
|
+
# it cannot leak into an access log, a traceback or a screen share during the
|
|
20
|
+
# demo.
|
|
21
|
+
DEFAULT_GATEWAY_URL = "https://gateway.thegraph.com/api"
|
|
22
|
+
|
|
23
|
+
# Token API is a REST service on its own host with its own credential — it is
|
|
24
|
+
# NOT the same key as the subgraph gateway. See README "Credentials".
|
|
25
|
+
#
|
|
26
|
+
# Host verified by probe on 2026-07-25: `token-api.thegraph.com` (the name in
|
|
27
|
+
# The Graph's own docs) does not resolve at all, and the docs now redirect to
|
|
28
|
+
# Pinax, a Graph core developer who operates the service. `GET /health` here
|
|
29
|
+
# returns {"status":"OK"}.
|
|
30
|
+
DEFAULT_TOKEN_API_URL = "https://api.pinax.network/v1"
|
|
31
|
+
|
|
32
|
+
# x402: the same subgraph gateway, on the payment-gated route. Deliberately
|
|
33
|
+
# needs no API key — that is the entire point of the path.
|
|
34
|
+
DEFAULT_X402_GATEWAY_URL = "https://gateway.thegraph.com/api/x402"
|
|
35
|
+
|
|
36
|
+
DEFAULT_CHAIN = "base"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _env_flag(name: str, default: bool = False) -> bool:
|
|
40
|
+
raw = os.getenv(name)
|
|
41
|
+
if raw is None or raw == "":
|
|
42
|
+
return default
|
|
43
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _env_float(name: str, default: float) -> float:
|
|
47
|
+
raw = os.getenv(name)
|
|
48
|
+
if raw is None or raw.strip() == "":
|
|
49
|
+
return default
|
|
50
|
+
try:
|
|
51
|
+
return float(raw)
|
|
52
|
+
except ValueError:
|
|
53
|
+
return default
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _find_dotenv(start: Path | None = None) -> Path | None:
|
|
57
|
+
"""Walk up from `start` looking for a .env.
|
|
58
|
+
|
|
59
|
+
Relative to the file, never to the process CWD — the MCP server is launched
|
|
60
|
+
from whatever directory the host client happens to be in, and a teammate on
|
|
61
|
+
macOS must get the same answer as we do on Windows.
|
|
62
|
+
"""
|
|
63
|
+
here = (start or Path(__file__).resolve()).parent
|
|
64
|
+
for candidate in [here, *here.parents]:
|
|
65
|
+
dotenv = candidate / ".env"
|
|
66
|
+
if dotenv.is_file():
|
|
67
|
+
return dotenv
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Settings:
|
|
73
|
+
"""Resolved configuration. Immutable — construct a new one to change it."""
|
|
74
|
+
|
|
75
|
+
graph_api_key: str | None = None
|
|
76
|
+
gateway_url: str = DEFAULT_GATEWAY_URL
|
|
77
|
+
token_api_url: str = DEFAULT_TOKEN_API_URL
|
|
78
|
+
token_api_key: str | None = None
|
|
79
|
+
chain: str = DEFAULT_CHAIN
|
|
80
|
+
#: JSON-RPC endpoint for on-chain sources (Chainlink feeds). Prefers the
|
|
81
|
+
#: local fork so a snapshot matches the chain the vault is actually on.
|
|
82
|
+
rpc_url: str | None = None
|
|
83
|
+
#: 30s, not 15: the live Uniswap V3 Base subgraph repeatedly answered in
|
|
84
|
+
#: ~20s during testing (its indexers are slow and intermittently
|
|
85
|
+
#: unavailable). A timeout below that turns a working source into a
|
|
86
|
+
#: permanently failing one.
|
|
87
|
+
request_timeout_s: float = 30.0
|
|
88
|
+
|
|
89
|
+
#: Per-source ceiling inside `Registry.snapshot`. A source that hangs must
|
|
90
|
+
#: not hold the decision loop open; it lands in `errors[]` instead. Sits
|
|
91
|
+
#: above `request_timeout_s` so a slow *request* fails with a useful
|
|
92
|
+
#: message rather than being cut off by the outer deadline.
|
|
93
|
+
source_timeout_s: float = 45.0
|
|
94
|
+
|
|
95
|
+
# ── x402 (feature-flagged, default off) ───────────────────────────────
|
|
96
|
+
x402_enabled: bool = False
|
|
97
|
+
x402_gateway_url: str = DEFAULT_X402_GATEWAY_URL
|
|
98
|
+
x402_private_key: str | None = None
|
|
99
|
+
x402_chain: str = DEFAULT_CHAIN
|
|
100
|
+
|
|
101
|
+
#: Extra registry keys are inert unless a mandate names them, so carrying
|
|
102
|
+
#: unknown config here is harmless.
|
|
103
|
+
extra: dict[str, str] = field(default_factory=dict)
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def from_env(cls, *, load_dotenv: bool = True) -> Settings:
|
|
107
|
+
"""Read the environment, optionally loading the repo-root `.env` first.
|
|
108
|
+
|
|
109
|
+
Real process environment always wins over the file, so a teammate can
|
|
110
|
+
override any single value without editing a committed template.
|
|
111
|
+
"""
|
|
112
|
+
if load_dotenv:
|
|
113
|
+
dotenv_path = _find_dotenv()
|
|
114
|
+
if dotenv_path is not None:
|
|
115
|
+
try:
|
|
116
|
+
from dotenv import load_dotenv as _load
|
|
117
|
+
|
|
118
|
+
_load(dotenv_path, override=False)
|
|
119
|
+
except ImportError: # pragma: no cover - dotenv is a hard dep
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
return cls(
|
|
123
|
+
graph_api_key=os.getenv("GRAPH_API_KEY") or None,
|
|
124
|
+
gateway_url=os.getenv("GRAPH_GATEWAY_URL") or DEFAULT_GATEWAY_URL,
|
|
125
|
+
token_api_url=os.getenv("TOKEN_API_URL") or DEFAULT_TOKEN_API_URL,
|
|
126
|
+
# Falls back to the gateway key: The Graph Market issues one
|
|
127
|
+
# credential that works for both in the common case, and a
|
|
128
|
+
# confusing 401 is worse than an attempt.
|
|
129
|
+
token_api_key=os.getenv("TOKEN_API_KEY") or os.getenv("GRAPH_API_KEY") or None,
|
|
130
|
+
chain=os.getenv("DATA_CHAIN") or DEFAULT_CHAIN,
|
|
131
|
+
# Fork first: the agent reasons about the chain its vault lives on,
|
|
132
|
+
# and during development that is anvil, not mainnet.
|
|
133
|
+
rpc_url=(
|
|
134
|
+
os.getenv("DATA_RPC_URL")
|
|
135
|
+
or os.getenv("ANVIL_RPC_URL")
|
|
136
|
+
or os.getenv("BASE_RPC_URL")
|
|
137
|
+
or None
|
|
138
|
+
),
|
|
139
|
+
request_timeout_s=_env_float("DATA_REQUEST_TIMEOUT_S", 15.0),
|
|
140
|
+
source_timeout_s=_env_float("DATA_SOURCE_TIMEOUT_S", 20.0),
|
|
141
|
+
x402_enabled=_env_flag("X402_ENABLED", False),
|
|
142
|
+
x402_gateway_url=os.getenv("X402_GATEWAY_URL") or DEFAULT_X402_GATEWAY_URL,
|
|
143
|
+
x402_private_key=os.getenv("X402_PRIVATE_KEY") or None,
|
|
144
|
+
x402_chain=os.getenv("X402_CHAIN") or DEFAULT_CHAIN,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# ── Capability checks, so callers report rather than guess ────────────
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def has_gateway_credential(self) -> bool:
|
|
151
|
+
return bool(self.graph_api_key)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def has_token_api_credential(self) -> bool:
|
|
155
|
+
return bool(self.token_api_key)
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def x402_ready(self) -> bool:
|
|
159
|
+
"""Flag on AND a key to sign with. Either missing means fall back."""
|
|
160
|
+
return self.x402_enabled and bool(self.x402_private_key)
|
|
161
|
+
|
|
162
|
+
def subgraph_url(self, subgraph_id: str) -> str:
|
|
163
|
+
return f"{self.gateway_url.rstrip('/')}/subgraphs/id/{subgraph_id}"
|
|
164
|
+
|
|
165
|
+
def x402_subgraph_url(self, subgraph_id: str) -> str:
|
|
166
|
+
return f"{self.x402_gateway_url.rstrip('/')}/subgraphs/id/{subgraph_id}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
__all__ = [
|
|
170
|
+
"Settings",
|
|
171
|
+
"DEFAULT_GATEWAY_URL",
|
|
172
|
+
"DEFAULT_TOKEN_API_URL",
|
|
173
|
+
"DEFAULT_X402_GATEWAY_URL",
|
|
174
|
+
"DEFAULT_CHAIN",
|
|
175
|
+
]
|
curator_data/default.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""A ready-made registry instance, for consumers that late-bind by reference.
|
|
2
|
+
|
|
3
|
+
Lane B's harness resolves its data seam from configuration —
|
|
4
|
+
`AGENT_DATA_REGISTRY=<module>:<attribute>` — imports it on first use, and checks
|
|
5
|
+
it against the frozen `DataSourceRegistry` Protocol. That mechanism needs an
|
|
6
|
+
*instance* at a stable import path, which `build_registry()` (a function) does
|
|
7
|
+
not provide.
|
|
8
|
+
|
|
9
|
+
So the integration point is:
|
|
10
|
+
|
|
11
|
+
AGENT_DATA_REGISTRY=curator_data.default:registry
|
|
12
|
+
|
|
13
|
+
Kept in its own module rather than on `curator_data` itself, because
|
|
14
|
+
constructing this reads `.env`. Importing the package should have no side
|
|
15
|
+
effect; importing *this* module is an explicit request for the configured
|
|
16
|
+
default. Anyone wanting different settings calls `build_registry(settings)`
|
|
17
|
+
instead.
|
|
18
|
+
|
|
19
|
+
Construction is cheap and cannot fail on missing credentials: sources are
|
|
20
|
+
built lazily on first use, so an absent `GRAPH_API_KEY` surfaces later as a
|
|
21
|
+
`MarketSnapshot.errors` entry rather than an import-time crash. That matters
|
|
22
|
+
for a late-binding consumer — a raise here would silently drop them back to
|
|
23
|
+
fixture data.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from .registry import Registry, build_registry
|
|
29
|
+
|
|
30
|
+
#: The configured registry. Safe to import at any time; holds no connections
|
|
31
|
+
#: until a source is actually consulted.
|
|
32
|
+
registry: Registry = build_registry()
|
|
33
|
+
|
|
34
|
+
__all__ = ["registry"]
|