chainplot: scoped onchain events to a reproducible dataset and dashboard - #1
Conversation
|
Important Review skippedToo many files! This PR contains 198 files, which is 48 over the limit of 150. To get a review, reduce the PR to 150 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Free Run ID: ⛔ Files ignored due to path filters (3)
📒 Files selected for processing (198)
You can disable this status message by setting the chainstacklabs is on CodeRabbit Free, which includes PR summaries. Ask your admin to upgrade for code reviews.
Comment |
f877173 to
a9e6276
Compare
An agent-first toolkit. A project declares which contracts and events it cares about; chainplot indexes exactly that range, exports it to parquet, runs SQL over it, and builds a static dashboard that needs no server. How it holds together: - Plans are digest-bound. `plan` records the project digest and every job boundary; `apply` refuses a plan whose project changed underneath it, so a run either does what was reviewed or nothing at all. - Coverage is proven rather than assumed. Every ingested range is joined back to block hashes from the chain, and the promotion gate refuses to build a release over a range that cannot be shown complete. - Queries run sandboxed. The DuckDB worker reads its snapshots, then closes external access before any project-supplied SQL executes, and admits only statements that survive `json_serialize_sql` as read-only. - Amounts stay exact. A uint256 travels as a decimal string from parquet to the rendered cell; nothing but chart geometry ever touches a double. - Releases are content-addressed and forkable. `publish` writes a release under a digest of its own content; `fork` reconstructs the project from a published release and recomputes it offline. Three examples cover the surface, one per release mode: a year of USDC supply (1.43M mint/burn events), WETH wrap/unwrap flows, and transfer traffic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a9e6276 to
ecae00f
Compare
|
@easeev I ran the whole pipeline end to end against Robinhood Chain: coverage gate proved every range complete, released, published to R2, then forked from the public URL with no credentials and recomputed every figure exactly. The core works. I've grouped what I ran into into two sets: a few things I'd suggest fixing before the repo goes public, and a few that can wait. The dashboard that run produced is live: FOMO flow on Robinhood Chain. Its Suggested before the repo goes publicC1 —
|
rindexer snake_cases the manifest, contract and event names into Postgres identifiers; chainplot lowercased them. The two agree on `Transfer` — the only event any template uses — and disagree on everything longer: `RelayERC20Deposit` becomes `relay_erc_20_deposit` upstream and `relayerc20deposit` here, so rows landed in a table `apply` could not find. naming.ts ports rindexer's `camel_to_snake` byte for byte, and both the coverage lookup and the export read through it. The snapshot file name keeps chainplot's own convention, which the templates' `snapshot:` paths document. Past 63 characters rindexer compacts the cursor table with a hash and Postgres truncates the schema; neither can be derived, so `validate` now refuses that combination before a block is indexed, naming the source and event that make up the excess. The live compose e2e gains a second source whose event is multi-word (stETH `TransferShares`) and asserts it indexes, proves coverage and exports — the case that was invisible while every fixture used `Transfer`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
publish verified every uploaded byte against the bucket and then returned a dashboard_url it had never fetched. A public_base_url naming a different bucket, a bucket with public reads off, or an S3 endpoint that folded the bucket into every key all passed that verification and 404'd for every reader — while the command reported ok: true. After promoting latest.json, publish now GETs the dashboard URL and fails the run on anything but 200, spelling out the three causes. The upload has still happened, so the error is retryable and points back at publish. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
serve hard-coded 127.0.0.1. Run inside the producer container, as every template and example README instructs, that is the container's own loopback, and the compose files published no ports: the server started, printed a URL, and nothing on the host could open it. serve takes --host (default unchanged: 127.0.0.1). The compose files publish 4173 to the host's loopback only, and the READMEs show the form that works — `serve --host 0.0.0.0 --port 4173` in the container, http://127.0.0.1:4173 on the host. The URL the command reports is that one, never 0.0.0.0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A scaffolded project told the user to put their RPC endpoint in .env and shipped nothing that would keep that file out of git — nor the run journal or the built release. init now writes a .gitignore covering all three. The copied chainplot.yaml also kept the template's own id, so every project scaffolded from ingest-transfers was called ingest-transfers. The id is now derived from the output directory, reduced to the characters an id may carry, with the template id as the fallback for a name that leaves nothing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
publish_targets came across a fork verbatim. No credentials travel with a release, so nothing could be written there — but the forked project's first `publish` would aim at a stranger's bucket and prefix. fork now removes the block and says so in a warning, alongside the chain sources it already strips. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The package is private and nothing links its bin, so `chainplot …` as written in the fork example was "command not found" on a fresh clone. The quickstart now gives the alias and the `pnpm link --global` alternative, and the fork example points at it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…here The run journal reuses the outcome of a plan that already succeeded. For a publish that meant: empty the bucket, run publish again, and be told "N files uploaded" with nothing uploaded — for as long as the journal entry lived, and with deleting .chainplot/runs/<key> by hand as the only way out. Before replaying a publish, apply now asks the target whether release.json is still under the recorded prefix and latest.json still exists. If either is gone the plan runs again and the journal records why it was not reused. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The client is path-style, so the bucket is the first path segment of every request. An endpoint that already has a path — R2's console shows `…/<bucket>` beside the account URL — makes the real bucket a prefix on every key: uploads land one level too deep, verification passes, publish reports success, and the public URL serves nothing. CHAINPLOT_S3_ENDPOINT must now be a bare origin. Anything else is a validation error that names the origin to set instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
--jsonl was read straight from argv but never declared to the parser, so commander rejected it as an unknown option and progress streaming could not be switched on. It is an option on apply and refresh now. --help without --json returned a validation error, and with --json returned "help is not available in JSON mode". Asking what the commands are is not running one: help needs no --json, the text rides in the envelope when --json is given, and main prints it plain when it is not. The gate on real commands is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The producer ran as root, so on Linux everything it wrote into the bind-mounted project — .chainplot/, dist/ — belonged to root, and the host user could neither read results nor delete the project without sudo. The `chainplot` shim now drops to the uid:gid that owns /workspace before starting the CLI; where the mount is root-owned it is a no-op. HOME moves to a world-readable directory so DuckDB finds its pre-installed postgres extension as any user. CI builds a project inside the container and asserts the host user owns, and can remove, what came out. The image also gains templates/, which `init` and `templates list` read at runtime and which had never been copied in. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The scaffolded project pins mainnet blocks, but the suite ran whenever RPC_URL was set. An endpoint for any other chain turned it red for a reason unrelated to the code. The gate now asks the endpoint which chain it serves and skips unless the answer is 1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
"Anyone can fork it" was true of the format but told a reader nothing about this release, and nothing about needing the CLI. The footer now says either that the release is results-only and cannot be recomputed, or links to a README section that walks through cloning, building and forking. The README states plainly that forkability needs dataset_included or dataset_referenced. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The budget is a block count, and 100k blocks is two weeks on Ethereum, two days on Base and an afternoon on Arbitrum One. Someone who only knows one chain carries the default onto another and silently gets a different span. The template and the limits table now say so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
apply fans a multi-event source out into one job per event before calling exportEventTable, which reads events[0]. Read on its own that looks like the second event is dropped; it is not, but a job carrying several would be exported as one without a word. Refuse it instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Pushed fixes for every item, one commit per finding (or pair): Before public
Can wait — done anyway
Snapshot file names keep chainplot's lowercase convention; only the Postgres lookup follows rindexer's snake_case. Say if you'd rather they match as well. |
`getRowsJson()` returns positional arrays; the worker indexed them as objects, so `total` and `distinct_keys` were both `undefined` and were coerced to 0. Two consequences: every export reported `"rows": 0` to the progress stream and the run journal, and the duplicate-key gate compared 0 to 0, so it has never fired and could not. Read by column name instead, and make a missing count column an error rather than a default — the silent 0 is what hid this. The gate and the count move into exporter.ts where they can be tested; the worker keeps the sequencing. Tests cover both readers against real DuckDB output, and a table holding a genuinely duplicated physical key now trips the gate. Verified against the live database: the export that reported 0 reports 20618. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVa8K4piCcGRDA5f83RkYt
`--from` takes a release directory or a publish root when the source is
local, but remotely only ever fetched `${base}/release.json`. The README
section added with the fork footer documents the publish root, so its own
example returned `fork fetch: HTTP 404`.
Remote now falls back to latest.json and verifies the checksum it names,
mirroring the local branch. Two further things this exposed: the pointer's
`release_prefix` is written relative to the *bucket*, not to `--from`, so
appending it to a publish root that already ends in that prefix repeats it
— the shared segments are dropped. And the remote file and dataset fetches
ignored the prefix entirely, which nothing had noticed because no remote
fork had ever followed a pointer.
The publish root is the URL worth documenting: a release prefix changes on
every publish. Verified against the live bucket — the README's own line now
pulls 39 files and 3 referenced datasets and recomputes every figure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVa8K4piCcGRDA5f83RkYt
Refusing an endpoint that carries a path was right, but the live suite's gate calls the same reader at module scope. A missing variable returned null and skipped; a malformed one now threw, so the file failed during collection as a bare "Unknown Error" with no test name on it. Both answers mean the same thing to a gate: these tests cannot run here. s3EnvIfUsable says so without losing the strict read that publish depends on. The incentive was backwards otherwise — no S3 config skipped cleanly, correct config ran, and slightly wrong config turned the suite red over a publish-time setting unrelated to the tests being run. CI could not catch it either, having no S3 environment to get wrong. Same shape as gating the live e2e on RPC_URL merely being set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVa8K4piCcGRDA5f83RkYt
|
@easeev Re-ran everything. Three corrections from me: C6 I withdraw — The retest turned up three things. They were small, so I've pushed fixes rather than filed them — one commit each.
|
The parquet a source/event exports to was `<contract>_<event lowercased>`, while the table it came from is snake_cased: two conventions for one name, and the difference shows exactly when an event is more than one word. `snapshotFileName` now applies rindexer's rule to both parts, so `TransferShares` exports to `steth_transfer_shares.parquet` and a `snapshot:` path follows from the event name by one rule. Single-word events, which every template and example uses, are unchanged. The template and README say how the path is derived. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Chainplot for internal review. The repo was empty, so this PR carries the whole
toolkit against an empty
main— the diff is the codebase, not a changeset.What it does
A project declares which contracts and events it cares about. Chainplot indexes
exactly that range, exports it to parquet, runs SQL over it, and builds a static
dashboard that needs no server. Every step is a plan an agent can review before
it runs, and a release anyone can fork and recompute.
Where to start reading
src/project/src/plan/src/ingest/src/query/src/publish/,src/fork/viewer/src/The properties worth checking
planrecords the project digest and every jobboundary;
applyrefuses a plan whose project changed underneath it. A rundoes what was reviewed, or nothing.
the chain. The promotion gate refuses to build a release over a range that
cannot be shown complete, so a dashboard never quietly renders a gap.
enable_external_access=falsebefore any project-supplied SQL runs, and admitsonly statements that survive
json_serialize_sqlas read-only. The ordering insrc/query/workerMain.tsis the control — a test fails if it is reversed.the rendered cell. Only chart geometry touches a double, and the exact figure
stays on hover.
publishwrites under a digest of the release's owncontent;
forkrebuilds the project from a published release and recomputes itwith no RPC, no credentials and no reindexing.
Evidence
rm -rf dist viewer/dist && pnpm test), plus alive compose e2e that indexes mainnet through the producer image — including a
multi-word event name, the case that surfaced in review.
(984,251 mints, 441,043 burns) over 2,587,000 blocks, 9 segments per source,
rebuilt from the parquet in ~4s into an 838 KB release.
dataset_referencedverified end to end against Cloudflare R2: published,then forked from the public URL with no access to the producer, and recomputed
offline to matching row counts.
Live dashboards, published from the examples in this PR:
Known limits
is why ingest is containerised rather than run on the host.
bounds download size, not what can be queried — the dashboard page is ~790 KB
regardless of release mode.
🤖 Generated with Claude Code