Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ chain_sources:
- id: mainnet
chain_id: 1
rpc_secret: RPC_URL # the env var name, never the value
explorer_url: https://etherscan.io # optional; links addresses, txs, blocks
finality: { policy: finalized }

event_sources:
Expand Down Expand Up @@ -150,15 +151,39 @@ from a closed set — `validation`, `policy_refused`, `missing_credentials`,

## Presentation

Panels are declarative. `title`, `description`, `span`, `hide_columns` and
`unit` shape the page; `decimals`, `symbol` and `label` on a raw amount column
shape the numbers. Charts are `line`, `bar`, `area`, `kpi`, `table` — an
Panels are declarative. `title`, `description`, `span`, `hide_columns`,
`unit` and `columns` shape the page; `decimals`, `symbol` and `label` on a raw
amount column shape the numbers. Charts are `line`, `bar`, `area`, `kpi`, `table` — an
allowlist, not an embedded plotting language.

Display metadata never alters stored values. `decimals: 6` renders
`983644533552` as `983,644.533552 USDC`; the exact integer stays in the result
JSON and in the hover title.

`columns` on a panel names, explains and types what a query returns:

```yaml
panels:
- query: largest_sells
chart: table
columns:
wallet: { label: Seller, description: "The wallet that sold." }
tx_hash: { label: Transaction, full: true }
order_id: { kind: text } # a bytes32 id, not a transaction
```

`label` replaces the header text and `description` appears when hovering it; a
header with a description is underlined. `kind` is `address`, `tx`, `block` or
`text`. Left out, a 20-byte hex value is read as an address, a column named
`tx_hash` as a transaction and one named `block_number` as a block. A 32-byte
value is never guessed to be a transaction, since it may as well be any id.
With an `explorer_url` on the chain source, those cells link to the explorer.
Every hex cell has a copy button that copies the full value; `full: true` also
shows it unshortened.

Every panel links to the SQL behind it, tables and charts download their rows
as CSV with exact values, and a table longer than ten rows gets a filter box.

## What a release weighs

A release is a static page plus the answers, and that part is about 800 KB
Expand Down
6 changes: 5 additions & 1 deletion docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@ Machine-readable source of truth: `chainplot capabilities --json`.
- Publish root: beside `latest.json`, `publish` writes an `index.html` that forwards to the release the pointer names. A release directory is content-addressed, so its URL moves whenever the release does — a change to the viewer bundle is enough, since the bundle is part of the digest. The root is the URL that stays put, and `publish` returns it as `entry_url`. The page is written both at `<prefix>/index.html` and at `<prefix>/`: a host that resolves directories finds the former, an object store that serves keys needs the latter, and writing both keeps the bare URL working on either without a rewrite rule at the CDN. That second key is best effort: a prefix-less target has no directory key, a `directory` target cannot name a file that way, a store may refuse a key ending in a separator, and a foreign object already there is left alone. `entry_url` then names `index.html` explicitly, so the URL returned resolves either way. An `index.html` already at that key without chainplot's marker is left alone and `entry_point_written` comes back false, so publishing into a bucket that serves a site of its own does not replace that site's front page. On `s3` that refusal is enforced by a conditional write and holds against a concurrent writer; on `directory` it is a check followed by a rename, so a foreign file written into the same key mid-publish is overwritten. The page reads `latest.json` in the browser rather than naming a release, so its bytes depend only on the prefix: every publish writes the same page, and the entry point cannot fall behind the pointer whatever order concurrent publishers finish in. It needs script, as does the viewer it forwards to. Both the page and `latest.json` are written `no-cache, must-revalidate`, since a cache that serves either without asking would show an older release.
- Charts: `line`, `bar`, `area`, `kpi`, `table` (allowlisted encodings only)
- Dataset modes: `results_only` (default), `dataset_referenced`, `dataset_included`. The default publishes the page and its results only. `dataset_referenced` uploads the parquet beside the release and records a release-relative path and checksum, so `fork` fetches and verifies it on demand. `dataset_included` copies it into the release. Set per build (`--mode`) or per project (`policy.release_mode`).
- Panel presentation: `title`, `description`, `span` (`half`/`full`), `hide_columns`, `unit`
- Panel presentation: `title`, `description`, `span` (`half`/`full`), `hide_columns`, `unit`, `columns`
- Column display: `raw_amount_columns[].decimals` / `.symbol` / `.label` (display only; stored values are never rewritten)
- Panel columns: `columns.<name>.label` / `.description` (header tooltip) / `.kind` (`address`, `tx`, `block`, `text`) / `.full` (hex unshortened). Addresses are detected from their values, `tx_hash` and `block_number` by name; 32-byte values are never guessed to be transactions.
- Explorer links: `chain_sources[].explorer_url` links address, transaction and block cells to `<url>/address/…`, `/tx/…`, `/block/…`. The first chain source that declares one is used.
- Viewer affordances: copy button on every hex cell, a link to each panel's SQL in the source bundle, CSV download of a table's or chart's rows (exact values, query column names), a filter on tables over ten rows
- Layout: a bar chart with up to 30 bars labels every bar, tilted when crowded; past that its axis thins labels like a line's. A KPI beside a table or chart keeps its own height; two KPIs side by side are levelled

