Skip to content

chainplot: scoped onchain events to a reproducible dataset and dashboard - #1

Merged
easeev merged 19 commits into
mainfrom
chainplot-v0.2
Sep 20, 2026
Merged

easeev merged 19 commits into
mainfrom
chainplot-v0.2

Conversation

@easeev

@easeev easeev commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

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.

chainplot init my-project && cd my-project
chainplot plan --intent ingest      # what will run, and over which blocks
chainplot apply --plan plans/latest.json
chainplot build && chainplot serve

Where to start reading

Area Path Why it matters
Project schema src/project/ The single source of truth a plan is digested from
Plan / apply src/plan/ Digest binding, promotion gate, idempotency journal
Ingest src/ingest/ Bounded rindexer jobs, coverage proof by block-hash join
Query sandbox src/query/ Where project SQL runs, and what it is allowed to touch
Publish / fork src/publish/, src/fork/ Content-addressed releases, offline recompute
Viewer viewer/src/ Static dashboard, BigInt-exact rendering

The properties worth checking

  • A plan is binding. plan records the project digest and every job
    boundary; apply refuses a plan whose project changed underneath it. A run
    does what was reviewed, or nothing.
  • Coverage is proven. Ingested ranges are joined back to block hashes from
    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.
  • Project SQL is sandboxed. The DuckDB worker reads its snapshots, then sets
    enable_external_access=false before any project-supplied SQL runs, and admits
    only statements that survive json_serialize_sql as read-only. The ordering in
    src/query/workerMain.ts is the control — a test fails if it is reversed.
  • Amounts are exact. A uint256 travels as a decimal string from parquet to
    the rendered cell. Only chart geometry touches a double, and the exact figure
    stays on hover.
  • Releases are forkable. publish writes under a digest of the release's own
    content; fork rebuilds the project from a published release and recomputes it
    with no RPC, no credentials and no reindexing.

