curator-data 0.2.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.
- curator_data-0.2.0/.gitignore +46 -0
- curator_data-0.2.0/GRAPH-USAGE.md +110 -0
- curator_data-0.2.0/PKG-INFO +381 -0
- curator_data-0.2.0/PUBLISHING.md +132 -0
- curator_data-0.2.0/README.md +358 -0
- curator_data-0.2.0/curator_data/__init__.py +25 -0
- curator_data-0.2.0/curator_data/chain/__init__.py +12 -0
- curator_data-0.2.0/curator_data/chain/rpc.py +121 -0
- curator_data-0.2.0/curator_data/cli.py +204 -0
- curator_data-0.2.0/curator_data/config.py +175 -0
- curator_data-0.2.0/curator_data/default.py +34 -0
- curator_data-0.2.0/curator_data/facts.py +143 -0
- curator_data-0.2.0/curator_data/graph/__init__.py +11 -0
- curator_data-0.2.0/curator_data/graph/errors.py +42 -0
- curator_data-0.2.0/curator_data/graph/factory.py +48 -0
- curator_data-0.2.0/curator_data/graph/gateway.py +172 -0
- curator_data-0.2.0/curator_data/ports.py +106 -0
- curator_data-0.2.0/curator_data/queries.py +266 -0
- curator_data-0.2.0/curator_data/registry.py +294 -0
- curator_data-0.2.0/curator_data/sources/__init__.py +59 -0
- curator_data-0.2.0/curator_data/sources/aave.py +209 -0
- curator_data-0.2.0/curator_data/sources/chainlink.py +206 -0
- curator_data-0.2.0/curator_data/sources/feeds.py +69 -0
- curator_data-0.2.0/curator_data/sources/messari.py +390 -0
- curator_data-0.2.0/curator_data/sources/pools.py +44 -0
- curator_data-0.2.0/curator_data/sources/protocols.py +152 -0
- curator_data-0.2.0/curator_data/sources/token_api.py +315 -0
- curator_data-0.2.0/curator_data/sources/tokens.py +42 -0
- curator_data-0.2.0/curator_data/verify.py +270 -0
- curator_data-0.2.0/curator_data/x402/__init__.py +27 -0
- curator_data-0.2.0/curator_data/x402/client.py +167 -0
- curator_data-0.2.0/curator_data/x402/payment.py +291 -0
- curator_data-0.2.0/curator_mcp/README.md +145 -0
- curator_data-0.2.0/curator_mcp/SKILL.md +114 -0
- curator_data-0.2.0/curator_mcp/curator_mcp/__init__.py +16 -0
- curator_data-0.2.0/curator_mcp/curator_mcp/server.py +215 -0
- curator_data-0.2.0/curator_mcp/pyproject.toml +55 -0
- curator_data-0.2.0/publish.sh +127 -0
- curator_data-0.2.0/pyproject.toml +56 -0
- curator_data-0.2.0/tests/conftest.py +58 -0
- curator_data-0.2.0/tests/test_aave.py +158 -0
- curator_data-0.2.0/tests/test_chainlink.py +239 -0
- curator_data-0.2.0/tests/test_cli.py +116 -0
- curator_data-0.2.0/tests/test_facts.py +72 -0
- curator_data-0.2.0/tests/test_gateway.py +161 -0
- curator_data-0.2.0/tests/test_mcp_server.py +114 -0
- curator_data-0.2.0/tests/test_messari.py +413 -0
- curator_data-0.2.0/tests/test_published.py +129 -0
- curator_data-0.2.0/tests/test_queries.py +141 -0
- curator_data-0.2.0/tests/test_registry.py +376 -0
- curator_data-0.2.0/tests/test_source_agnostic.py +95 -0
- curator_data-0.2.0/tests/test_token_api.py +292 -0
- curator_data-0.2.0/tests/test_x402.py +392 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# ── Secrets ───────────────────────────────────────────────────────────────
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
|
|
6
|
+
# ── Python ────────────────────────────────────────────────────────────────
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.py[cod]
|
|
9
|
+
.venv/
|
|
10
|
+
venv/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.ruff_cache/
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
*.egg-info/
|
|
15
|
+
dist/
|
|
16
|
+
build/
|
|
17
|
+
|
|
18
|
+
# ── Node / Next.js ────────────────────────────────────────────────────────
|
|
19
|
+
node_modules/
|
|
20
|
+
.next/
|
|
21
|
+
out/
|
|
22
|
+
.turbo/
|
|
23
|
+
*.tsbuildinfo
|
|
24
|
+
|
|
25
|
+
# ── Foundry ───────────────────────────────────────────────────────────────
|
|
26
|
+
cache/
|
|
27
|
+
broadcast/
|
|
28
|
+
# NOTE: contracts/out is intentionally NOT ignored — Lane A publishes ABIs from
|
|
29
|
+
# there and every other lane consumes them (master plan §10 Lane A).
|
|
30
|
+
|
|
31
|
+
# ── Local runtime state ───────────────────────────────────────────────────
|
|
32
|
+
*.log
|
|
33
|
+
.anvil-state.json
|
|
34
|
+
scratch/
|
|
35
|
+
# Lane B: persisted mandates and AgentAction journals (agent/config.py state_dir)
|
|
36
|
+
.agent-state/
|
|
37
|
+
# Wave 0: e2e runs against an isolated AGENT_STATE_DIR so they never share a
|
|
38
|
+
# journal with a running agent — two writers corrupt line-count-derived ids.
|
|
39
|
+
.agent-state-*/
|
|
40
|
+
|
|
41
|
+
# ── OS / editor ───────────────────────────────────────────────────────────
|
|
42
|
+
.DS_Store
|
|
43
|
+
Thumbs.db
|
|
44
|
+
.idea/
|
|
45
|
+
.vscode/*
|
|
46
|
+
!.vscode/extensions.json
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# The Graph — exactly what we use
|
|
2
|
+
|
|
3
|
+
Written for the submission form. Track 2 asks you to *"briefly describe how The Graph is used (which
|
|
4
|
+
Subgraphs, endpoints, tools)"*, and Track 3 asks for **two or more Graph products composed**. This is
|
|
5
|
+
the copy-pasteable answer, with the specific identifiers rather than a category name — every figure
|
|
6
|
+
below was read from the live gateway on 2026-07-25.
|
|
7
|
+
|
|
8
|
+
Reproduce any of it with:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
uv run curator-data verify-live # queries every subgraph and prints what came back
|
|
12
|
+
uv run curator-data snapshot --assets USDC,WETH
|
|
13
|
+
uv run curator-data protocols # the configured table
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Subgraphs queried (decentralised network, via the gateway)
|
|
19
|
+
|
|
20
|
+
| Protocol | Subgraph ID | Schema | Supplies |
|
|
21
|
+
|---|---|---|---|
|
|
22
|
+
| **Moonwell** (Base) | `33ex1ExmYQtwGVwri1AP3oMFPGSce6YbocBP7fWbsBrg` | Messari **standardized** lending | supply APY, TVL, utilization |
|
|
23
|
+
| **Aave V3** (Base) | `GQFbb95cE6d8mV989mL5figjaGaKCQB3xqYrr1bRyXqF` | Aave's **own** schema (`reserves`) | supply APY, TVL, utilization |
|
|
24
|
+
| **Uniswap V3** (Base) | `FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS` | Messari **standardized** dex-amm | pool liquidity |
|
|
25
|
+
| The Graph Network | `DZz4kDTdmzWLWsV373w2bSmoar3umKKH9y82SUKr5qmp` | — | used to *search* the network for candidate subgraphs (see below) |
|
|
26
|
+
|
|
27
|
+
Endpoint form: `https://gateway.thegraph.com/api/subgraphs/id/{id}`, authenticated with
|
|
28
|
+
`Authorization: Bearer $GRAPH_API_KEY` — the key travels as a header, never in the URL, so it cannot
|
|
29
|
+
leak into a log or a screen share.
|
|
30
|
+
|
|
31
|
+
### The composition argument, concretely
|
|
32
|
+
|
|
33
|
+
Messari publishes **one standardized schema per protocol type**, so a single GraphQL document reads
|
|
34
|
+
every lending market. Adding a protocol is one line of configuration in
|
|
35
|
+
[`sources/protocols.py`](curator_data/sources/protocols.py) — no adapter, no schema change. That
|
|
36
|
+
claim is asserted by a test (`test_one_query_shape_serves_every_lending_protocol`), not just stated.
|
|
37
|
+
|
|
38
|
+
**Where it stops, honestly:** the published *Aave V3 Base* subgraph is **not** Messari-standardized —
|
|
39
|
+
it exposes `reserves`, not `markets`. We searched all 381 active Base subgraphs via The Graph's own
|
|
40
|
+
network subgraph and found no standardized Aave on Base, so Aave got its own adapter and its own
|
|
41
|
+
registry key. It is a separate key rather than a branch inside the Messari adapter because
|
|
42
|
+
`Fact.source` is **provenance**, and labelling Aave's own subgraph as "messari" would be false.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Other Graph products used
|
|
47
|
+
|
|
48
|
+
**Token API** — `https://api.pinax.network/v1`, authenticated with a Graph Market JWT
|
|
49
|
+
(`TOKEN_API_KEY`; the subgraph gateway's key is rejected with 401). Base has no price category, so
|
|
50
|
+
price is derived from executed DEX swaps: `GET /evm/swaps?network=base&pool={pool}&limit=10`.
|
|
51
|
+
|
|
52
|
+
**x402 pay-per-query** — `https://gateway.thegraph.com/api/x402/subgraphs/id/{id}`. The agent pays
|
|
53
|
+
for its own market data out of its own wallet, in USDC on Base, **with no API key at all**. The
|
|
54
|
+
gateway quotes $0.01 per query.
|
|
55
|
+
|
|
56
|
+
**MCP server** (Track 1) — [`curator_mcp/`](curator_mcp/), a separately installable distribution with
|
|
57
|
+
its own `pyproject.toml`, `README.md` and [`SKILL.md`](curator_mcp/SKILL.md). Four tools:
|
|
58
|
+
`compare_protocols`, `get_market_yields`, `list_markets`, `get_token_price`, plus a
|
|
59
|
+
`curator://protocols` resource. It shares this package's sources rather than reimplementing them, so
|
|
60
|
+
the reusable product runs the same code as the demo — our agent is visibly *a* consumer, not *the*
|
|
61
|
+
consumer.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Two or more Graph products, composed (Track 3)
|
|
66
|
+
|
|
67
|
+
Four sources merge into one **source-agnostic** `MarketSnapshot`:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
Messari standardized subgraphs ─┐
|
|
71
|
+
Aave V3 subgraph ─┼─► MarketSnapshot (flat facts, each carrying its own provenance)
|
|
72
|
+
Graph Token API ─┤
|
|
73
|
+
Chainlink (on-chain, non-Graph)─┘
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Sources never see each other and never agree on coverage; each contributes a *partial* list of facts
|
|
77
|
+
and the registry merges them. That is what makes the composition real rather than a list — and the
|
|
78
|
+
fourth source is deliberately **not** an HTTP API at all: it reads a contract over JSON-RPC, showing
|
|
79
|
+
the port abstracts *kinds of provider* rather than endpoints.
|
|
80
|
+
|
|
81
|
+
A live snapshot, exactly as an agent tick runs it (6.3s):
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
moonwell USDC APY 15.38% TVL $ 14,489,518 util 0.91 [messari]
|
|
85
|
+
aave-v3 USDC APY 3.51% TVL $172,603,113 util 0.85 [aave]
|
|
86
|
+
aave-v3 WETH APY 1.46% TVL $174,448,695 util 0.77 [aave]
|
|
87
|
+
price USDC $ 1.00 via [chainlink]
|
|
88
|
+
price WETH $1,856.33 via [chainlink, token_api] spread 0.29%
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Two lending protocols compared, and **two mechanically independent price sources cross-validating** —
|
|
92
|
+
an oracle read and executed swap prices, 0.29% apart. A wide spread is surfaced as `disagreement`,
|
|
93
|
+
because that gap means a stale oracle, a manipulated pool, or a dislocated market: all things a
|
|
94
|
+
curator should act on.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Live data, not mocks
|
|
99
|
+
|
|
100
|
+
The Graph disqualifies mocked or static data. `curator-data verify-live` is the gate: it queries
|
|
101
|
+
every configured subgraph for real and **exits non-zero if anything failed *or was skipped***,
|
|
102
|
+
because "we did not check" is not proof. The unit suite is deliberately the opposite — hermetic, no
|
|
103
|
+
network, no credentials — so the two never get confused for one another.
|
|
104
|
+
|
|
105
|
+
## Known limitation, stated plainly
|
|
106
|
+
|
|
107
|
+
The Uniswap V3 subgraph's indexers are intermittently unavailable (`bad indexers`, or a ~20s
|
|
108
|
+
timeout). It is not our query — live introspection confirms the subgraph *is* Messari-standardized
|
|
109
|
+
and it has returned data. A per-protocol deadline means it costs at most 6s and degrades into
|
|
110
|
+
`MarketSnapshot.errors` rather than delaying the tick. Lending data does not depend on it.
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: curator-data
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Pluggable market-data registry: mandate-named sources merged into a source-agnostic MarketSnapshot
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: agent-tools,chainlink,defi,market-data,subgraph,thegraph
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: curator-schema
|
|
14
|
+
Requires-Dist: httpx>=0.27
|
|
15
|
+
Requires-Dist: pydantic>=2.7
|
|
16
|
+
Requires-Dist: python-dotenv>=1.0
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
19
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
20
|
+
Provides-Extra: x402
|
|
21
|
+
Requires-Dist: eth-account>=0.11; extra == 'x402'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# `/data` — the market data layer (Lane C)
|
|
25
|
+
|
|
26
|
+
**Everything the agent can see.** A pluggable source registry that merges partial contributions from
|
|
27
|
+
any number of providers into one source-agnostic `MarketSnapshot`.
|
|
28
|
+
|
|
29
|
+
Four sources ship today — three over HTTP, one reading a contract on-chain. The registry is the
|
|
30
|
+
product; they are its consumers.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Purpose
|
|
35
|
+
|
|
36
|
+
The agent decides where to put capital, so what it can see determines what it can do. This layer
|
|
37
|
+
owns that, with two goals held simultaneously:
|
|
38
|
+
|
|
39
|
+
1. **Win three Graph tracks now** — live Messari standardized subgraphs, the Token API, a standalone
|
|
40
|
+
MCP server, and x402 pay-per-query.
|
|
41
|
+
2. **Make adding a non-Graph provider later a 30-minute job** — Chainlink, Pyth, DefiLlama.
|
|
42
|
+
|
|
43
|
+
Those goals conflict only if the Graph adapters drive the design. They don't: the registry and the
|
|
44
|
+
`MarketSnapshot` shape came first, and no provider name appears anywhere above `sources/`
|
|
45
|
+
(asserted by [`tests/test_source_agnostic.py`](tests/test_source_agnostic.py)).
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Public interface
|
|
50
|
+
|
|
51
|
+
### The one call Lane B needs
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from curator_data import build_registry
|
|
55
|
+
|
|
56
|
+
registry = build_registry() # reads .env
|
|
57
|
+
snapshot = await registry.snapshot(
|
|
58
|
+
mandate.permitted_data_sources, # ["messari", "token_api"]
|
|
59
|
+
mandate.constraints.allowed_assets, # ["USDC", "WETH"]
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Implements the frozen [`DataSourceRegistry`](../packages/schema/python/curator_schema/ports.py)
|
|
64
|
+
port. Returns a [`MarketSnapshot`](../packages/schema/market-snapshot.schema.json).
|
|
65
|
+
|
|
66
|
+
| Method | Returns | Notes |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `await registry.snapshot(source_keys, assets)` | `MarketSnapshot` | Fans out concurrently, merges, never raises for source failure |
|
|
69
|
+
| `registry.available()` | `list[str]` | Registered keys — the set the genesis UI offers |
|
|
70
|
+
| `registry.describe()` | `list[dict]` | Key, human description and capabilities, for a source picker |
|
|
71
|
+
| `registry.sources_providing(*kinds)` | `list[str]` | Capability lookup: who supplies `"price"`? |
|
|
72
|
+
| `registry.register(key, factory)` | `None` | Runtime registration, for embedders and tests |
|
|
73
|
+
| `await registry.aclose()` | `None` | Releases every source's HTTP client. Also an async context manager |
|
|
74
|
+
|
|
75
|
+
### For late-binding consumers (Lane B)
|
|
76
|
+
|
|
77
|
+
The agent harness resolves its data seam from configuration rather than importing this lane
|
|
78
|
+
directly, so it needs an *instance* at a stable path. That is:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
AGENT_DATA_REGISTRY=curator_data.default:registry
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
[`curator_data/default.py`](curator_data/default.py) exposes a ready-made `Registry` built from the
|
|
85
|
+
environment. It satisfies the frozen `DataSourceRegistry` Protocol, and importing it cannot fail on
|
|
86
|
+
a missing `GRAPH_API_KEY` — sources are constructed lazily, so an absent credential degrades into
|
|
87
|
+
`snapshot.errors` rather than raising at import and dropping the caller back to fixtures.
|
|
88
|
+
|
|
89
|
+
### Market-level views
|
|
90
|
+
|
|
91
|
+
`MarketSnapshot` is a flat fact list, which is what keeps it source-agnostic — but that is not how
|
|
92
|
+
you render a table. Pivot it:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from curator_data.queries import pivot_markets, pivot_pools, prices, errors_as_dicts
|
|
96
|
+
|
|
97
|
+
for row in pivot_markets(snapshot): # sorted by APY, highest first
|
|
98
|
+
print(row.protocol, row.supply_apy, row.tvl_usd, row.utilization, row.fact_ids)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`MarketRow` / `PoolRow` both expose `.to_dict()`. `row.fact_ids` is what belongs in
|
|
102
|
+
`AllocationDecision.facts_used`.
|
|
103
|
+
|
|
104
|
+
### Command line
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
uv run curator-data sources # what a mandate may grant
|
|
108
|
+
uv run curator-data protocols # what is configured, and how to add more
|
|
109
|
+
uv run curator-data snapshot --assets USDC,WETH [--json]
|
|
110
|
+
uv run curator-data verify-live # prove the demo path hits live data (exit 1 if not)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`snapshot --json` emits a schema-valid `MarketSnapshot` on stdout, so another lane can consume real
|
|
114
|
+
data without importing any of this.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Data shapes
|
|
119
|
+
|
|
120
|
+
Everything crossing the boundary is defined in [`packages/schema`](../packages/schema/). Nothing new
|
|
121
|
+
is invented here.
|
|
122
|
+
|
|
123
|
+
```jsonc
|
|
124
|
+
{
|
|
125
|
+
"taken_at": "2026-07-25T14:05:00Z",
|
|
126
|
+
"facts": [
|
|
127
|
+
{
|
|
128
|
+
"id": "messari:yield:aave-v3/usdc", // stable across snapshots; cite this
|
|
129
|
+
"kind": "yield", // yield|price|tvl|liquidity|volatility|utilization|volume
|
|
130
|
+
"subject": { "protocol": "aave-v3", "market": "USDC", "chain": "base" },
|
|
131
|
+
"value": 0.0432, // 4.32% — a FRACTION, never 4.32
|
|
132
|
+
"unit": "apy_fraction",
|
|
133
|
+
"source": "messari", // provenance — the UI shows this
|
|
134
|
+
"observed_at": "2026-07-25T14:04:12Z"
|
|
135
|
+
}
|
|
136
|
+
],
|
|
137
|
+
"errors": [ { "source": "moonwell", "message": "HTTP 502" } ]
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**`errors` is not decoration.** A non-empty `errors` means the snapshot is *partial*. Show it to the
|
|
142
|
+
model — an agent that treats a partial view as complete is the failure mode this layer is shaped to
|
|
143
|
+
avoid, and it holds a key.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Sources that ship
|
|
148
|
+
|
|
149
|
+
| Key | Provides | Data | Live status |
|
|
150
|
+
|---|---|---|---|
|
|
151
|
+
| `messari` | `yield`, `tvl`, `utilization`, `liquidity` | Messari standardized subgraphs — lending markets and DEX pools on Base | ✅ Moonwell verified live (~15% USDC APY on $14.5M). Uniswap V3 works but its indexers are slow/intermittent |
|
|
152
|
+
| `aave` | `yield`, `tvl`, `utilization` | Aave V3 on Base, via **Aave's own** subgraph schema | ✅ verified live (3.41% USDC APY on $174.9M, 0.84 utilization) |
|
|
153
|
+
| `chainlink` | `price` | Chainlink feeds, read **on-chain** over JSON-RPC | ✅ verified live (WETH $1,858.98, USDC $0.9999). **Needs no credential** |
|
|
154
|
+
| `token_api` | `price` | The Graph Token API — prices derived from executed DEX swaps | ✅ verified live (WETH $1,857.95). Needs its own Graph Market JWT, *not* `GRAPH_API_KEY` |
|
|
155
|
+
|
|
156
|
+
All subgraph IDs live in [`curator_data/sources/protocols.py`](curator_data/sources/protocols.py),
|
|
157
|
+
including a list of candidates **rejected after live testing**, so nobody re-adds them.
|
|
158
|
+
|
|
159
|
+
### Two independent price sources, on purpose
|
|
160
|
+
|
|
161
|
+
`chainlink` reads an oracle; `token_api` derives price from executed DEX swaps. The mechanisms share
|
|
162
|
+
nothing, so agreement corroborates and **disagreement is a signal** — a stale oracle, a manipulated
|
|
163
|
+
pool, or a genuinely dislocated market. Live they sat 0.19% apart.
|
|
164
|
+
|
|
165
|
+
`prices(snapshot)` therefore returns every observation rather than one winner:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
{"WETH": {"price_usd": 1857.18, # median consensus
|
|
169
|
+
"sources": ["chainlink", "token_api"],
|
|
170
|
+
"observations": [{"source": "chainlink", "price_usd": 1858.98, ...}, ...],
|
|
171
|
+
"spread_pct": 0.19, "disagreement": False}}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`chainlink` also reads the **same feeds `totalAssets()` uses**, so the agent and the vault contract
|
|
175
|
+
can never disagree about what the portfolio is worth.
|
|
176
|
+
|
|
177
|
+
### Why Aave is a separate source rather than a branch in `messari`
|
|
178
|
+
|
|
179
|
+
Live introspection showed the published *Aave V3 Base* subgraph exposes `reserves`, not the
|
|
180
|
+
standardized `markets` — so one query shape genuinely cannot read it. It could have been a second
|
|
181
|
+
query inside the Messari adapter, but **`Fact.source` is provenance**: labelling data pulled from
|
|
182
|
+
Aave's own subgraph as `messari` would be false to anyone reading the dApp.
|
|
183
|
+
|
|
184
|
+
Adding it was `sources/aave.py` plus one line in `sources/__init__.py`. Nothing else changed — not
|
|
185
|
+
the registry, the schema, the MCP server or the agent. That is the extension-point claim exercised
|
|
186
|
+
on a real provider rather than a test double.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Adding a data source
|
|
191
|
+
|
|
192
|
+
The extension point. **One new file, one new line.**
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
# curator_data/sources/pyth.py
|
|
196
|
+
from curator_data.ports import BaseSource
|
|
197
|
+
from curator_data.facts import FactBuilder
|
|
198
|
+
|
|
199
|
+
class PythSource(BaseSource):
|
|
200
|
+
key = "pyth"
|
|
201
|
+
provides = ("price",)
|
|
202
|
+
description = "Pyth price feeds on Base"
|
|
203
|
+
|
|
204
|
+
async def fetch(self, assets):
|
|
205
|
+
builder = FactBuilder(self.key)
|
|
206
|
+
return [builder.usd("price", builder.subject(token=a), await self._read(a))
|
|
207
|
+
for a in assets]
|
|
208
|
+
|
|
209
|
+
def make_pyth_source(settings):
|
|
210
|
+
return PythSource(settings)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
# curator_data/sources/__init__.py — the ONLY other edit
|
|
215
|
+
SOURCE_FACTORIES = {
|
|
216
|
+
"messari": make_messari_source,
|
|
217
|
+
"token_api": make_token_api_source,
|
|
218
|
+
"aave": make_aave_source,
|
|
219
|
+
"chainlink": make_chainlink_source,
|
|
220
|
+
"pyth": make_pyth_source, # ← this line
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Then name `"pyth"` in a mandate's `permitted_data_sources`. Nothing else changes — not the registry,
|
|
225
|
+
not the schema, not the agent, not the dApp. Because sources are selected by *capability*
|
|
226
|
+
(`provides`), the new source immediately participates in price queries and in the MCP server's
|
|
227
|
+
`get_token_price` — and it is cross-checked against the existing price sources for free.
|
|
228
|
+
|
|
229
|
+
**This is not a hypothetical.** `aave` and `chainlink` were both added this way *after* the registry
|
|
230
|
+
shipped, and `chainlink` is not even an HTTP API — it reads a contract over JSON-RPC. Neither
|
|
231
|
+
required a change outside its own file plus one line here.
|
|
232
|
+
|
|
233
|
+
**Adding a protocol to an existing source is even smaller** — one `Protocol(...)` line in
|
|
234
|
+
`protocols.py`. That is the point of Messari standardized subgraphs: every lending market answers
|
|
235
|
+
the same GraphQL document, so a new protocol needs no adapter at all.
|
|
236
|
+
|
|
237
|
+
### What a source must guarantee
|
|
238
|
+
|
|
239
|
+
- **Never raise for expected failure** — a timeout, a rate limit, a missing market. Return what you
|
|
240
|
+
have and call `self.note("...")` for the rest; notes surface in `snapshot.errors`.
|
|
241
|
+
- **Normalise at the boundary.** `apy_fraction` is `0.0432` for 4.32%. Use
|
|
242
|
+
`FactBuilder.apy_from_percent()` when the upstream reports percentages — Messari does.
|
|
243
|
+
- **Never guess an identifier.** An unknown token symbol is a note naming the fix, not a guessed
|
|
244
|
+
contract address. This system trades with a real key.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Dependencies
|
|
249
|
+
|
|
250
|
+
**Requires:** `curator-schema` (the frozen interface), `httpx`, `pydantic`, `python-dotenv`.
|
|
251
|
+
Optional: `eth-account` for x402 (`pip install curator-data[x402]`).
|
|
252
|
+
|
|
253
|
+
**Depends on no other lane.** Lane B consumes this through the `DataSourceRegistry` port; nothing
|
|
254
|
+
here imports `agent/`, `venues/`, `web/` or `contracts/`.
|
|
255
|
+
|
|
256
|
+
### Credentials
|
|
257
|
+
|
|
258
|
+
| Variable | Required | Default | Purpose |
|
|
259
|
+
|---|---|---|---|
|
|
260
|
+
| `GRAPH_API_KEY` | **yes** | — | Subgraph gateway. Free at [thegraph.com/studio](https://thegraph.com/studio) → API Keys |
|
|
261
|
+
| `TOKEN_API_KEY` | no | falls back to `GRAPH_API_KEY` | Token API bearer token — a *separate* credential |
|
|
262
|
+
| `TOKEN_API_URL` | no | `https://api.pinax.network/v1` | ⚠️ **not** `token-api.thegraph.com` — that host, named in The Graph's own docs, does not resolve. Verified live |
|
|
263
|
+
| `DATA_CHAIN` | no | `base` | Chain to query |
|
|
264
|
+
| `DATA_REQUEST_TIMEOUT_S` | no | `15` | Per-HTTP-request timeout |
|
|
265
|
+
| `DATA_SOURCE_TIMEOUT_S` | no | `20` | Per-source ceiling inside `snapshot()` |
|
|
266
|
+
| `X402_ENABLED` | no | `false` | Pay-per-query. Needs `X402_PRIVATE_KEY` too |
|
|
267
|
+
| `X402_PRIVATE_KEY` | no | — | Wallet with a few dollars of USDC on Base |
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## The MCP server
|
|
272
|
+
|
|
273
|
+
[`curator_mcp/`](curator_mcp/) is a **separate distribution** — its own `pyproject.toml`,
|
|
274
|
+
`README.md` and [`SKILL.md`](curator_mcp/SKILL.md). It installs standalone from a clone:
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
uv pip install ./data/curator_mcp # siblings resolve from the repo; verified in a clean 3.10 venv
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
```jsonc
|
|
281
|
+
{ "mcpServers": { "curator": {
|
|
282
|
+
"command": "uv", "args": ["run", "--directory", "/abs/path/to/repo", "curator-mcp"],
|
|
283
|
+
"env": { "GRAPH_API_KEY": "..." } } } }
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Publishing to PyPI turns that into `uvx curator-mcp` with no clone at all. The distributions are
|
|
287
|
+
built and verified to install from wheels alone; only the upload is outstanding — see
|
|
288
|
+
[PUBLISHING.md](PUBLISHING.md).
|
|
289
|
+
|
|
290
|
+
Tools: `compare_protocols`, `get_market_yields`, `list_markets`, `get_token_price`.
|
|
291
|
+
|
|
292
|
+
It shares this package's sources rather than reimplementing them, so the reusable product runs the
|
|
293
|
+
same code as our demo. Our agent talks to the registry directly (in-process, no stdio hop) — it is
|
|
294
|
+
visibly *a* consumer, not *the* consumer.
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## x402 — pay-per-query
|
|
299
|
+
|
|
300
|
+
Off by default. Enabled only by `X402_ENABLED=true` **and** `X402_PRIVATE_KEY`.
|
|
301
|
+
|
|
302
|
+
It is a **decorator over the gateway transport**, not a data source:
|
|
303
|
+
|
|
304
|
+
```
|
|
305
|
+
GatewayClient API-key auth. Always works. The default.
|
|
306
|
+
X402GatewayClient wraps it. Tries to pay; delegates on ANY failure.
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
There is no code path where enabling x402 loses data the API-key path would have returned. Worst
|
|
310
|
+
case is a wasted round-trip and a note in `errors`. A client-side ceiling refuses to sign anything
|
|
311
|
+
above 1 USDC — a market-data query costs a fraction of a cent, so a larger demand means something is
|
|
312
|
+
wrong.
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## Assumptions & invariants
|
|
317
|
+
|
|
318
|
+
Callers may rely on all of these:
|
|
319
|
+
|
|
320
|
+
- **`snapshot()` never raises** for source failure, timeout or an unknown source key. Those degrade
|
|
321
|
+
into `errors[]`. It raises only for programmer error.
|
|
322
|
+
- **Every `Fact.source` is the registry key that produced it.** Enforced at merge; a mislabelled
|
|
323
|
+
fact is corrected *and* reported.
|
|
324
|
+
- **Fact ids are unique within a snapshot** and stable across snapshots for the same subject, so
|
|
325
|
+
`facts_used` citations are unambiguous and decisions can be diffed over time.
|
|
326
|
+
- **APY is always a fraction.** `0.0432` means 4.32%.
|
|
327
|
+
- **Amounts in USD are floats; no `uint256` crosses this boundary** — this layer reports market
|
|
328
|
+
observations, not balances.
|
|
329
|
+
- **A source is constructed once** and reused across ticks, so connection pools survive. Call
|
|
330
|
+
`aclose()` when finished.
|
|
331
|
+
- **`permitted_data_sources` is access control.** A registered source not named is never consulted.
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## Tests
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
uv run pytest data/tests -q # 180 tests, no network, no credentials
|
|
339
|
+
uv run curator-data verify-live # the live path — needs GRAPH_API_KEY
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
The unit suite never touches the network (`httpx.MockTransport`); `verify-live` only touches the
|
|
343
|
+
network. Live gateway data on the demo path is a Graph submission gate, so it is a command rather
|
|
344
|
+
than an assumption.
|
|
345
|
+
|
|
346
|
+
`tests/conftest.py` strips credentials and disables `.env` discovery for every test. That is
|
|
347
|
+
deliberate: when a real `GRAPH_API_KEY` first landed in `.env`, three tests changed behaviour and
|
|
348
|
+
several others silently began making live calls. The suite now asserts the same thing on a laptop
|
|
349
|
+
with a full `.env` and on a fresh clone with none — which is what the macOS handoff needs.
|
|
350
|
+
|
|
351
|
+
> ⚠️ **Shared-venv trap.** `uv sync --extra data` *prunes* every package not in the named extras,
|
|
352
|
+
> which silently uninstalls other lanes' dependencies. Always sync all of them:
|
|
353
|
+
> `uv sync --extra dev --extra data --extra agent --extra venues`.
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## Layout
|
|
358
|
+
|
|
359
|
+
```
|
|
360
|
+
curator_data/
|
|
361
|
+
config.py Settings — every env value resolved in one place
|
|
362
|
+
ports.py BaseSource: the class a new source subclasses
|
|
363
|
+
facts.py FactBuilder — unit-safe, provenance-stamped Fact construction
|
|
364
|
+
registry.py fan-out, merge, degradation, capability lookup ← the extension point
|
|
365
|
+
queries.py pivots from flat facts back into market rows
|
|
366
|
+
verify.py live-path checks (importable; the CLI just prints them)
|
|
367
|
+
cli.py curator-data: sources / protocols / snapshot / verify-live
|
|
368
|
+
graph/ gateway transport (GraphQL over httpx) + transport factory
|
|
369
|
+
sources/
|
|
370
|
+
__init__.py THE REGISTRATION TABLE — one line per source
|
|
371
|
+
protocols.py protocol -> subgraph id (data, not code)
|
|
372
|
+
tokens.py symbol -> contract address (data, not code)
|
|
373
|
+
messari.py Messari standardized subgraphs
|
|
374
|
+
token_api.py Graph Token API
|
|
375
|
+
x402/ pay-per-query transport decorator, feature-flagged
|
|
376
|
+
curator_mcp/ SEPARATE DISTRIBUTION — the standalone MCP server
|
|
377
|
+
tests/
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Plan: [`plans/2026-07-25-lane-c-data.md`](../plans/2026-07-25-lane-c-data.md).
|
|
381
|
+
Decisions and their reasoning: [`docs/build-log.md`](../docs/build-log.md).
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Publishing `curator-mcp` to PyPI
|
|
2
|
+
|
|
3
|
+
The MCP server is a submission deliverable for The Graph's Track 1, whose stated criteria include
|
|
4
|
+
**Reusability & completeness (25%)** and whose defining requirement is *"reusable tooling or
|
|
5
|
+
infrastructure … not a single end-user app."* A server only we can run does not meet that.
|
|
6
|
+
|
|
7
|
+
**Everything below has been verified except the upload itself, which needs a PyPI token.**
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Current state: it installs, without PyPI
|
|
12
|
+
|
|
13
|
+
The blocker reported in the phase 2 plan §6 — `uv pip install ./data/curator_mcp` failing with
|
|
14
|
+
*"Because curator-data was not found in the package registry … unsatisfiable"* — **is fixed.**
|
|
15
|
+
|
|
16
|
+
`data/curator_mcp/pyproject.toml` now carries relative `[tool.uv.sources]` for its two siblings, so
|
|
17
|
+
a clone resolves them locally. Verified in a clean Python 3.10 venv outside the repo:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uv pip install ./data/curator_mcp # exit 0
|
|
21
|
+
python -c "import asyncio; from curator_mcp.server import build_server; \
|
|
22
|
+
print(sorted(t.name for t in asyncio.run(build_server().list_tools())))"
|
|
23
|
+
# ['compare_protocols', 'get_market_yields', 'get_token_price', 'list_markets']
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Those entries are **inert once the packages exist on PyPI** — a registry version satisfies the
|
|
27
|
+
requirement first — and they match the root workspace's `editable = true`, without which `uv sync`
|
|
28
|
+
fails with *"Requirements contain conflicting URLs for package curator-data"*.
|
|
29
|
+
|
|
30
|
+
## Publishing makes `uvx curator-mcp` work for someone who has never seen the repo
|
|
31
|
+
|
|
32
|
+
That is the version worth having: a judge pasting our MCP config into their own client, with no
|
|
33
|
+
clone.
|
|
34
|
+
|
|
35
|
+
### The short version
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
./data/publish.sh # build + verify, uploads nothing — safe to run now
|
|
39
|
+
./data/publish.sh --publish # build, verify, confirm, then upload
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The dry run is the default deliberately. It builds all three distributions and proves they install
|
|
43
|
+
from wheels alone in a clean 3.10 venv — the check that matters, because a PyPI version number can
|
|
44
|
+
never be re-uploaded. `--publish` additionally requires `UV_PUBLISH_TOKEN` and prompts before
|
|
45
|
+
uploading, since `curator-schema` belongs to Wave 0 rather than Lane C.
|
|
46
|
+
|
|
47
|
+
The manual equivalents are below, for anyone who would rather see each step.
|
|
48
|
+
|
|
49
|
+
### 1. Build
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
uv build --out-dir dist packages/schema/python # curator-schema
|
|
53
|
+
uv build --out-dir dist data # curator-data
|
|
54
|
+
uv build --out-dir dist data/curator_mcp # curator-mcp
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Verified — all three produce a wheel and an sdist:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
curator_schema-0.1.0-py3-none-any.whl curator_schema-0.1.0.tar.gz
|
|
61
|
+
curator_data-0.2.0-py3-none-any.whl curator_data-0.2.0.tar.gz
|
|
62
|
+
curator_mcp-0.2.0-py3-none-any.whl curator_mcp-0.2.0.tar.gz
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 2. Prove the wheels stand alone *before* uploading
|
|
66
|
+
|
|
67
|
+
This is the step that catches a broken dependency chain while it is still free to fix:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
uv venv --python 3.10 /tmp/wheelcheck/.venv
|
|
71
|
+
uv pip install --python /tmp/wheelcheck/.venv --find-links dist curator-mcp
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Verified passing.** Resolving from `--find-links` alone proves the wheel metadata carries real
|
|
75
|
+
dependency names rather than the local path sources — path sources are a uv resolution hint and are
|
|
76
|
+
deliberately not baked into wheels.
|
|
77
|
+
|
|
78
|
+
### 3. Upload — bottom of the dependency chain first
|
|
79
|
+
|
|
80
|
+
Order matters: each upload must be able to resolve the one below it.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
export UV_PUBLISH_TOKEN=pypi-... # from https://pypi.org/manage/account/token/
|
|
84
|
+
|
|
85
|
+
uv publish --token "$UV_PUBLISH_TOKEN" dist/curator_schema-*
|
|
86
|
+
uv publish --token "$UV_PUBLISH_TOKEN" dist/curator_data-*
|
|
87
|
+
uv publish --token "$UV_PUBLISH_TOKEN" dist/curator_mcp-*
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Then confirm the thing we actually claim in `SKILL.md`:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
uvx curator-mcp # from a machine that has never seen this repo
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Names are free
|
|
97
|
+
|
|
98
|
+
Checked against PyPI: `curator-mcp`, `curator-data` and `curator-schema` all return 404. Nobody has
|
|
99
|
+
claimed them.
|
|
100
|
+
|
|
101
|
+
> **`curator-schema` belongs to Wave 0**, not to Lane C. It needs no edit to publish — its metadata
|
|
102
|
+
> is already valid — but the decision to publish it is not Lane C's to take alone. It is the bottom
|
|
103
|
+
> of the chain, so `curator-data` cannot be published without it.
|
|
104
|
+
|
|
105
|
+
### Test first, if you want to be careful
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
uv publish --publish-url https://test.pypi.org/legacy/ --token "$UV_TEST_TOKEN" dist/*
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Note that a version number on PyPI is **permanent** — `0.2.0` can never be re-uploaded, only
|
|
112
|
+
superseded by `0.2.1`. Get the wheel-only check in step 2 passing first.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Why the version numbers moved to 0.2.0
|
|
117
|
+
|
|
118
|
+
Not changelog hygiene. **uv caches built wheels by name and version**, so rebuilding `0.1.0` after
|
|
119
|
+
adding modules serves the stale wheel and fails with a confusing `ModuleNotFoundError: No module
|
|
120
|
+
named 'curator_data.sources.feeds'`. Hit during this work; `--no-cache` confirmed the diagnosis.
|
|
121
|
+
Bump the version whenever the package contents change.
|
|
122
|
+
|
|
123
|
+
## Checklist before publishing
|
|
124
|
+
|
|
125
|
+
- [x] `LICENSE` present at repo root (MIT), matching what both `pyproject.toml`s declare
|
|
126
|
+
- [x] `readme` set on every package, so PyPI shows a description
|
|
127
|
+
- [x] Classifiers and keywords set
|
|
128
|
+
- [x] `requires-python = ">=3.10"` — the MCP SDK's floor, verified by installing on 3.10
|
|
129
|
+
- [x] Wheels install from `--find-links` with no repo present
|
|
130
|
+
- [x] `curator-mcp` exposes the `curator-mcp` console script
|
|
131
|
+
- [ ] **PyPI token** — needs a human
|
|
132
|
+
- [ ] Repo public (a stated requirement of every Graph track anyway)
|