## SQL admission control

Expand Down
6 changes: 3 additions & 3 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,14 @@ Full static build on `main`: SELECT model graph (topo order, cycles refused at
`validate`, SELECT-only enforced in the isolated worker), extended `build`
(full §16.1 layout minus `latest.json`: `index.html`, `assets/`,
`dashboards/`, `results/`, `datasets/<id>/{manifest.json,tables/*.parquet}`,
`source/` allowlist), viewer bundle (React + Vite + ECharts, committed under
`viewer/dist/`, no CDN), `serve` (127.0.0.1 by default, `--host` to change), `plan --intent build`
`source/` allowlist), viewer bundle (React + Vite + ECharts, built into
`viewer/dist/` by `pnpm build` and not committed, no CDN), `serve` (127.0.0.1 by default, `--host` to change), `plan --intent build`
(no RPC). A2 + A9 groundwork pass offline.

| Piece | Pin |
| --- | --- |
| viewer deps | react 19, echarts 6, vite 7 (`viewer/package.json`, own lockfile) |
| viewer bundle | committed `viewer/dist/`; rebuild with `pnpm --dir viewer build` |
| viewer bundle | built into `viewer/dist/` by `pnpm build`, not committed; `build` refuses to run without it |
| release id | `local-<ts>` staged then renamed to `dist/releases/local` (M4 adds real ids + `latest.json`) |

Notes:
Expand Down
11 changes: 11 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ the statement count, so `SELECT 1; DROP TABLE t` is refused as two statements
rather than passing a check aimed at the first. The statement is parsed, not
run, by this call.

### Links the viewer renders

A dashboard turns project-supplied strings into links: `explorer_url` on a
chain source, and each panel's SQL path. Validation holds `explorer_url` to
`http(s)://`, and the viewer checks again on what it is actually served, since
a release's JSON can be edited after the build: an explorer base that is not
http(s) produces no link, and a SQL path renders only when it stays inside the
release's `source/` directory. Cell values reach a link only after matching
the shape their kind requires (20-byte address, 32-byte hash, decimal block),
so a value cannot carry markup or a scheme into an `href`.

## What is *not* defended

- **Authenticity of a published release.** `release.json` lists a SHA-256 for
Expand Down
25 changes: 24 additions & 1 deletion schemas/project.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,24 @@
"type": "array",
"items": { "type": "string" }
},
"unit": { "type": "string", "maxLength": 16 }
"unit": { "type": "string", "maxLength": 16 },
"columns": {
"description": "Per-column presentation, keyed by the column name the query returns.",
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": { "description": "Header text, in place of the column name.", "type": "string", "maxLength": 60 },
"description": { "description": "Shown when hovering the header.", "type": "string", "maxLength": 240 },
"kind": {
"description": "What the values are. address, tx and block link to the chain's explorer_url. Left out, a 20-byte hex value is read as an address, a column named tx_hash as a transaction and one named block_number as a block; text turns that off.",
"enum": ["address", "tx", "block", "text"]
},
"full": { "description": "Show hex values in full instead of shortened. Long hashes widen the table.", "type": "boolean" }
}
}
}
}
}
}
Expand All @@ -132,6 +149,12 @@
"id": { "type": "string" },
"chain_id": { "type": "integer" },
"rpc_secret": { "type": "string" },
"explorer_url": {
"description": "Block explorer base URL, without a trailing slash. Table cells holding addresses, transactions and blocks link to <url>/address/<a>, <url>/tx/<hash> and <url>/block/<n>, the Etherscan and Blockscout layout.",
"type": "string",
"pattern": "^https?://[^\\s]+[^/\\s]$",
"maxLength": 200
},
"finality": {
"oneOf": [
{ "type": "object", "additionalProperties": false, "required": ["policy"],
Expand Down
11 changes: 11 additions & 0 deletions src/project/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ export interface Query {

export type ChartKind = "line" | "bar" | "area" | "kpi" | "table";

export type ColumnKind = "address" | "tx" | "block" | "text";

export interface PanelColumn {
label?: string;
description?: string;
kind?: ColumnKind;
full?: boolean;
}

export interface DashboardPanel {
query: string;
chart: ChartKind;
Expand All @@ -37,6 +46,7 @@ export interface DashboardPanel {
span?: "half" | "full";
hide_columns?: string[];
unit?: string;
columns?: Record<string, PanelColumn>;
}

export interface Dashboard {
Expand All @@ -61,6 +71,7 @@ export interface ChainSource {
id: string;
chain_id: number;
rpc_secret: string;
explorer_url?: string;
finality: Finality;
}

Expand Down
37 changes: 27 additions & 10 deletions src/project/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,33 @@ function error(
};
}

function strayProperty(err: ErrorObject): string | null {
if (err.keyword !== "additionalProperties") return null;
const prop = (err.params as { additionalProperty?: string }).additionalProperty;
return typeof prop === "string" ? prop : null;
}

function pointerFromAjv(err: ErrorObject): string | null {
if (err.keyword === "additionalProperties") {
const prop = (err.params as { additionalProperty?: string }).additionalProperty;
if (typeof prop === "string") {
return `${err.instancePath}/${prop}`;
}
}
const prop = strayProperty(err);
if (prop !== null) return `${err.instancePath}/${prop}`;
return err.instancePath || null;
}

/**
* Ajv's text, with the stray key named. "must NOT have additional properties"
* alone sends a reader hunting; the key usually gives the cause away, as when
* an unquoted comma in a flow-mapping title splits it into a second key.
*/
function messageFromAjv(errors: ErrorObject[]): string {
return errors
.map((err) => {
const text = `project${err.instancePath} ${err.message ?? "is invalid"}`;
const prop = strayProperty(err);
return prop === null ? text : `${text}: ${JSON.stringify(prop)}`;
})
.join(", ");
}

export function validateProject(
doc: unknown,
projectDir: string,
Expand All @@ -66,8 +83,8 @@ export function validateProject(
if (!validateSchema(doc)) {
const first = validateSchema.errors?.[0];
const pointer = first ? pointerFromAjv(first) : null;
const message = first
? ajv.errorsText(validateSchema.errors, { dataVar: "project" })
const message = validateSchema.errors?.length
? messageFromAjv(validateSchema.errors)
: "invalid project document";
return {
ok: false,
Expand Down Expand Up @@ -154,15 +171,15 @@ export function validateProject(
// Snapshot files are authored inputs for dataset-only projects. Ingest
// projects materialize them at apply time, so their absence is not an error.
const isIngest = (project.event_sources ?? []).length > 0;
for (const dataset of project.datasets ?? []) {
for (const [index, dataset] of (project.datasets ?? []).entries()) {
if (isIngest) break;
const filePath = path.resolve(projectDir, dataset.snapshot);
if (!fs.existsSync(filePath)) {
return {
ok: false,
error: error("validation", `missing snapshot file: ${dataset.snapshot}`, {
resource_id: dataset.id,
pointer: "/datasets",
pointer: `/datasets/${index}/snapshot`,
}),
};
}
Expand Down
29 changes: 29 additions & 0 deletions src/publish/writeRelease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,21 @@ export async function buildRelease(
// lets someone fork the release and recompute, and it is also what turns an
// 800 KB page into hundreds of megabytes.
const mode = opts.mode ?? project.policy?.release_mode ?? "results_only";
// A snapshot chainplot did not write itself — a lookup table a script fills
// in after `apply`, say — may simply not exist yet. Say which one, rather
// than surfacing the stat call's ENOENT as an internal error.
for (const [index, dataset] of datasets.entries()) {
if (!fs.existsSync(path.resolve(projectDir, dataset.snapshot))) {
throw {
...error(
"validation",
`dataset ${dataset.id} has no snapshot at ${dataset.snapshot}`,
{ resource_id: dataset.id, pointer: `/datasets/${index}/snapshot` },
),
suggested_next: "produce the snapshot (`apply` writes an event source's own), then build again",
} satisfies CommandError;
}
}
let copiedBytes = 0;
for (const dataset of datasets) {
const parquetPath = path.resolve(projectDir, dataset.snapshot);
Expand Down Expand Up @@ -307,6 +322,18 @@ export async function buildRelease(
// Dashboards data. Panel headings are resolved here — panel title, then
// the query's title, then its id — so the viewer needs no query registry.
const queryTitles = new Map(queries.map((q) => [q.id, q.title ?? q.id]));
// Each panel links to the SQL behind it, which the source bundle ships
// under source/. Only files inside the bundle's allowlist get a link.
const querySql = new Map(
queries
.map((q) => [q.id, path.posix.normalize(q.file.replace(/\\/g, "/"))] as const)
.filter(([, file]) => file.startsWith("queries/"))
.map(([id, file]) => [id, `source/${file}`]),
);
// Lives here rather than in release.json so that it counts toward the
// content digest: it changes what the page renders.
const explorerUrl =
project.chain_sources?.find((c) => c.explorer_url)?.explorer_url ?? null;
const dashboardIds: string[] = [];
for (const dashboard of project.dashboards ?? []) {
const rel = path.join("dashboards", `${dashboard.id}.json`);
Expand All @@ -315,10 +342,12 @@ export async function buildRelease(
dashboard_id: dashboard.id,
title: dashboard.title,
description: dashboard.description ?? null,
explorer_url: explorerUrl,
panels: dashboard.panels.map((panel) => ({
...panel,
title: panel.title ?? queryTitles.get(panel.query) ?? panel.query,
span: panel.span ?? "half",
sql: querySql.get(panel.query) ?? null,
})),
});
files.push(rel);
Expand Down
77 changes: 77 additions & 0 deletions tests/cli/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ describe("build", () => {
fs.readFileSync(path.join(dist, "dashboards/overview.json"), "utf8"),
);
expect(dash.title).toBe("Amounts");
// Each panel links to its SQL in the source bundle; no chain, no explorer.
expect(dash.panels[0].sql).toBe("source/queries/raw_amounts.sql");
expect(dash.explorer_url).toBeNull();

const raw = JSON.parse(
fs.readFileSync(path.join(dist, "results/raw_amounts.json"), "utf8"),
Expand All @@ -63,6 +66,80 @@ describe("build", () => {
expect(fs.existsSync(path.join(dist, "source/.env"))).toBe(false);
expect(fs.existsSync(path.join(dist, "source/.chainplot"))).toBe(false);
});

// An ingest project may declare a snapshot that `apply` does not write — a
// lookup table a script fills in afterwards. Validation lets it through,
// since ingest snapshots appear at apply time, and the build used to fail on
// it with an internal error carrying the stat call's ENOENT.
it("names a dataset whose snapshot is missing", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "chainplot-build-missing-"));
fs.cpSync(template, dir, { recursive: true });
fs.mkdirSync(path.join(dir, "abis"));
fs.writeFileSync(
path.join(dir, "abis/ERC20.json"),
JSON.stringify([
{
type: "event",
name: "Transfer",
anonymous: false,
inputs: [
{ name: "from", type: "address", indexed: true },
{ name: "to", type: "address", indexed: true },
{ name: "value", type: "uint256", indexed: false },
],
},
]),
);
const yaml = path.join(dir, "chainplot.yaml");
fs.writeFileSync(
yaml,
fs
.readFileSync(yaml, "utf8")
.replace(
"datasets:\n",
[
"chain_sources:",
" - { id: mainnet, chain_id: 1, rpc_secret: RPC_URL, finality: { policy: finalized } }",
"event_sources:",
" - id: usdc",
" chain: mainnet",
' addresses: ["0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"]',
" abi: abis/ERC20.json",
" events: [Transfer]",
" start_block: 100",
" end: { mode: pinned, block: 110 }",
"datasets:",
" - id: lookups",
" snapshot: .chainplot/lookups/blocks.parquet",
"",
].join("\n"),
),
);
fs.mkdirSync(path.join(dir, ".chainplot"));
fs.writeFileSync(
path.join(dir, ".chainplot/coverage.json"),
JSON.stringify({
schema_version: 1,
chain_id: 1,
sources: [
{
source_id: "usdc",
segments: [{ start_block: 100, end_block: 110, status: "complete_empty", row_count: 0 }],
},
],
}),
);

const result = await runCliJson(["build", "--json"], dir);
expect(result.ok).toBe(false);
expect(result.error).toMatchObject({
code: "validation",
resource_id: "lookups",
pointer: "/datasets/0/snapshot",
});
expect(result.error?.message).toContain(".chainplot/lookups/blocks.parquet");
expect(result.error?.suggested_next).toBeTruthy();
});
});

// Models are materialized into every query's session. While each session
Expand Down
Loading
Loading