Evidence

  • 292 tests over 42 files, from a clean rebuild (rm -rf dist viewer/dist && pnpm test), plus a
    live compose e2e that indexes mainnet through the producer image — including a
    multi-word event name, the case that surfaced in review.
  • A year of USDC mint/burn indexed live against mainnet: 1,425,294 events
    (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_referenced verified 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

  • rindexer's image is linux/amd64 only; on arm64 it runs under emulation, which
    is why ingest is containerised rather than run on the host.
  • Results carried inside a release are capped at 10,000 rows per query. The cap
    bounds download size, not what can be queried — the dashboard page is ~790 KB
    regardless of release mode.
  • Multi-year scope is bounded by indexing time, not by the release format.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown

Important

Review skipped

Too 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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Free

Run ID: 8c2f06e3-a46e-4d3d-b760-500f4f2fcd2a

📥 Commits

Reviewing files that changed from the base of the PR and between f924441 and 79bb63b.

⛔ Files ignored due to path filters (3)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • templates/fixture-transfers/snapshots/amounts.parquet is excluded by !**/*.parquet
  • viewer/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (198)
  • .dockerignore
  • .env.example
  • .github/workflows/ci.yml
  • .gitignore
  • LICENSE
  • NOTICES.md
  • README.md
  • docker/producer.Dockerfile
  • docs/acceptance.md
  • docs/capabilities.md
  • docs/compatibility.md
  • docs/plans/2026-09-12-m1-fixture-cli.md
  • docs/plans/2026-09-13-m2-ingest-snapshots.md
  • docs/plans/2026-09-13-m3-models-dashboards-static-build.md
  • docs/plans/2026-09-13-m4-publish-fork.md
  • docs/plans/2026-09-14-m5-examples-acceptance.md
  • docs/security.md
  • docs/specs/2026-09-12-chainplot-design.md
  • examples/README.md
  • examples/fork/README.md
  • examples/protocol-flows/.env.example
  • examples/protocol-flows/README.md
  • examples/protocol-flows/abis/WETH.json
  • examples/protocol-flows/chainplot.yaml
  • examples/protocol-flows/compose.yaml
  • examples/protocol-flows/queries/deposit_count.sql
  • examples/protocol-flows/queries/largest_deposits.sql
  • examples/protocol-flows/queries/withdrawal_count.sql
  • examples/transfer-traffic/.env.example
  • examples/transfer-traffic/README.md
  • examples/transfer-traffic/abis/ERC20.json
  • examples/transfer-traffic/chainplot.yaml
  • examples/transfer-traffic/compose.yaml
  • examples/transfer-traffic/queries/top_transfers.sql
  • examples/transfer-traffic/queries/transfer_count.sql
  • examples/transfer-traffic/tests/usdc.yaml
  • examples/usdc-supply/.env.example
  • examples/usdc-supply/README.md
  • examples/usdc-supply/abis/ERC20.json
  • examples/usdc-supply/chainplot.yaml
  • examples/usdc-supply/compose.yaml
  • examples/usdc-supply/models/daily.sql
  • examples/usdc-supply/queries/cumulative_net.sql
  • examples/usdc-supply/queries/daily_events.sql
  • examples/usdc-supply/queries/daily_flow.sql
  • examples/usdc-supply/queries/largest_mints.sql
  • examples/usdc-supply/queries/net_change.sql
  • examples/usdc-supply/queries/total_burned.sql
  • examples/usdc-supply/queries/total_minted.sql
  • package.json
  • schemas/coverage.schema.json
  • schemas/latest.schema.json
  • schemas/lock.schema.json
  • schemas/manifest.schema.json
  • schemas/plan.schema.json
  • schemas/progress.schema.json
  • schemas/project.schema.json
  • schemas/release.schema.json
  • schemas/result.schema.json
  • scripts/m0-probe/.env.example
  • scripts/m0-probe/abis/ERC20.json
  • scripts/m0-probe/compose.yaml
  • scripts/m0-probe/rindexer.yaml
  • scripts/write-fixture-parquet.ts
  • src/cli/commands/apply.ts
  • src/cli/commands/build.ts
  • src/cli/commands/capabilities.ts
  • src/cli/commands/describe.ts
  • src/cli/commands/doctor.ts
  • src/cli/commands/fork.ts
  • src/cli/commands/init.ts
  • src/cli/commands/plan.ts
  • src/cli/commands/publish.ts
  • src/cli/commands/query.ts
  • src/cli/commands/refresh.ts
  • src/cli/commands/runs.ts
  • src/cli/commands/schemaShow.ts
  • src/cli/commands/serve.ts
  • src/cli/commands/templates.ts
  • src/cli/commands/test.ts
  • src/cli/commands/validate.ts
  • src/cli/envelope.ts
  • src/cli/main.ts
  • src/cli/run.ts
  • src/config/env.ts
  • src/fork/fetchGuard.ts
  • src/fork/importRelease.ts
  • src/ingest/adapter.ts
  • src/ingest/coverage.ts
  • src/ingest/coverageStore.ts
  • src/ingest/exportWorkerMain.ts
  • src/ingest/exporter.ts
  • src/ingest/rindexer/index.ts
  • src/ingest/rindexer/inspectCoverage.ts
  • src/ingest/rindexer/naming.ts
  • src/ingest/rindexer/renderConfig.ts
  • src/ingest/rindexer/runBounded.ts
  • src/plan/apply.ts
  • src/plan/digest.ts
  • src/plan/errors.ts
  • src/plan/generate.ts
  • src/project/assertions.ts
  • src/project/columns.ts
  • src/project/limits.ts
  • src/project/load.ts
  • src/project/modelGraph.ts
  • src/project/types.ts
  • src/project/validate.ts
  • src/publish/directory.ts
  • src/publish/doctor.ts
  • src/publish/latestPointer.ts
  • src/publish/publishRelease.ts
  • src/publish/s3.ts
  • src/publish/serve.ts
  • src/publish/sourceBundle.ts
  • src/publish/target.ts
  • src/publish/writeRelease.ts
  • src/query/runQuery.ts
  • src/query/sqlGuard.ts
  • src/query/workerMain.ts
  • src/rpc/client.ts
  • src/rpc/heads.ts
  • src/runtime/journal.ts
  • src/runtime/locks.ts
  • src/snapshot/describe.ts
  • templates/fixture-transfers/chainplot.yaml
  • templates/fixture-transfers/queries/raw_amounts.sql
  • templates/fixture-transfers/tests/amounts.yaml
  • templates/ingest-transfers/.env.example
  • templates/ingest-transfers/README.md
  • templates/ingest-transfers/abis/ERC20.json
  • templates/ingest-transfers/chainplot.yaml
  • templates/ingest-transfers/compose.yaml
  • templates/ingest-transfers/queries/transfer_count.sql
  • tests/cli/a10.e2e.test.ts
  • tests/cli/a15.e2e.test.ts
  • tests/cli/a2.e2e.test.ts
  • tests/cli/build.test.ts
  • tests/cli/capabilities.test.ts
  • tests/cli/describe.test.ts
  • tests/cli/doctor.test.ts
  • tests/cli/exampleCoverage.test.ts
  • tests/cli/help.test.ts
  • tests/cli/ingestCommands.test.ts
  • tests/cli/init.test.ts
  • tests/cli/initIngest.test.ts
  • tests/cli/query.test.ts
  • tests/cli/runs.cancel.test.ts
  • tests/cli/runs.test.ts
  • tests/cli/schemaKinds.test.ts
  • tests/cli/schemaShow.test.ts
  • tests/cli/serve.test.ts
  • tests/cli/testCmd.test.ts
  • tests/cli/validate.test.ts
  • tests/fixtures/projects/follow-plus-depth/chainplot.yaml
  • tests/fixtures/projects/follow-plus-depth/queries/raw_amounts.sql
  • tests/fixtures/projects/unknown-field/chainplot.yaml
  • tests/fixtures/projects/valid-dataset-only/chainplot.yaml
  • tests/fixtures/projects/valid-dataset-only/queries/raw_amounts.sql
  • tests/fork/fetchGuard.test.ts
  • tests/fork/hostileRelease.test.ts
  • tests/fork/importRelease.test.ts
  • tests/helpers/fakeRindexer.mjs
  • tests/helpers/run.ts
  • tests/ingest/coverage.test.ts
  • tests/ingest/exporter.test.ts
  • tests/ingest/inspectCoverage.test.ts
  • tests/ingest/live/e2e.live.test.ts
  • tests/ingest/locks.test.ts
  • tests/ingest/naming.test.ts
  • tests/ingest/planApply.test.ts
  • tests/ingest/renderConfig.test.ts
  • tests/ingest/runBounded.test.ts
  • tests/plan/buildIntent.test.ts
  • tests/project/modelGraph.test.ts
  • tests/publish/directory.test.ts
  • tests/publish/live/s3.live.test.ts
  • tests/publish/modes.test.ts
  • tests/publish/publishCommand.test.ts
  • tests/publish/s3unit.test.ts
  • tests/query/models.test.ts
  • tests/query/sqlGuard.test.ts
  • tests/rpc/client.test.ts
  • tests/viewer/format.test.ts
  • tsconfig.json
  • viewer/index.html
  • viewer/package.json
  • viewer/pnpm-workspace.yaml
  • viewer/src/App.tsx
  • viewer/src/data.ts
  • viewer/src/echarts.ts
  • viewer/src/env.d.ts
  • viewer/src/format.ts
  • viewer/src/main.tsx
  • viewer/src/styles.css
  • viewer/tsconfig.json
  • viewer/vite.config.ts
  • vitest.config.ts

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

chainstacklabs is on CodeRabbit Free, which includes PR summaries. Ask your admin to upgrade for code reviews.

  • Ask an admin to upgrade

Open in CodeRabbit


Comment @coderabbitai help to get the list of available commands.

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>
@smypmsa

smypmsa commented Sep 17, 2026

Copy link
Copy Markdown
Member

@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 latest.json pointer is here.


Suggested before the repo goes public

C1 — apply can't find data that rindexer indexed successfully

src/ingest/rindexer/inspectCoverage.ts derives the Postgres table name by lowercasing the event name. rindexer, which creates the table, snake_cases it — splitting on capitals and on letter→digit boundaries. Chainplot looks for relayerc20deposit; the real table is relay_erc_20_deposit.

The rows land in the database; apply then stops with a raw Postgres error.

To reproduce: define any event whose name is more than one word (UserOperationEvent will do), then plan and apply.

error: relation "…_erc20dep_relayerc20deposit" does not exist

Would call it settled when: a project using a multi-word event name indexes and exports, and there is a test covering it. Every current test uses the event Transfer, where lowercase and snake_case are identical — that is why this is invisible in-repo, and why a fix without a new test will silently regress.

C3 — publish reports success without verifying the URL it returns

It uploads, flips the pointer, and returns a dashboard_url it has never fetched. Ours 404'd through three consecutive publishes while reporting ok: true.

To reproduce: set public_base_url to any URL not actually serving the bucket, publish, then curl the URL it hands back.
Done when: publish issues a GET of index.html after promoting and fails the command on a non-200. That one request would have caught a mistyped endpoint, a public_base_url pointing at the wrong bucket, and a bucket with public access off — we hit all three.

C5 — the documented preview step can't be opened from a browser

src/publish/serve.ts:50 hard-codes listen(port, "127.0.0.1"). Run inside the producer container, as the docs instruct, that is the container's own loopback, and the template compose publishes no ports. The server starts, prints a URL, and nothing on the host can reach it.

Done when: the release is reachable from a host browser by following the template README verbatim. Note that publishing a port is not sufficient on its own — the bind address has to change too.

C9 — a scaffolded project holds a credential with no .gitignore beside it

The generated project holds a real RPC_URL in .env and nothing prevents committing it. Separately, --output ~/my-project still writes id: ingest-transfers, copied from the template rather than derived.

To reproduce: chainplot init --output ~/my-project --json, then ls -a and head -2 chainplot.yaml.
Done when: init writes a .gitignore covering .env, .chainplot/ and dist/, and derives id from the output directory.

C11 — a forked project still points at the original author's bucket

publish_targets comes across verbatim. No credentials, so nothing leaks, but the bucket name and prefix are the author's, and a publish from a fork aims at a stranger's storage.

To reproduce: fork any published release and read the bottom of chainplot.yaml.
Done when: fork drops publish_targets, or comments it out with a note to replace it.

D2 — the quickstart runs a command that isn't on PATH yet

README.md:15 runs chainplot … straight after pnpm build. The package is "private": true and no bin is linked, so there is no such command. The first thing a new user copies fails with "command not found".

Done when: the README works verbatim on a fresh clone — either by linking the binary or by showing the full node dist/cli/main.js form plus the alias.

D4 — four docs files point at that same preview step

templates/ingest-transfers/README.md:30, examples/fomo-rh/README.md:30, examples/protocol-flows/README.md:12, examples/transfer-traffic/README.md:23. The last already says "from the host, or serve inside the container" but then shows only the container form.

Done when: the documented preview step works, or the docs show serving from the host. Tied to C5 — fix them together.


Can wait — probably better as issues than as changes here

  • C2 — no binary on PATH after a build ("private": true, no bin link). Acceptable while the project is early; D2 should stop implying otherwise.
  • C4 — the run journal replays a stored success after the remote content is deleted. We emptied the bucket, re-ran publish, and got ok: true / "40 files uploaded" with nothing uploaded. Shares a root cause with C3; worth pairing. Recovery is deleting .chainplot/runs/<key>/; note publish accepts no --idempotency-key, unlike apply.
  • C6src/ingest/exporter.ts:78 uses events[0], so a source listing two events indexes and proves coverage for both, then exports only the first. No warning.
  • C7--jsonl is read from argv in run.ts but never registered as an option, so it is rejected as unknown. Progress streaming is unreachable.
  • C8dist/ and .chainplot/ are written by the container as root, mode 700. The host cannot read results or delete the project without Docker or sudo.
  • C10tests/ingest/live/e2e.live.test.ts un-skips on RPC_URL merely being set, but the project it scaffolds is pinned to Ethereum mainnet. Any other chain in a repo-root .env turns the suite red. Confirmed pre-existing.
  • D1README.md:11 says "every command speaks JSON", but --json is mandatory on --help too, so asking for help returns a validation error.
  • D3README.md:35 "anyone can fork it… no credentials" is true of the format (verified) but the forker must first clone and build the repo. Cheap interim step: give every rendered dashboard a footer link to a "how to fork this" section. Also worth stating that forkability needs a dataset_referenced or dataset_included release — the default cannot be recomputed.
  • D5 — nothing warns that CHAINPLOT_S3_ENDPOINT must carry no path. R2's S3 API is path-style, so a trailing /your-bucket makes the client send the bucket twice; R2 reads the first segment as the bucket and folds the second into every key. Upload succeeds, publish reports success, files land one level too deep and the public URL serves nothing. A comment in .env.example is enough; rejecting a path at startup would remove the failure mode.
  • D6policy.block_budget is documented once, in docs/capabilities.md:63, as the bare number 100_000. What that buys varies 120-fold by chain: Ethereum 13.9 days, Base 2.3 days, Arbitrum One 6.9 hours, Robinhood Chain 2.8 hours. Someone who only knows Ethereum carries the default onto a fast chain and silently gets an afternoon.

easeev and others added 14 commits September 18, 2026 10:46
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>
@easeev

easeev commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Pushed fixes for every item, one commit per finding (or pair):

Before public

Commit What settled it
C1 e60b428 naming.ts ports rindexer's camel_to_snake verbatim; coverage and export read through it. validate refuses names past 63 chars (rindexer hashes those). The live compose e2e gained a multi-word source (stETH TransferShares) and asserts index → coverage → export; unit tests pin RelayERC20Deposit → relay_erc_20_deposit and the digit-boundary cases.
C3 4d01094 After promotion, publish GETs index.html and fails on anything but 200 — retryable, with the three causes you hit named in the message.
C5 / D4 3f9a15c serve --host; compose publishes 127.0.0.1:4173:4173; all four READMEs show the working form; the reported URL is never 0.0.0.0.
C9 7fb22ac init writes .gitignore (.env, .chainplot/, dist/) and derives id from the directory name.
C11 1f3d559 fork drops publish_targets, with a warning.
D2 e53df34 Alias and pnpm link --global documented. Correction: README.md:15 was already the node dist/cli/main.js form — the bare chainplot … was in examples/fork/README.md.

Can wait — done anyway

Commit What settled it
C4 4f648cf Before replaying a journaled publish, apply checks release.json is still under the prefix and latest.json exists; otherwise it re-runs and journals why. No --idempotency-key needed.
D5 6fbc12a CHAINPLOT_S3_ENDPOINT must be a bare origin; a path is a validation error naming the origin to set.
C7 / D1 38e9bc2 --jsonl registered on apply and refresh. --help works without --json (plain text) and with it (text in the envelope). The gate on real commands is unchanged.
C8 6a9d3af The chainplot shim drops to the uid:gid owning /workspace; HOME moved so DuckDB's extension loads as any user. CI builds a project inside the container and asserts the host user owns and can remove it. Also found the image had never included templates/init inside the container was ENOENT.
C10 4cca083 The live e2e asks the RPC for eth_chainId and skips unless mainnet.
D3 2d58b2d Footer says "results only — cannot be recomputed" or links "fork this release" to a new README section; the README states forkability needs dataset_included / dataset_referenced.
D6 032dff2 block_budget explained as a block count with what 100k spans on Ethereum / Base / Arbitrum One, in the template and the limits table.
C6 d5d93a4 Not a data-loss bug: apply.ts:383 already fans a multi-event source into one export job per event — protocol-flows exports both weth_deposit and weth_withdrawal from one source. exportEventTable now refuses a multi-event job rather than reading events[0] silently.
C2 Covered by D2's docs; the bin stays unlinked while the package is private.

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.

smypmsa and others added 3 commits September 18, 2026 22:19
`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
@smypmsa

smypmsa commented Sep 18, 2026

Copy link
Copy Markdown
Member

@easeev Re-ran everything.

Three corrections from me: C6 I withdraw — apply.ts already fans a multi-event source into one job per event, nothing was dropped. D2's citation was wrong, the bare chainplot … was in examples/fork/README.md, not README.md:15. Snapshot names — yes, make them match.

The retest turned up three things. They were small, so I've pushed fixes rather than filed them — one commit each.

23a5c16 — export row counts were always 0, and the duplicate-key gate with them. exportWorkerMain.ts read getRowsJson() (positional arrays) as objects, so total and distinct_keys were both undefined and coerced to 0 — meaning total !== distinctKeys could never fire. Now read by column, and a missing count is an error rather than a default. Pre-existing: C1 was stopping apply before the export path ever ran. Verified against the live database — the export that reported 0 reports 20618.

3e37d12fork --from <publish root> 404'd remotely. The section you added documents the publish root, but the remote branch only fetched ${base}/release.json; only the local branch read latest.json. Deeper than it first looked: release_prefix in latest.json is bucket-relative, so appending it to a publish root repeats that prefix, and the remote file and dataset fetches ignored the prefix entirely — nothing had noticed because no remote fork had ever followed a pointer. Your README line now works against the live bucket.

d6b26e6 — a malformed S3 endpoint failed the suite instead of skipping it. s3.live.test.ts calls the strict reader at module scope, so a bad CHAINPLOT_S3_ENDPOINT killed collection with a bare "Unknown Error". Added a gate that treats "not configured" and "configured wrong" alike; the strict read publish depends on is unchanged. Same shape as gating the live e2e on RPC_URL merely being set — and CI can't catch it, having no S3 env to get wrong.

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>
@easeev
easeev merged commit 126a207 into main Sep 20, 2026
3 of 4 checks passed
@easeev
easeev deleted the chainplot-v0.2 branch September 20, 2026 02:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants