Raw blockchain data to clean dimensional tables in your own warehouse, from one command.
chainstar reads blocks, transactions, token transfers and DEX swaps from Ethereum, Base, Bitcoin and Solana, and turns them into tidy tables you can open in a spreadsheet or query with SQL, with USD values from prices it derives from on-chain swaps. It runs on your own computer: nothing is sent anywhere except requests to the blockchain node, and it needs no account or sign-up.
You need a terminal (Terminal on macOS, PowerShell on Windows) and one of two things: Docker (easiest, works for every chain) or uv (no Docker; Ethereum and Base then also need a tool called cryo).
With Docker. Install Docker Desktop and start it. Make an empty folder, open a terminal in it, and run:
docker run --rm -it -v "$PWD:/work" ghcr.io/bit-primitive/chainstar quickstartOn Windows PowerShell write "${PWD}:/work" instead of "$PWD:/work". On Linux add
--user "$(id -u):$(id -g)" after --rm so the files belong to you.
With uv. Install uv, then:
uv tool install git+https://github.com/Bit-Primitive/chainstar
chainstar quickstart(Once chainstar is on PyPI this becomes uv tool install chainstar.) Bitcoin and Solana
work straight away. Ethereum and Base also need cryo; if it is missing, quickstart says
how to get it, or use the Docker command above, which includes it.
What quickstart does. It asks which chain and how many of the latest blocks
(default: 20, or the single latest Bitcoin block), fetches them from a free public
endpoint, builds the tables in a file called warehouse.duckdb in your folder, and
prints what to do next. It takes about a minute on Solana and Bitcoin and a few minutes
on Ethereum and Base (most of it reading token names). To run it without questions, add
--chain base --yes.
Open it in a spreadsheet. Save the transfers as an Excel file and double-click it:
chainstar export transfers --output transfers.xlsx(with Docker: docker run --rm -v "$PWD:/work" ghcr.io/bit-primitive/chainstar export transfers --output transfers.xlsx)
Each row is one transfer: when, which token (token_symbol), from whom, to whom, and how
much (amount), and what that was worth in US dollars (amount_usd, when the token has
an on-chain price that hour; see Swaps and USD prices).
amount_raw is the exact amount in the token's smallest unit. Prefer
.xlsx over .csv for spreadsheets: a spreadsheet opening a CSV rounds long numbers
like amount_raw.
Your own wallet's history. All token transfers to or from one address, as a spreadsheet:
chainstar wallet 0xYourAddress --chain base --recent 1000That writes base-0xyouraddress.xlsx. On Ethereum and Base the node filters by address,
so this is quick; see Wallet history for what it costs on each chain.
Good to know. The free public endpoints are shared by everyone and rate limited, so
chainstar asks them slowly (see Public endpoints). They are fine for
trying things out and for a few thousand blocks; for more, get an RPC URL from a provider
and pass it with --rpc-url or the CHAINSTAR_RPC_URL environment variable. If
something goes wrong, chainstar says what happened and what to try, in one or two lines.
The rest of this page is the full reference.
chainstar pulls blocks, transactions and token transfers from an RPC endpoint (yours, or a free public one), saves them as Parquet, loads them into your warehouse (DuckDB, Postgres, MotherDuck, ClickHouse, BigQuery or Snowflake) or a Parquet data lake, and builds a small star schema with dbt: facts for transfers, fees, swaps and USD prices, dimensions for wallets, tokens, contracts, pools and dates. All chains share the same tables, so one warehouse can hold Ethereum, Base, Bitcoin and Solana side by side.
extract → decode → load → model
cryo cryo DuckDB dbt (Ethereum, Base)
JSON-RPC - DuckDB dbt (Bitcoin, Solana)
- extract: on Ethereum and Base, cryo fetches
blocks, transactions, ERC-20
Transferevents and DEXSwapevents for your block range into Parquet files, decoding eachTransferlog into token, sender, recipient and value; chainstar decodes the swaps. cryo does not support Bitcoin or Solana, so for those chainstar reads blocks over plain JSON-RPC itself and writes Parquet files in the same layout (see Chains), deriving Solana swaps from the transactions. - decode (Ethereum and Base): for every pool seen, chainstar reads its tokens and
factory and asks the factory to confirm it; for every token contract seen, cryo calls
name(),symbol()anddecimals(). - load: the Parquet files are copied into raw tables (schema
chainstar_raw). - model: a bundled dbt project builds the star schema (schema
chainstar) and tests it.
This is an early release (0.1, unreleased). It supports Ethereum, Base, Bitcoin and Solana, and as targets DuckDB, Postgres, MotherDuck, ClickHouse, BigQuery, Snowflake and a Parquet data lake on local disk or S3-compatible storage; see Targets for how each was tested.
There are three ways; pick one.
Docker image (no Python or Rust needed, cryo included). Published to the GitHub
Container Registry by this repository's Docker image workflow, for amd64 and arm64
(Apple silicon and other 64-bit ARM machines):
docker run --rm -v "$PWD:/work" ghcr.io/bit-primitive/chainstar --helpghcr.io/bit-primitive/chainstar:latest follows main; version tags such as :0.1.0
follow releases. To build the image yourself: docker build -t chainstar . in a
checkout (the cryo compile takes about 5 minutes).
How the container sees your files: the folder you mount at /work is chainstar's
working folder, so warehouse.duckdb, data/ and exports appear in it, and paths you
pass (--output mine.xlsx, --target duckdb:my.duckdb) are relative to it. Pass your
own RPC URL with -e CHAINSTAR_RPC_URL (it is read from your environment, not written
into the command). Add -it for quickstart's questions; without a terminal it uses the
defaults, as with --yes. The image runs as an unprivileged user (uid 1000); on Linux,
--user "$(id -u):$(id -g)" makes the files yours. DuckDB's postgres, excel, httpfs,
aws and motherduck extensions and the ClickHouse driver are included, so DuckDB,
Postgres, MotherDuck, ClickHouse and the Parquet lake all work without downloads, in an
image of about 1 GB unpacked. The BigQuery and Snowflake drivers would nearly double that,
so they are left out of the published image; to write to those, build your own:
docker build --build-arg EXTRAS="all" -t chainstar . (or EXTRAS="bigquery",
EXTRAS="snowflake", or EXTRAS="" for no drivers).
uv or pipx (Python 3.11 or newer):
uv tool install git+https://github.com/Bit-Primitive/chainstar
# or: pipx install git+https://github.com/Bit-Primitive/chainstarchainstar is not on PyPI yet; once it is, uv tool install chainstar or
pipx install chainstar.
DuckDB, Postgres, MotherDuck and Parquet lakes need nothing more. ClickHouse, BigQuery
and Snowflake need their drivers, as an extra: clickhouse, bigquery, snowflake, or
all:
uv tool install "chainstar[clickhouse] @ git+https://github.com/Bit-Primitive/chainstar"The plug-ins for dlt and Airbyte have their own extras, dlt and airbyte, which all
does not include; see Using chainstar from dlt and Airbyte.
cryo, for Ethereum and Base outside Docker. It is written in Rust; get Rust from https://rustup.rs, then:
cargo install cryo_cliThis installs cryo 0.3.2 from crates.io, the version chainstar is tested with. Bitcoin and Solana need nothing else.
chainstar quickstart --chain ethereum --yes # the latest 20 blocks
chainstar run --chain ethereum --blocks 20000000:20000100run writes Parquet files to ./data/ethereum/ and the tables to ./warehouse.duckdb.
Without --rpc-url or CHAINSTAR_RPC_URL both use the chain's free public endpoint;
with your own:
export CHAINSTAR_RPC_URL="https://your-ethereum-rpc.example"
chainstar doctor --chain ethereum
chainstar run --chain ethereum --blocks 20000000:20000100Bitcoin and Solana work the same way:
chainstar run --chain bitcoin --blocks 972460:972463
chainstar run --chain solana --blocks 459564290:459564310 # slots, see belowQuery the tables with the DuckDB command-line tool:
duckdb warehouse.duckdb "
select d.token_address, d.symbol, count(*) as transfers, sum(f.amount) as volume
from chainstar.fct_transfers f
join chainstar.dim_tokens d using (token_id)
group by 1, 2 order by 3 desc limit 10"Every command has --help with examples. Add --debug before the command
(chainstar --debug run ...) to see the full traceback when something fails.
| Command | What it does |
|---|---|
chainstar quickstart |
Guided first run: asks for a chain and how many of the latest blocks, reads the range from the chain head, runs everything, and prints next steps. --yes for scripts. |
chainstar run |
Runs extract, decode, load and model for a block range, then prints row counts. |
chainstar sync |
Brings the warehouse up to date from where the last sync stopped, for one or more chains, up to the newest block with enough confirmations; detects and rolls back reorganisations; builds the star schema incrementally. Once, or --every 5m until stopped. See Keeping a warehouse in sync. |
chainstar status |
Per synced chain: synced to, head, lag in blocks and time, last run, rows per table. --json; --max-lag 15m exits 1 when a chain is further behind. |
chainstar export |
Saves a table, or the transfers view with token symbols, as CSV, Excel or Parquet, with optional filters. |
chainstar wallet |
All token transfers sent or received by one address in a range, as a spreadsheet. --sync keeps it current. |
chainstar doctor |
Checks that cryo is installed (Ethereum and Base only), the RPC serves the expected chain (EVM chain id, Bitcoin network main, or Solana mainnet's genesis hash), the target is reachable, and dbt is installed. |
chainstar init |
Writes a commented chainstar.yml you can edit. |
chainstar run options:
| Option | Default | Meaning |
|---|---|---|
--chain |
from chainstar.yml |
ethereum, base, bitcoin or solana |
--blocks START:END |
from chainstar.yml |
Block range. END is exclusive, as in cryo: 100:200 is 100 blocks. On Solana the numbers are slots. |
--rpc-url |
$CHAINSTAR_RPC_URL, then the chain's public endpoint |
JSON-RPC endpoint for the chain |
--target |
$CHAINSTAR_TARGET, then chainstar.yml, then duckdb:warehouse.duckdb |
duckdb:PATH or postgres:postgresql://USER@HOST:PORT/DBNAME |
--data-dir |
./data |
Where Parquet files are kept |
--requests-per-second |
no limit; the public endpoint's rate when using one | Cap on requests sent to the RPC |
--chunk-size |
100; 10 on public endpoints |
Blocks (Solana: slots) per Parquet file. Lower it if your RPC often fails requests (see below). |
--config |
./chainstar.yml |
Config file, if you use one |
Command-line flags override environment variables, which override chainstar.yml.
chainstar quickstart options: --chain (default base), --recent N (default 20, or
1 on Bitcoin), --rpc-url, --target (default duckdb:warehouse.duckdb), --data-dir
(default ./data) and --yes. On Ethereum and Base the range ends 5 blocks behind the
head, since quickstart and run do not handle reorganisations (sync does); on
Bitcoin it is the latest block, and on Solana the latest finalized slots.
Runs are safe to repeat. Parquet files already written are skipped, by cryo on Ethereum and Base and by chainstar on Bitcoin and Solana, and each load replaces a chain's raw rows with everything in its data directory, not only the range you just asked for. So you can:
- re-run the same command after a failure; only the missing chunks are fetched again;
- run further ranges into the same warehouse; they add up;
- load all four chains into the same warehouse; every table has a
chaincolumn.
The extract lines count the range you asked for; the load line counts everything loaded, and the line under it says which blocks the warehouse now holds. When that is more than this run's range, chainstar says so:
extract erc20_transfers 4,640 rows 0.4s
...
load chainstar_raw 4 tables, 18,844 rows 0.1s
ethereum now in the warehouse: blocks 26,240,971 to 26,240,990 (20 blocks)
(each run loads every chunk saved in data/ethereum, so earlier runs' blocks are included)
To keep a range on its own, give it its own --data-dir and --target. run rebuilds
the star-schema tables in full each time; chainstar sync builds them incrementally.
When no RPC URL is set, chainstar uses these free shared endpoints and prints a one-line notice saying so. They are run by PublicNode, are shared by everyone, and limit request rates, so chainstar asks them gently:
| Chain | Endpoint | Requests per second | Blocks per chunk |
|---|---|---|---|
| Ethereum | https://ethereum-rpc.publicnode.com |
5 | 10 |
| Base | https://base-rpc.publicnode.com |
5 | 10 |
| Bitcoin | https://bitcoin-rpc.publicnode.com |
2 | 10 |
| Solana | https://solana-rpc.publicnode.com |
5 | 10 (slots) |
All four ran chainstar quickstart in October 2026 without a refused request: 20
Ethereum blocks in 4.8 minutes, 20 Base blocks in 3.4 minutes (most of both reading
names and symbols of 300 to 400 tokens, three calls each), 20 Solana slots in 26 seconds,
and one Bitcoin block in 6 seconds. In earlier runs the Ethereum endpoint refused a few
percent of requests at 5 per second with 100-block chunks, and none with 10-block chunks.
--requests-per-second and --chunk-size override these, and your own --rpc-url or
CHAINSTAR_RPC_URL replaces them (with no rate cap unless you set one). Public
endpoints can change their limits or go away at any time; if they refuse requests,
chainstar's hint says to wait and run the same command again, which resumes from the
chunks already saved.
chainstar export [TABLE] saves a table to a file. TABLE is transfers (the default)
or any star-schema table: fct_transfers, fct_fees, fct_swaps, fct_token_prices,
dim_wallets, dim_tokens, dim_contracts, dim_pools, dim_dates.
chainstar export # transfers.csv
chainstar export transfers --output transfers.xlsx
chainstar export transfers --chain base --wallet 0xYourAddress --output mine.xlsx
chainstar export transfers --token 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 --limit 1000
chainstar export fct_fees --format parquet --target postgres:postgresql://me@localhost:5432/chain| Option | Meaning |
|---|---|
--output, -o |
File to write. Default <table>.<format> in the current folder. |
--format |
csv, xlsx or parquet. Default: from --output's extension, else csv. |
--chain |
Only this chain's rows. |
--wallet ADDRESS |
Only rows where the address sends or receives (transfers and fees: from_address or to_address; swaps: trader, sender or recipient; dim_wallets: address). Ethereum and Base addresses match in any letter case. |
--token ADDRESS |
Only this token: ERC-20 contract, SPL mint, or native for BTC and SOL (swaps and pools: either side). |
--limit N |
At most N rows. |
--target, --config |
Which warehouse, as for run. |
transfers is fct_transfers joined to dim_tokens, ordered by chain, block and
position: block_timestamp, chain, block_number, transaction_hash,
transfer_position, token_address, token_symbol, token_name, from_address,
to_address, amount, amount_raw, and for bookkeeping amount_usd, usd_price (the
token's on-chain USD price used), price_method (how it was derived),
price_confidence and price_bucket_start (the UTC hour it is the price of), so each
USD value can be traced to fct_token_prices. A transfer without a usable price has
these empty.
In every format amount_raw stays text, so 256-bit token amounts keep every digit, and
amount is a number. Excel files are written by DuckDB's
excel extension, which DuckDB
downloads the first time (the Docker image includes it), so chainstar needs no extra
Python package. An Excel sheet holds at most 1,048,576 rows including the header; a
larger export is refused before anything is written, with a suggestion to filter it or
use CSV or Parquet, which have no limit. Excel files store numbers as floating point, so
block_number and the like arrive as numbers with up to 15 significant digits, which
covers every block number and index.
On Bitcoin, --wallet matches outputs the address received, and outputs of transactions
the address funded alone (see from_address in fct_transfers); a
transaction funded by several addresses has no single sender.
chainstar wallet ADDRESS builds a warehouse for one address and writes its transfers
to a spreadsheet:
chainstar wallet 0xYourAddress --chain base --recent 1000
chainstar wallet 0xYourAddress --chain ethereum --blocks 20000000:20010000 \
--rpc-url "$MY_RPC" --blocks-per-request 1000
chainstar wallet YourSolanaAddress --chain solana --recent 100 --output mine.csvGive either --blocks START:END or --recent N. The output defaults to
<chain>-<address>.xlsx; the data goes to data/wallet-<chain>-<address>/, with its own
warehouse.duckdb, so it never mixes with your main warehouse.
What it costs, honestly:
- Ethereum and Base: the node does the filtering. cryo 0.3.2 can filter ERC-20
Transferevents by sender (--from-address, the event's topic1) or by recipient (--to-address, topic2);eth_getLogscombines topics with AND, so chainstar makes one filtered pass for each direction. Only matching events are downloaded, then only the blocks that contain one (for their timestamps), then the token names. Transactions, and thereforefct_fees, are skipped. With--blocks-per-request 1(the default) that is two requests per block: the free public endpoints refuse a filteredeth_getLogsover more than one block without a contract address. Many paid providers accept hundreds or thousands of blocks per request, which makes a long history quick; check your provider'seth_getLogsblock-range limit. Checked on 20 Base blocks: the same 8 transfers, byte for byte, as a full run filtered withexport --wallet, in 18 seconds instead of 202 (before USD values were added). For USD values it also looks up each token's pools against WETH and USDC (10 calls per token on Ethereum, 14 on Base, once), fetches only those pools' swaps, one filtered request per chunk, and the blocks they are in; see Swaps and USD prices. Checked on 20 Base blocks on 21 October 2026: 16 transfers of one address, every one valued (WETH at 2,489.92 USD from 7 pricing pools, against 2,489.87 from all of the range's swaps), in 37 seconds. - Bitcoin and Solana: their RPC interfaces cannot filter by address, so every block
in the range is downloaded in full (a Bitcoin block is about 6 MB compressed, a Solana
block 4 to 8 MB of JSON before compression) and the export keeps the address's rows. It costs the same as
chainstar runon that range.
chainstar prints one error: line saying what happened and, for common problems, a
hint: line saying what to try, instead of a traceback. For example:
error: getblockchaininfo: could not reach https://no-such-host.invalid/<redacted> (URLError)
hint: check your internet connection and the RPC URL; a free public endpoint may also be down for a while, so try again later or use your own RPC URL
Covered: cryo missing (with install and Docker instructions), RPC unreachable or rate
limited (HTTP 429), the RPC serving another chain, a range past the chain head, Postgres
refusing the password or not running, a DuckDB file another program has open, folders
that cannot be written, a full disk, and DuckDB extensions that cannot be downloaded.
Anything else is reported as a likely bug, with a pointer to --debug and the issue
tracker. Ctrl-C stops cleanly; finished chunks are kept.
Hosted RPC URLs usually contain an API key, so chainstar:
- never writes the URL to
chainstar.yml(it refuses to read one that contains it); - reads one URL per chain from
CHAINSTAR_RPC_URL_<CHAIN>(for exampleCHAINSTAR_RPC_URL_BASE) when set, beforeCHAINSTAR_RPC_URL; - passes it to cryo through an environment variable, not the command line, and sends it nowhere but your endpoint for Bitcoin and Solana;
- turns off cryo's report files;
- prints only the scheme and host, e.g.
https://eth-mainnet.g.alchemy.com/<redacted>, also in--debugtracebacks.
chainstar sends no telemetry, and turns off dbt's anonymous usage statistics.
chainstar run fetches a range you name. chainstar sync keeps a warehouse current:
each time it runs, it continues from where the last sync stopped, up to the newest block
that is safe to take, and exits; with --every, it keeps going until you stop it.
chainstar wallet 0xYourAddress --chain base --recent 1000 --syncThe first run fetches the latest 1,000 blocks and writes base-0xyouraddress.xlsx, as
chainstar wallet always does. Run the same command again tomorrow or next month and it
fetches only the blocks since then, and rewrites the spreadsheet; --recent matters only
the first time. To have it update by itself while the terminal stays open, add
--every 1h; Ctrl-C stops it. To run it on a schedule, see
Running it as a service. Each wallet keeps its own folder and
warehouse, as before, and costs the same per block as
chainstar wallet.
chainstar sync --chain base --from-recent 1000 # first sync: the latest 1,000 blocks
chainstar sync --chain base # later: continue where it stopped
chainstar sync --chain ethereum --chain base --every 5mA chain's first sync into a warehouse needs a starting point: --from BLOCK, or
--from-recent N for the latest N blocks (Solana: slots). After that, every sync resumes
from the warehouse's own record of its progress, so the same command works every time;
--from and --from-recent are then ignored, with a note. Several chains are synced one
after the other, and the star schema is built once at the end. Without --chain, sync
takes the chains listed under chains: in chainstar.yml, or else every chain already
synced into the target.
Each chain needs an endpoint for that chain, so with several give each its own URL in
CHAINSTAR_RPC_URL_<CHAIN>, for example CHAINSTAR_RPC_URL_BASE and
CHAINSTAR_RPC_URL_SOLANA; chainstar uses it before CHAINSTAR_RPC_URL, and the public
endpoint when neither is set. --rpc-url works for a single chain.
A real first sync of Base on the public endpoint, 21 October 2026:
$ chainstar sync --chain base --from-recent 10
2026-10-21T22:55:38Z all start chainstar 0.1.0.dev0; target duckdb:warehouse.duckdb; data data
2026-10-21T22:55:38Z base rpc https://base-rpc.publicnode.com (free shared public endpoint)
2026-10-21T22:55:38Z base note no RPC URL set, so chainstar uses a free shared public endpoint (5 requests/s, fine for trying small ranges); for more, use your own with --rpc-url or CHAINSTAR_RPC_URL
2026-10-21T22:55:38Z base head chain id 8453, latest block 52,916,995; safe head 52,916,936 (60 confirmations)
2026-10-21T22:55:54Z base extract 52,916,927-52,916,936: erc20_transfers 3,412, dex_swaps 250, blocks 10, transactions 1,856 15.2s
2026-10-21T23:00:03Z base decode 52,916,927-52,916,936: pools 96, pools fetched 96, tokens 203, fetched 203 249.4s
2026-10-21T23:00:03Z base load 52,916,927-52,916,936: 5,827 raw rows; synced to 52,916,936 0.1s
2026-10-21T23:00:09Z all model dbt build (full refresh: first sync or rollback of base): 30 models, 93 tests passed 5.3s
and, from the release before swaps, a few minutes after an earlier first sync, the same
command without --from-recent continuing from the next block (the decode step now also
reads each new pool, and the build has 30 models and 93 tests):
$ chainstar sync --chain base
2026-10-21T17:31:49Z all start chainstar 0.1.0.dev0; target duckdb:warehouse.duckdb; data data
2026-10-21T17:31:49Z base rpc https://base-rpc.publicnode.com (free shared public endpoint)
2026-10-21T17:31:49Z base note no RPC URL set, so chainstar uses a free shared public endpoint (5 requests/s, fine for trying small ranges); for more, use your own with --rpc-url or CHAINSTAR_RPC_URL
2026-10-21T17:31:49Z base head chain id 8453, latest block 52,907,281; safe head 52,907,222 (60 confirmations)
2026-10-21T17:31:50Z base check block 52,907,136 still has hash 0x2e6f0107...c0b1bc; continuing from 52,907,137
2026-10-21T17:37:07Z base extract 52,907,137-52,907,222: erc20_transfers 26,352, blocks 86, transactions 18,783 70.0s
2026-10-21T17:37:07Z base decode 52,907,137-52,907,222: tokens 586, fetched 411 247.2s
2026-10-21T17:37:07Z base load 52,907,137-52,907,222: 45,807 raw rows; synced to 52,907,222 0.5s
2026-10-21T17:37:14Z all model dbt build (incremental): 23 models, 54 tests passed 6.6s
chainstar sync options, besides --target, --data-dir, --requests-per-second,
--chunk-size and --config, which work as for run:
| Option | Default | Meaning |
|---|---|---|
--chain |
chains in chainstar.yml, else the chains already synced |
Chain to sync; repeat for several |
--from BLOCK |
First sync only: start here (one chain at a time) | |
--from-recent N |
First sync only: start N blocks before the safe head | |
--every DURATION |
run once | Keep running, syncing every 30s, 5m, 2h or 1d |
--confirmations |
per chain, see below | Confirmations a block needs, or finalized (Ethereum, Base) |
--full-refresh |
off | Rebuild the star-schema tables in full instead of incrementally |
--json-logs |
off | One JSON object per log line |
--rpc-url |
CHAINSTAR_RPC_URL_<CHAIN>, CHAINSTAR_RPC_URL, the public endpoint |
For a single chain |
What one sync does, per chain:
- Head. Asks the node for its latest block and works out the safe head (below).
- Check. Asks the chain for the hash of the last block it synced and compares it with the one it stored. If they differ, see Reorganisations.
- Extract and load, in batches of ten chunks: 1,000 blocks with the default chunk size, 100 on public endpoints, whose chunks are 10. Each batch is loaded in one transaction together with the progress it makes, so a crash or a power cut never leaves the warehouse claiming blocks it does not hold, or holding raw rows its record does not cover. Only the batch's blocks are replaced in the raw tables; nothing else is reloaded.
- Model. After all chains, dbt builds the star schema incrementally: each table processes only rows it does not have yet (see Incremental tables).
Stopping is safe at any point. The first Ctrl-C or SIGTERM (what docker stop and
systemctl stop send) stops at the next safe point: between batches, or on Ethereum and
Base between a batch's extraction and its token-metadata step, in which case that batch
is not loaded and the next sync loads it, reusing the chunk files already saved. The dbt
build is skipped too; the next sync does it. So stopping waits for at most one
extraction stage, which on a public endpoint can take a minute or two. A second Ctrl-C
or SIGTERM stops at once, which is also safe: chunk files appear only when complete, and
an unfinished transaction is rolled back.
Only if your endpoint answers faster than the chain makes blocks. Measured on the free public endpoints on 21 October 2026:
- Solana makes about 2.5 blocks a second. At the public endpoint's 5 requests per
second, chainstar fetched 99 slots in 87.5 seconds, about 1.1 a second, so a sync
--everyfew minutes falls further behind each time. To follow Solana's head, use your own RPC and raise--requests-per-second. - Base makes one block every 2 seconds. On the public endpoint 86 blocks took 70 seconds to extract, faster than the chain, plus 247 seconds reading names of 411 new tokens; that second part shrinks as tokens repeat, because metadata is fetched once. Swaps add the reading of each new pool, about 6 calls in batches of 10, also once per pool: the first 10 blocks above met 96 pools. The next 100 blocks met 357, and after a day of test runs from the same machine the public endpoint refused those batches (HTTP 403) a few minutes in, on three tries over 20 minutes. Pools are saved in files of 50, so a try keeps what it read, and the blocks wait, unloaded, for the next sync. A Base sync with swaps wants your own RPC.
- Ethereum makes one block every 12 seconds, well within the public endpoint's pace.
- Bitcoin makes one block about every 10 minutes; one block takes seconds.
chainstar status shows the lag; --max-lag turns a growing lag into a failing health
check.
A block's confirmations are the blocks built on top of it, plus one, so the newest block has one. Sync takes a block only once it has enough, so the short reorganisations every chain has never reach the warehouse:
| Chain | Default | Behind the head | Why |
|---|---|---|---|
| Ethereum | 12 | 11 blocks, about 2.2 minutes | Well past the short reorganisations late blocks cause; a margin, not a guarantee. --confirmations finalized waits for the node's finalized block instead, which cannot be reverted without a third of all stake being slashed: 92 blocks (about 18 minutes) behind on 21 October 2026. |
| Base | 60 | 59 blocks, about 2 minutes | Past the node's safe head, the newest block derived from data already posted to Ethereum, which was 38 blocks (76 seconds) behind on 21 October 2026. finalized (581 blocks, about 19 minutes behind that day) also waits for Ethereum to finalize that data. |
| Bitcoin | 6 | 5 blocks, about 50 minutes | The long-standing convention: five blocks of work on top. Bitcoin has no finality, so there is no finalized. |
| Solana | finalized | to the latest finalized slot | The latest finalized slot, which the cluster has rooted and cannot roll back; a number of confirmations does not apply. |
Set them per chain in chainstar.yml, or with --confirmations for every chain in a
command:
confirmations:
ethereum: finalized
base: 120A reorganisation deeper than the confirmation depth is rare but possible, so before each sync chainstar checks that the last synced block still has the hash it had. If not, it:
- compares the blocks it stored, newest first, with the chain until one still matches (blocks link to their parents by hash, so everything before a matching block is unchanged); it checks at most 128 and otherwise stops and asks you to look;
- deletes everything after that block: its chunk files (whole files, so the rollback may
start a few blocks earlier), its raw rows, and its rows in
fct_transfersandfct_fees; - syncs that range again from the chain as it is now, and rebuilds the star schema in full so the dimensions are recounted.
The log says what happened; from the tests' simulated reorganisation:
ethereum reorg warning: block 26,239,739 now has hash 0xabababab...ababab, not 0x5f3cfd2d...77e8c1 as synced: the chain reorganised deeper than 1 confirmation
ethereum rollback warning: rolled back from block 26,239,739: deleted 3 chunk files, 47 raw rows and 0 fact rows; syncing them again, then rebuilding the star schema in full
This is tested with a simulated reorganisation on DuckDB, Postgres, ClickHouse and Parquet lakes (local and S3-compatible): afterwards the tables equal a full rebuild from the new chain, and the block before the fork is untouched. Files are deleted before the database, so if the process stops half-way the hash check fails again next time and the rollback repeats.
The star-schema tables are dbt incremental models. Each keeps, per chain, the highest
block it already holds, and a sync builds only rows above that: new transfers and fees
are appended, and wallets, tokens and contracts seen again are updated (first seen kept,
last seen and counts moved on). Each table is updated by its unique key: with dbt's
delete+insert strategy on DuckDB, Postgres, MotherDuck, ClickHouse and Snowflake, and
with merge on BigQuery, whose adapter has no delete+insert (see Targets).
dim_dates, a few hundred
rows a year, is rebuilt each time from a one-row-per-chain summary
(chainstar_staging.int_chain_coverage) instead of from every block.
The tables are rebuilt in full instead whenever older blocks may have changed: on a
chain's first sync (a run may have loaded older blocks), after a rollback, with
--full-refresh, and on every chainstar run, which therefore behaves as before. An
incremental build after two syncs gives exactly the rows a full rebuild gives, on
DuckDB, Postgres, ClickHouse and Parquet lakes, for all four chains; the tests check it
table by table.
On Postgres, sync also indexes the raw tables by chain and block, and the incremental tables by key and by chain and block, so a sync reads only its own blocks. DuckDB needs no indexes: it skips data by its min/max statistics. On ClickHouse the raw tables are ordered by chain and block, and on BigQuery clustered by them.
Two things a full rebuild would recompute but an incremental build leaves as they were:
an ERC-20 token's name, symbol or decimals fetched again later (only if you delete
data/<chain>/erc20_metadata/) reach older rows' amount only with --full-refresh;
the same holds for a Solana mint whose decimals were unknown in its first transfers.
Sync keeps its progress in the warehouse, in chainstar_state.sync_progress, one row
per chain, written in the same transaction as the data it describes (on ClickHouse and
BigQuery, which have no such transactions, after it, as a new version: see
Targets):
| Column | Meaning |
|---|---|
chain |
|
first_block |
where the first sync started |
last_block |
every block from first_block to here is loaded |
checkpoint_block, checkpoint_hash, checkpoint_time |
the newest loaded block with a known hash (Solana: the last slot that had a block), checked before each sync |
confirmations |
as used by the last sync |
modelled_block |
the star schema includes raw data up to here |
rebuild_pending |
the next build is a full rebuild (after a first sync or a rollback) |
updated_at |
when progress was last recorded |
last_run_at, last_run_status, last_error |
the last sync of the chain, ok or error, and why (RPC URLs redacted) |
chainstar_version |
the version that last recorded progress |
Times are UTC. Block numbers on Solana are slots.
$ chainstar status --max-lag 10m
chainstar status: duckdb:warehouse.duckdb at 2026-10-21 17:37:16 UTC
base
synced to block 52,907,222, 2026-10-21 17:29:51 UTC (first synced block 52,907,127)
head 52,907,444; safe head 52,907,385 (60 confirmations)
lag 222 blocks, 7m 25s
last run 2026-10-21 17:37:07 UTC, ok
star schema built to block 52,907,222
rows fct_transfers 32,861, fct_fees 20,916, dim_wallets 19,673, dim_tokens 586, dim_contracts 586
healthy: every chain is within 10m
--json prints the same as JSON for scripts. --max-lag DURATION makes it a health
check: it exits with status 1 when any chain's newest synced block is older than that.
The lag in time is measured from that block's own time, so it works without the node and
includes the confirmation depth: about 2 minutes on Base, an hour on Bitcoin. Allow for
that and for the sync interval, for example --max-lag 15m for Base synced every 5
minutes, and --chain bitcoin --max-lag 3h. The lag in blocks needs the node; when it
cannot be reached, status says so and still reports the rest.
Ready-to-use files are in examples/:
-
cron (
examples/cron/crontab):chainstar syncevery 5 minutes. Runs never overlap: a sync refuses to start while another uses the same data folder. -
systemd (
examples/systemd/): a service that runs one sync, and a timer that starts it 5 minutes after the previous run finished. Logs go to the journal (journalctl -u chainstar-sync). -
Docker, one container that keeps syncing:
docker run -d --name chainstar-sync --restart unless-stopped --stop-timeout 180 \ -v "$PWD:/work" -e CHAINSTAR_RPC_URL_BASE \ ghcr.io/bit-primitive/chainstar sync --chain base --from-recent 100 --every 5m docker logs -f chainstar-sync docker exec chainstar-sync chainstar status
--stop-timeoutgives the current extraction stage time to finish ondocker stop(the default is 10 seconds, after which Docker kills it, which is safe but wastes the stage's work). -
Docker Compose with Postgres (
examples/docker-compose.yml): Postgres plus a sync container every 5 minutes, withchainstar status --max-lag 15mas its health check.docker compose up -din that folder; put a password and RPC URLs in a.envfile next to it.
Run the first sync by hand, or keep --from-recent in the command as the Docker examples
do: it applies only until the first sync has happened.
One line per step, with the block range, row counts and time taken:
2026-10-21T17:30:53Z base extract 52,907,127-52,907,136: erc20_transfers 6,509, blocks 10, transactions 2,133 9.1s
Steps: head, check, extract, decode, load, model, publish (Parquet lakes),
export (wallets), reorg, rollback, wait, stop. Warnings and errors say so after the step name. With
--json-logs each line is a JSON object with time, level, chain, stage,
message, seconds where timed, and the step's numbers (first_block, last_block,
rows, head, safe_head, ...), for log shippers. RPC URLs are redacted in both, and
warnings and errors are cleared of warehouse passwords, tokens and keys.
- One sync per data folder. A second one refuses to start. Do not point syncs with different data folders, or on different machines, at the same warehouse and chain.
syncandrunon the same chain and warehouse work together when they share a data folder:runreloads every chunk on disk and rebuilds in full. With different data folders,runwould replace the rows sync recorded; keep them apart.- Errors do not stop the others. A chain that fails (node down, rate limited) is
logged and recorded in
last_error; the other chains are synced, the exit status is 1, and with--everythe next round tries again. - Wallets on Ethereum and Base store blocks only where the wallet has transfers, so their checkpoint hash is asked of the node at the end of each batch rather than taken from the data.
- dbt tests run on every build, over whole tables, as with
run; on a large warehouse they take a growing share of each sync. - A DuckDB file has one writer.
chainstar statuswaits up to 15 seconds while a sync step holds it; other tools that open the file can make a sync fail with a hint, as forrun. For a warehouse others query while it syncs, use one of the server warehouses, or a Parquet lake, which readers query from its files. - Public endpoints are fine for
--every 5mon one or two chains. A Bitcoin block is about 6 MB to download from them, so a long Bitcoin catch-up wants your own node.
chainstar reads DEX swaps on Ethereum, Base and Solana into fct_swaps, derives USD
prices from them into fct_token_prices, and values transfers (amount_usd), fees
(fee_usd) and swaps (usd_value) at those prices. Every price comes from on-chain
swaps: no price service is asked, nothing needs an account, and nothing is assumed
except that the USD stablecoins listed below are worth 1 USD. Where a token has no
reliable price, its USD columns are NULL rather than a guess.
Ethereum and Base. cryo's logs dataset, filtered to three Swap events by
topic0, one eth_getLogs request per block as for transfers. chainstar decodes them
itself, exactly, because their amounts are 256-bit integers (signed in Uniswap v3):
| Event layout | Signature | topic0 | Source |
|---|---|---|---|
uniswap_v2 |
Swap(address indexed sender, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out, address indexed to) |
0xd78ad95f...159d822 |
Uniswap/v2-core IUniswapV2Pair.sol |
uniswap_v3 |
Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick) |
0xc42079f9...fbcca67 |
Uniswap/v3-core IUniswapV3PoolEvents.sol; aerodrome-finance/slipstream ICLPoolEvents.sol (identical) |
solidly_v2 |
Swap(address indexed sender, address indexed to, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out) |
0xb3e27736...efffaf75b |
aerodrome-finance/contracts IPool.sol |
Any contract can emit these events, so for every pool chainstar reads token0(),
token1() and factory() (plus fee() and tickSpacing(), or stable()), then asks
that factory for the pool of the same tokens (getPair, getPool(tokens, fee),
getPool(tokens, tickSpacing) or getPool(tokens, stable)). A pool is verified when
the factory answers with the pool's own address and the factory is one of these:
| Chain | Venue | Factory | Published in |
|---|---|---|---|
| Ethereum | uniswap_v2 |
0x5c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f |
Uniswap v2 deployments page |
| Ethereum | uniswap_v3 |
0x1f98431c8ad98523631ae4a59f267346ea31f984 |
Uniswap v3 Ethereum deployments page |
| Ethereum | sushiswap_v2 |
0xc0aee478e3658e2610c5f7a4a2e1777ce9e4f2ac |
sushi-labs/sushi sushiswap-v2.ts |
| Ethereum | sushiswap_v3 |
0xbaceb8ec6b9355dfc0269c18bac9d6e2bdc29c4f |
sushi-labs/sushi sushiswap-v3.ts |
| Base | uniswap_v2 |
0x8909dc15e40173ff4699343b6eb8132c65e18ec6 |
Uniswap v2 deployments page |
| Base | uniswap_v3 |
0x33128a8fc17869897dce68ed026d694621f6fdfd |
Uniswap v3 Base deployments page |
| Base | sushiswap_v2 |
0x71524b4f93c58fcbf659783284e38825f0622859 |
sushi-labs/sushi sushiswap-v2.ts |
| Base | sushiswap_v3 |
0xc35dadb65012ec5796536bd9864ed8773abc74c4 |
sushi-labs/sushi sushiswap-v3.ts |
| Base | aerodrome_v2 |
0x420dd381b31aef6683db6b902084cb0ffece40da |
aerodrome-finance/contracts README |
| Base | aerodrome_slipstream |
0x5e7bb104d84c7cb9b682aac2f3d509f5f406809a, 0xade65c38cd4849adba595a4323a8c7ddfe89716a, 0xf8f2eb4940cfe7d13603dddd87f123820fc061ef |
aerodrome-finance/slipstream README (three deployments) |
Each factory was also checked on chain (it has code; the Uniswap and SushiSwap v3 ones
enable fee tiers 100, 500, 3000 and 10000), and every topic and function selector is
recomputed from its signature by the tests. Swaps in other pools with the same events
(forks such as BaseSwap, whose factory address could not be found in its own
documentation) are kept with venue unknown and pool_verified false; they are never
used for prices and get no usd_value. Pool metadata is read once per pool at the chain
head and cached in data/<chain>/dex_pools/, like token metadata: reading factories'
PairCreated/PoolCreated logs instead would need every block since each factory was
deployed. A swap whose pool does not answer token0() and token1() is left out of
fct_swaps (it stays in the raw table).
Not covered: Uniswap v4 (one contract for all pools, whose tokens are known only from
its Initialize events), PancakeSwap v3 (its Swap event has two more fields, so a
different topic0), Curve, Balancer, and aggregators' own events (their swaps show up as
the pool swaps they route through).
Solana. A swap through an aggregator crosses several pools and programs; what matters is what the trader gave and got, so chainstar derives one swap per transaction from balance changes. A successful transaction counts when it calls one of these programs (each id from the program's own source or IDL, and checked on chain to be an executable program):
| Venue | Program id | Source |
|---|---|---|
raydium_amm_v4 |
675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 |
raydium-io/raydium-amm |
raydium_clmm |
CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK |
raydium-io/raydium-clmm |
raydium_cpmm |
CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C |
raydium-io/raydium-cp-swap |
orca_whirlpool |
whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc |
orca-so/whirlpools |
meteora_dlmm |
LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo |
MeteoraAg/dlmm-sdk IDL |
jupiter_v6 |
JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 |
jup-ag/jupiter-cpi |
pump_fun |
6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P |
pump-fun/pump-public-docs IDL |
pump_swap |
pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA |
pump-fun/pump-public-docs IDL |
For the first signer whose balances changed like a swap, token changes are summed over
the token accounts it owns (preTokenBalances/postTokenBalances), and SOL and wrapped
SOL count as one asset, native: its lamport change, plus the fee it paid, plus the
lamport changes of its token accounts, which nets out wrapping, unwrapping and the rent of
accounts it opens and closes. Exactly one token down and one up is a swap (SOL that
moved only for fees, tips or rent is ignored); one token against SOL moving the other way
is a swap with SOL; anything else (several tokens each way, an arbitrage that ends where
it started) is left out. Limitations: a SOL amount includes tips and rent paid to
accounts the signer does not own in the same transaction; programs not listed are not
read; a transaction gives at most one swap; Solana swaps have no pool, sender or
recipient, and dim_pools has no Solana rows.
For each chain, asset and UTC hour (and, separately, UTC day), in fct_token_prices:
- USD stablecoins are 1 USD (
usd_stablecoin_peg), identified by address or mint, never by symbol.observed_priceshows what their swaps against other stablecoins said, and a dbt test warns when it strays more than 2% from 1. - Swaps against a stablecoin give one observation each: the stablecoin amount (its
USD value) divided by the other asset's amount. ETH and SOL are priced this way from
WETH's and wrapped SOL's swaps, as the asset
native. - Swaps against the native asset price other tokens through the native asset's price in the same bucket, when that price is high or medium confidence. Nothing is priced through a third token: a token traded only against other tokens has no price.
- Outliers: observations worth less than 10 USD are dropped (mostly rounding); those more than 10% from the bucket's median are rejected. The price is the median of the rest. Medians here are lower medians (for an even count, the lower middle value), always one of the observed prices, so a price is the same to the last digit on every warehouse and in incremental and full builds.
- Confidence:
highwith at least 5 observations kept, 10,000 USD of volume and 2 sources (pools, or Solana traders);mediumwith at least 3 and 1,000 USD;lowotherwise, whenusd_priceis NULL andobserved_pricestill shows the median. Every chain has a row for its native asset and its stablecoins in each bucket with blocks (nonewhen there were no observations); other tokens only where they were traded. - Bitcoin has no on-chain DEX. Its price is that of wrapped BTC (WBTC and cbBTC) on
Ethereum and Base in the same bucket (
cross_chain_wrapped_btc), at mostmedium, and only when those chains' swaps for that hour are in the same warehouse. Wrapped BTC can trade slightly away from BTC; otherwise BTC values are NULL.
Checked on real data, chainstar quickstart of 20 blocks of Base and Ethereum and 20
Solana slots into one warehouse on 21 October 2026 (22:36 to 22:51 UTC): 360, 470 and
1,285 swaps (separately, each of the 84 swaps in the test fixtures' Ethereum and Base
blocks was checked against its pool's Transfer events, to the unit). ETH came out at
2,489.33 USD on Ethereum (97 observations, 6 pools, high) and 2,489.87 on Base (21, 7
pools, high), 0.02% apart; each WETH/stablecoin Uniswap v3 and Slipstream pool's own price
after its last swap (from sqrtPriceX96) was within 0.14% of the hour's price, and the
medians of pools with two or more swaps spread 0.15% (Ethereum, 4 pools) and 0.03% (Base,
3). SOL came out at 107.37 USD (medium: 3 swaps of at least 10 USD, through Jupiter,
Orca and Meteora, 0.9% apart; a fourth, at 50,319, was rejected). USD values were found
for 52% of Ethereum's transfers, 48% of Base's and 78% of Solana's, and for every fee.
Incremental builds over the same data, in two halves, equalled a full rebuild.
Only verified pools count on Ethereum and Base. The thresholds are dbt variables in
dbt_project.yml (price_min_swap_usd, price_outlier_band, price_high_*,
price_medium_*).
The anchors:
| Chain | USD stablecoins (1 USD) | Priced as the native asset | Prices Bitcoin |
|---|---|---|---|
| Ethereum | USDC 0xa0b86991...3606eb48, USDT 0xdac17f95...831ec7, DAI 0x6b175474...71d0f |
WETH 0xc02aaa39...756cc2 |
WBTC 0x2260fac5...c2c599, cbBTC 0xcbb7c000...eed33bf |
| Base | USDC 0x833589fc...da02913, USDbC 0xd9aaec86...710b6ca, USDT 0xfde4c96c...2699bb2, DAI 0x50c57259...17db0cb |
WETH 0x42000000...00000006 |
cbBTC 0xcbb7c000...eed33bf |
| Solana | USDC EPjFWdd5...TDt1v, USDT Es9vMFrz...enwNYB |
wrapped SOL So111...112 |
(full addresses in the price_assets seed). They come from Circle's USDC addresses
page, Tether's supported-protocols page and the Uniswap Labs default token list; their
decimals were read on chain.
fct_transfers.amount_usd,fct_fees.fee_usdandfct_swaps.usd_valueuse the hourly price of the block's UTC hour, when its confidence ispeg,highormedium, and are NULL otherwise. Beside each: the price (usd_price,native_usd_price),price_method,price_confidenceandprice_bucket_start, the hour the price is for, so every value can be checked againstfct_token_prices.- A swap is valued by its stablecoin side if it has one, else its native side, else
token_in. - The
transfersexport andchainstar walletspreadsheets carry the same columns.
Wallet histories. chainstar wallet does not read every swap of the range. For each
token the wallet moved, it asks the Uniswap v2 and v3 factories (and Aerodrome's on Base)
for that token's pools against WETH and USDC, once per token (cached in
data/<chain>/.pricing_pools.json), and fetches only those pools' swaps, with
eth_getLogs filtered by address, which the public endpoints answer for a whole chunk
per request, and the blocks they are in. The lookup asks Uniswap v2, Uniswap v3 (fee
tiers 100, 500, 3000 and 10000) and, on Base, Aerodrome v2 (stable and volatile); it does
not find SushiSwap or Aerodrome Slipstream pools. Tokens without such a pool stay
unpriced, and a wallet's prices rest on fewer swaps than a full run's, so their
confidence is often lower. A Bitcoin wallet's warehouse has
no Ethereum or Base swaps, so its BTC values are NULL; run Bitcoin and Ethereum into one
warehouse to price them.
fct_token_prices is incremental like the other tables. A bucket's price is computed
from all its swaps, so new blocks recompute every bucket from the start of the UTC day of
the earliest new block, for every chain at once (Bitcoin's price depends on Ethereum's
and Base's swaps). Transfers, fees and swaps are revalued for the hour of their chain's
first new block (Bitcoin's: the earliest of Bitcoin's, Ethereum's and Base's), whose
price may have changed. The tests check that two syncs give exactly the rows of a full
rebuild, and a rollback deletes a chain's swaps and prices with its other facts.
After upgrading from a release without swaps, rebuild once with
chainstar sync --full-refresh (or any chainstar run): the tables have new columns.
Blocks extracted before the upgrade have no swaps until they are extracted again: a
chainstar run over such a range fetches its swaps and reuses the other chunk files.
| Chain | Extracted by | RPC you need | --blocks counts |
Identity check |
|---|---|---|---|---|
| Ethereum | cryo 0.3.2 | Ethereum JSON-RPC | blocks | chain id 1 |
| Base | cryo 0.3.2 | Ethereum JSON-RPC | blocks | chain id 8453 |
| Bitcoin | chainstar | Bitcoin Core JSON-RPC, version 23 or later, not pruned below your range | blocks (heights) | getblockchaininfo reports chain main |
| Solana | chainstar | Solana JSON-RPC that serves getBlock for your slots |
slots | getGenesisHash is mainnet's, 5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d |
chainstar run checks the identity before extracting anything and stops if the endpoint
serves a different chain (a testnet, devnet or another EVM chain).
Bitcoin. For each block chainstar calls getblockhash and then getblock with
verbosity 3, which includes the spent output (prevout: value, address, script type)
for every input. That is what lets chainstar compute fees and name senders without one
extra call per input. Bitcoin Core returns it only for blocks it still has undo data for,
so a pruned node fails for older blocks, with an error that says so. Amounts arrive as BTC
with 8 decimals and are converted to whole satoshis exactly.
Solana. START:END counts slots. Not every slot has a block: for each chunk
chainstar asks getBlocks which slots have a finalized block, then fetches only those
with getBlock (jsonParsed, full transaction details, maxSupportedTransactionVersion
1, commitment finalized). Skipped slots produce no rows and cost no failed requests. The
end of the range must not be past the latest finalized slot, which chainstar run checks.
Version-1 transactions are live on mainnet: an endpoint or setting that accepts only
version 0 refuses every block that contains one.
Both extractors send one request at a time, honour --requests-per-second, make up to
three attempts per request with a growing delay between them, and ask for gzip (it shrank a
Bitcoin block about 2.5 times and a Solana block about 7 times). Tested in October 2026
against https://bitcoin-rpc.publicnode.com (Bitcoin Core 29.3) and
https://solana-rpc.publicnode.com (solana-core 4.3.0);
https://api.mainnet-beta.solana.com answered the same identity and slot calls. Public
endpoints are fine for trying chainstar on a few blocks; a Bitcoin block with input
values is about 15 MB of JSON (6 MB compressed) and a Solana block about 4 to 8 MB. These
publicnode endpoints are the defaults when no RPC URL is set (see
Public endpoints).
--target (or CHAINSTAR_TARGET, or target: in chainstar.yml) says where the
tables go. Every target works with run, sync (its progress record, reorganisation
rollbacks and incremental builds), status and export. Passwords, tokens and keys never
go in the target or in chainstar.yml: each comes from an environment variable or the
warehouse's own standard mechanism, never appears in dbt's profile (dbt reads it from a
DBT_ENV_SECRET_* variable, which dbt keeps out of its logs), and chainstar removes it
from every error.
| Target | --target |
Credentials | Raw rows loaded by | Incremental strategy | Tested how |
|---|---|---|---|---|---|
| DuckDB | duckdb:PATH |
none | INSERT ... SELECT from the Parquet files |
delete+insert |
live: every end-to-end test |
| Postgres | postgres:postgresql://USER@HOST:5432/DB |
PGPASSWORD |
DuckDB's postgres extension |
delete+insert |
live: postgres:16-alpine in Docker |
| MotherDuck | motherduck:DB or duckdb:md:DB |
motherduck_token |
INSERT ... SELECT from the Parquet files, through DuckDB |
delete+insert (dbt-duckdb) |
unit only: URL, profile, load path with DuckDB standing in. Not run against MotherDuck (no token) |
| Parquet lake | parquet:PATH, parquet:s3://BUCKET/PREFIX |
S3: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (and AWS_SESSION_TOKEN), else the AWS credential chain |
built in a local DuckDB engine, published with COPY ... TO Parquet |
delete+insert in the engine; publishing rewrites changed partitions |
live: local disk; S3 API on adobe/s3mock:5.2.3 in Docker (multipart uploads and listing checked). Not run against AWS S3, R2 or MinIO |
| ClickHouse | clickhouse:http://USER@HOST:8123 (or https://...:8443) |
CLICKHOUSE_PASSWORD |
INSERT ... FORMAT Parquet over HTTP |
delete+insert (dbt-clickhouse's delete_insert, lightweight deletes) |
live: clickhouse/clickhouse-server:25.8 in Docker |
| BigQuery | bigquery:PROJECT (?location=EU) |
Application Default Credentials | load jobs, source format Parquet | merge on the unique key |
unit, and the models' SQL on the BigQuery emulator. Not run against BigQuery; dbt-bigquery and load jobs not run at all |
| Snowflake | snowflake:USER@ACCOUNT/DB?warehouse=WH&role=ROLE |
SNOWFLAKE_PASSWORD, or a key pair in SNOWFLAKE_PRIVATE_KEY_PATH (and SNOWFLAKE_PRIVATE_KEY_PASSPHRASE), or &authenticator=externalbrowser |
PUT to the table's stage, then COPY INTO ... MATCH_BY_COLUMN_NAME from Parquet |
delete+insert |
unit only: statements and profile, and the models compiled and parsed as Snowflake SQL. Not run against Snowflake |
ClickHouse, BigQuery and Snowflake need their extra (chainstar[clickhouse],
chainstar[bigquery], chainstar[snowflake]; see Install); the Docker image
has them all. chainstar doctor --target ... checks the target without changing it: it
connects (ClickHouse and Snowflake report their versions), lists a lake's files, checks
the MotherDuck token, and names the extra to install when the dbt adapter is missing.
What "tested" means above: live is the full suite (load, build and its 93 dbt tests,
two syncs equal to a full rebuild, a reorganisation rolled back, exports) against the
real thing in a throwaway container, and on ClickHouse the star schema equals DuckDB's
row for row and type for type. Unit checks what chainstar sends: the SQL, its
parameters and their types, the Parquet files given to the bulk loader, and the dbt
profile. For BigQuery and Snowflake the bundled dbt project is also compiled offline,
for a first build and an incremental one, and every model and test must parse as that
warehouse's SQL. On the BigQuery emulator (ghcr.io/goccy/bigquery-emulator:0.8.1),
which uses BigQuery's own SQL front end, the 30 models build from the compiled SQL and
the star schema equals DuckDB's; the emulator cannot run dbt-bigquery (its create schema job fails) or Parquet load jobs (it crashes), so neither was tested, and a bug in
it (SELECT x, x AS y ... UNION ALL SELECT 2, 3 returns 2 for y) blanks the Bitcoin
and Solana fees, which that test leaves out.
Every target gets the same schemas: chainstar_raw (the extracted columns, as loaded:
cryo's for Ethereum and Base, and bitcoin_* and solana_* tables), chainstar_staging
(dbt views and the chains seed) and chainstar (the star schema). chainstar sync adds
a fourth, chainstar_state, for its progress record. ClickHouse
calls them databases and BigQuery datasets.
DuckDB: --target duckdb:path/to/file.duckdb. The file is created if it does not
exist. Avoid naming it chainstar.duckdb: DuckDB names the database after the file, so
chainstar.fct_transfers would become ambiguous and you would have to write
chainstar.chainstar.fct_transfers.
Postgres: --target postgres:postgresql://user@host:5432/dbname. Put the password in
the PGPASSWORD environment variable rather than the URL, so it stays out of your shell
history. chainstar loads raw tables through DuckDB's postgres extension, which DuckDB
downloads from its extension repository the first time it is used. The database user
needs permission to create schemas.
MotherDuck: --target motherduck:my_db (or duckdb:md:my_db), with your token in
motherduck_token. It runs exactly as a DuckDB file does, through DuckDB's md: paths
and its motherduck extension, which DuckDB downloads the first time (the Docker image
includes it). Untested against MotherDuck itself (no token was available), so these are
open: whether the database must exist first, and whether MotherDuck's transactions cover
a sync batch as DuckDB's do.
Parquet data lake: --target parquet:./lake on local disk, or
parquet:s3://bucket/prefix on S3-compatible storage. The star schema and the progress
record are written in the Hive layout that DuckDB, Spark, Athena and Trino read:
lake/chainstar/fct_transfers/chain=base/date_day=2026-10-21/data_0.parquet
lake/chainstar/fct_fees/chain=base/date_day=2026-10-21/data_0.parquet
lake/chainstar/fct_swaps/chain=base/date_day=2026-10-21/data_0.parquet (and fct_token_prices)
lake/chainstar/dim_wallets/chain=base/data_0.parquet (and dim_tokens, dim_contracts, dim_pools)
lake/chainstar/dim_dates/data_0.parquet
lake/chainstar_state/sync_progress/data_0.parquet
lake/_chainstar/published.parquet (what was published)
The partition columns are in the folder names only, as those engines expect, so read
the files with Hive partitioning, for example in DuckDB
select * from read_parquet('lake/chainstar/fct_transfers/**/*.parquet', hive_partitioning = true).
How it is built: in a local DuckDB file, the engine (lake-engine.duckdb in the current
folder; choose another with ?engine=PATH), exactly as a DuckDB target, then published
to the lake after each successful build. dbt-duckdb can write Parquet directly (its
external materialization), but it rewrites each table in full every time and cannot
build incrementally; building in the engine keeps incremental builds, the transactional
progress record and reorganisation rollbacks the same as on DuckDB, and the lake only
ever receives tables that were built and tested. Publishing is incremental too: each
partition's row count and a hash of its rows are kept in _chainstar/published.parquet,
and only partitions whose rows changed are rewritten. A partition that no longer exists
(after a rollback, for example) is deleted on local disk; on object storage, which DuckDB
writes but cannot delete from, its file is replaced by an empty one, which readers see as
no rows. The manifest is written last, so a publish that stops half-way is completed by
the next one. Each file is replaced in one upload, but a reader that lists the lake while
a publish runs can see some partitions new and others not yet. Keep the engine file with
the data folder: it holds the raw tables and the progress that later syncs continue
from. status and export read the lake itself, so they work on any machine that can
reach it.
For S3, set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (and AWS_SESSION_TOKEN for
temporary keys); without them chainstar uses the AWS credential chain (shared config and
credentials files, SSO, instance and container roles). The region comes from ?region=,
AWS_REGION or AWS_DEFAULT_REGION. For other S3-compatible storage name its endpoint;
http:// turns TLS off, and requests use path-style URLs unless url_style=vhost:
--target "parquet:s3://my-bucket/chainstar?region=eu-west-1" # AWS S3
--target "parquet:s3://my-bucket/chainstar?endpoint=https://ACCOUNT_ID.r2.cloudflarestorage.com®ion=auto" # Cloudflare R2
--target "parquet:s3://my-bucket/chainstar?endpoint=http://127.0.0.1:9000®ion=us-east-1" # MinIOClickHouse: --target clickhouse:http://user@host:8123, or https:// for TLS (port
8443 by default), with the password in CLICKHOUSE_PASSWORD. chainstar's schemas become
ClickHouse databases, so the user needs to create databases and tables. Raw tables are
MergeTree tables ordered by chain and block, with nullable columns. dbt-clickhouse
builds the star schema with delete+insert (its delete_insert), which deletes with
lightweight DELETE, so the user must be allowed to set
allow_nondeterministic_mutations; dbt sessions also set join_use_nulls and
cast_keep_nullable, which the models need. ClickHouse has no transaction spanning
several statements, so a sync batch is not atomic there: the raw rows are replaced first
and the progress row is written last, as a new version of the row (the progress table has
a _version column, and readers take the newest). If a sync stops between the two, the
next one finds the old progress and loads the same blocks again, replacing them rather
than adding them twice (the tests simulate this). A rollback deletes raw and fact rows
before it records the new progress, so if it stops half-way the hash check fails again
and it repeats.
BigQuery: --target bigquery:my-project, optionally ?location=EU for where new
datasets are made. Credentials are Google's Application Default Credentials: the key file
named by GOOGLE_APPLICATION_CREDENTIALS, gcloud auth application-default login, or
the metadata server on Google Cloud. chainstar's schemas become datasets of the project;
the identity needs to create datasets and run load and query jobs. Raw rows go up as
Parquet load jobs and the raw tables are clustered by chain and block; the star schema
uses dbt-bigquery's merge on each table's key, which replaces the same rows
delete+insert would. BigQuery's multi-statement transactions need sessions, which
chainstar does not use, so progress is versioned as on ClickHouse. Every sync runs DML and
load jobs, which BigQuery bills or counts against quotas. Query results for export
arrive as Arrow; whole-number BIGNUMERIC columns come back as 38-digit decimals.
Snowflake: --target "snowflake:user@account/DATABASE?warehouse=WH&role=ROLE". The
account is your account identifier (for example myorg-myaccount). The password comes
from SNOWFLAKE_PASSWORD; for key-pair authentication set SNOWFLAKE_PRIVATE_KEY_PATH
(and SNOWFLAKE_PRIVATE_KEY_PASSPHRASE if the key is encrypted) instead; for single
sign-on add &authenticator=externalbrowser. The database must exist; chainstar's
schemas are created in it, in upper case, as Snowflake stores unquoted names. Raw rows are
uploaded to each table's stage with PUT and loaded with COPY INTO ... FILE_FORMAT = (TYPE = PARQUET) MATCH_BY_COLUMN_NAME = CASE_INSENSITIVE, each file under a new name and
with FORCE = TRUE, so a repeated load is never skipped as already loaded, and inside the
batch's transaction together with its progress, as on DuckDB and Postgres.
If your team already moves data with dlt or
Airbyte, chainstar plugs into them as a source. The tables they
deliver are the finished star schema: the same tested, priced tables chainstar sync
builds, not raw blocks to model yourself. That is chainstar's value: decoding, pool
verification, on-chain USD prices, incremental builds and reorganisation handling. Raw
records would leave all of that to you.
pip install "chainstar[dlt] @ git+https://github.com/Bit-Primitive/chainstar" # dlt 1.31
pip install "chainstar[airbyte] @ git+https://github.com/Bit-Primitive/chainstar" # airbyte-cdk 7.38Each run does two things:
- Sync. One
chainstar syncpass into a working folder: Parquet chunks indata/and a DuckDB working warehouse,warehouse.duckdb. Everything in Keeping a warehouse in sync applies: confirmations, reorganisation rollbacks, incremental builds, the progress record. Keep the folder between runs; it holds the progress and the token and pool metadata fetched once. - Emit what changed. Each table is compared, by key and a hash of the whole row,
with what this destination was last given (kept in the working warehouse, schema
chainstar_emit, per destination). New and changed rows are emitted, and so are deletes for keys that left the star schema. All of them carry a new_chainstar_generation, the cursor.
Watching only new blocks would not be enough. An incremental build also rewrites older rows: the newest hour's USD values, prices from the start of the UTC day, wallets and tokens seen again. A rollback deletes rows and rebuilds everything. Comparing rows catches all of it. After every run the destination's tables equal the working warehouse's, row for row. The comparison reads every table each run, which DuckDB does at millions of rows a second; on a warehouse of hundreds of millions of rows it becomes the slow part.
The cursor lives in the framework's state. dlt keeps it in the pipeline state and Airbyte in its state messages, and both move it only after a load commits. A run passes the destination's generation in, changes are computed against that generation, and an uncommitted run is simply repeated. When the state is empty (a first load, or a full refresh), everything is emitted.
Reorganisations. Blocks are taken only with enough confirmations (Ethereum 12, Base 60,
Bitcoin 6, Solana finalized), so the short reorganisations every chain has never reach the
warehouse. A deeper one is rolled back as described in
Reorganisations. The next emission then deletes the rows that are gone
from the destination (dlt: _chainstar_deleted, a hard_delete column; Airbyte:
_ab_cdc_deleted_at) and upserts the rebuilt ones. This is tested with a simulated
reorganisation for both.
The emitted tables, keys and cursor:
| Table | Primary key |
|---|---|
fct_transfers |
transfer_id |
fct_fees |
fee_id |
fct_swaps |
swap_id |
fct_token_prices |
price_id |
dim_wallets |
wallet_id |
dim_tokens |
token_id |
dim_contracts |
contract_id |
dim_pools |
pool_id |
dim_dates |
date_day |
Every table has the columns of The star schema, plus:
_chainstar_generation(bigint): the cursor, increasing per table;_chainstar_deleted(bool): true on deletes;- on Airbyte,
_ab_cdc_deleted_at.
amount_raw and the other *_raw amounts stay text.
import dlt
from chainstar.integrations.dlt import chainstar_source
def source():
return chainstar_source(
chains=["base", "solana"],
working_dir="chainstar-work", # keep it between runs
from_recent=100, # a chain's first sync: the latest 100 blocks
# rpc_urls={"base": "https://..."}, # else CHAINSTAR_RPC_URL_<CHAIN>, then public endpoints
# tables=["fct_transfers", "dim_tokens"],
)
# DuckDB
pipeline = dlt.pipeline("chainstar", destination="duckdb", dataset_name="chainstar")
print(pipeline.run(source()))
# Postgres (dlt reads the credentials from .dlt/secrets.toml or the environment, e.g.
# DESTINATION__POSTGRES__CREDENTIALS=postgresql://user:password@localhost:5432/db)
pipeline = dlt.pipeline("chainstar_pg", destination="postgres", dataset_name="chainstar")
print(pipeline.run(source()))Run the same script again, for example from cron, and only what changed is loaded. Each table is one dlt resource with:
write_disposition="merge"on its primary key;- column hints for every column, with the
DECIMAL(38,0)wei amounts as exact decimals; - a
dlt.sources.incrementalcursor on_chainstar_generation, kept in dlt's state.
Arguments:
| Argument | Meaning |
|---|---|
chains |
ethereum, base, bitcoin, solana |
working_dir |
default chainstar-work |
from_block / from_recent |
a chain's first sync |
rpc_urls |
per chain |
tables |
which tables |
confirmations |
per chain: a number or "finalized" |
requests_per_second, chunk_size |
as for chainstar sync |
sync=False |
emit from the working folder without syncing, for a folder another process keeps current, while that process is not running |
dlt can also read the arguments from its own configuration, for example
[sources.chainstar] rpc_urls = {base = "..."} in .dlt/secrets.toml.
- Full reload:
pipeline.run(source, refresh="drop_resources")resets the cursor and reloads everything. Do not usewrite_disposition="replace"without it, since only changes would be loaded. - Several pipelines can share one working folder. Each pipeline and dataset gets its own record of what it was given, and the second to run finds the sync already done.
- Telemetry: importing
chainstar.integrations.dltsetsRUNTIME__DLTHUB_TELEMETRY=falseunless you set it yourself, so dlt's anonymous telemetry is off.
airbyte/source-chainstar/ holds the connector. It is
built with Airbyte's Python CDK, packaged as a Docker image on Airbyte's
python-connector-base, and comes with its tests. Every stream offers
Incremental | Append + Deduped, using the primary keys above and the cursor
_chainstar_generation, and Full refresh. Exact DECIMAL amounts are sent as strings,
because Airbyte's number types lose digits in some destinations. Its README covers the
configuration, building the image, and adding it to a self-managed Airbyte.
One caveat to know first: Airbyte runs every sync in a fresh container, so the working
folder must be on storage that outlives it. On self-managed Airbyte that is the optional
/local volume; on Airbyte Cloud there is none, so Cloud is not supported.
docker build -f airbyte/source-chainstar/Dockerfile -t source-chainstar:dev .
docker run --rm source-chainstar:dev specKeys are readable strings that start with the chain, for example
ethereum:0xdac17f958d2ee523a2206206994597c13d831ec7 or bitcoin:native, so rows from
different chains never collide. Addresses and hashes are as each chain writes them:
lowercase 0x hex on Ethereum and Base, Bitcoin addresses and txids as the node returns
them, base58 public keys and signatures on Solana. Times are UTC.
The shared columns mean the same on every chain: chain; block_number (the Bitcoin
block height, the Solana slot); block_timestamp and date_day; transaction_hash (the
Bitcoin txid, the Solana transaction's first signature); and the _id keys. A column
that does not apply to a chain is NULL there, as noted below.
One row per value movement:
- Ethereum, Base: one row per ERC-20
Transferevent. - Bitcoin: one row per transaction output, coinbase outputs included, in BTC.
Bitcoin has no tokens, and a transaction can spend coins from many addresses at once,
so no single input is picked as "the" sender.
from_addressis set only when every input came from the same address, which is the common case; when inputs came from several addresses it is NULL andinput_address_countsays how many. The coinbase spends nothing:from_addressNULL,input_address_count0. Outputs without an address (OP_RETURN data, bare multisig, pay-to-pubkey, non-standard scripts) are kept withto_addressNULL and theirscript_type; in October 2026 almost half of all outputs were zero-value OP_RETURN outputs. - Solana: one row per parsed SPL Token or Token-2022
transfer/transferCheckedinstruction and System Program SOLtransferinstruction, top-level or inner (CPI), in successful transactions only. A failed transaction (meta.errset) moves nothing; its fee is still infct_fees. For SPL tokens,from_addressandto_addressare the wallets that own the token accounts, and the token accounts are infrom_token_accountandto_token_account.
| Column | Notes |
|---|---|
transfer_id |
chain:transaction_hash:transfer_position (on EVM chains the same as chain:transaction_hash:log_index) |
chain, block_number, block_timestamp, date_day |
date_day joins to dim_dates |
transaction_hash, transaction_index |
|
transfer_position |
log index (EVM), output index (Bitcoin), instruction position (Solana: 3 is the fourth top-level instruction, 3.1 its second inner instruction) |
log_index |
EVM only |
token_id |
joins to dim_tokens, and on EVM chains to dim_contracts |
from_wallet_id, to_wallet_id |
join to dim_wallets; on EVM the zero address means a mint (sender) or burn (recipient); NULL on Bitcoin where there is no single sender or no address |
token_address |
ERC-20 contract, SPL mint, or native for BTC and SOL |
from_address, to_address |
|
input_address_count |
Bitcoin only: distinct addresses among the inputs |
from_token_account, to_token_account |
Solana SPL only |
script_type |
Bitcoin only: the output script type as the node names it (witness_v0_keyhash, nulldata, ...) |
amount_raw |
the exact value in the asset's smallest unit (satoshis, lamports, token base units), as a whole-number string |
amount |
amount_raw / 10^decimals as a floating-point number (about 15 significant digits); NULL when the token's decimals are unknown |
amount_usd |
amount × usd_price; NULL when the token has no peg, high or medium price for the block's UTC hour (see Swaps and USD prices) |
usd_price, price_method, price_confidence, price_bucket_start |
the hourly price used, from fct_token_prices, and the start of its hour |
amount_raw is text because ERC-20 values are 256-bit integers and SPL amounts 64-bit
unsigned, larger than any numeric type DuckDB and Postgres share. Cast it yourself when
you need exact arithmetic, for example amount_raw::numeric in Postgres.
Why Solana transfers come from instructions rather than from token balance changes:
balance changes (preTokenBalances / postTokenBalances) net out every movement in a
transaction and cannot say who paid whom. Checked against real blocks, the parsed
instructions account for every token balance change except wrapped-SOL wrapping and
unwrapping (syncNative, closeAccount) and the fee that Token-2022 mints with the
transfer-fee extension withhold from the recipient: for those mints amount_raw is the
amount sent, and the recipient receives less. Token accounts that are created and closed
within one transaction appear in no balance list; their mint and owner are read from the
initializeAccount instruction that created them.
One row per fee-paying transaction, with what its payer paid.
- Ethereum, Base: every transaction; the execution fee,
gas_used × effective gas price. On Ethereum this leaves out the separate blob fee paid by blob (type 3) transactions, and on Base the L1 data fee. cryo does not extract either. - Bitcoin: every transaction except the coinbase, which pays no fee; the fee is the
value of the outputs spent minus the value of the outputs created. A dbt test checks
it against the fee the node reports for every transaction. The payer is known only
when one address funded the whole transaction; otherwise
payer_wallet_idis NULL. - Solana: every transaction, failed and vote transactions included (
is_voteflags validator votes, which are most transactions); the fee ismeta.fee(base plus priority fee), paid by the transaction's first account.
| Column | Notes |
|---|---|
fee_id |
chain:transaction_hash |
chain, block_number, block_timestamp, date_day |
|
transaction_hash, transaction_index |
|
payer_wallet_id |
who paid; joins to dim_wallets |
from_address |
the payer's address |
to_address |
EVM only; NULL for contract creation |
transaction_type |
EVM only: 0 legacy, 1 access list, 2 EIP-1559, 3 blob, 4 set-code, 126 OP-stack deposit (Base) |
success |
always true on Bitcoin |
is_vote |
Solana only |
gas_limit, gas_used, effective_gas_price_wei, base_fee_per_gas_wei |
EVM only |
execution_fee_wei, execution_fee_native |
EVM only, kept from earlier releases; equal to fee_raw and fee_native |
fee_raw |
the fee in the chain's smallest unit (wei, satoshis, lamports), exact |
native_symbol, fee_native |
the fee in ETH, BTC or SOL, as a floating-point number |
fee_usd |
fee_native at the native asset's hourly USD price; NULL when unpriced |
native_usd_price, price_method, price_confidence, price_bucket_start |
the hourly price used, and the start of its hour |
One row per DEX swap (see Swaps and USD prices for the venues):
on Ethereum and Base a decoded Swap event, on Solana what one signer gave and got in
a transaction that called a known DEX program.
| Column | Notes |
|---|---|
swap_id |
chain:transaction_hash:swap_position |
chain, block_number, block_timestamp, date_day |
|
transaction_hash, transaction_index |
|
swap_position, log_index |
the log index (EVM); 0 on Solana, one swap per transaction |
venue |
EVM: the pool's factory's venue (uniswap_v2, uniswap_v3, sushiswap_v2, sushiswap_v3, aerodrome_v2, aerodrome_slipstream) or unknown; Solana: the known programs called, joined with + |
swap_method |
the event layout (uniswap_v2, uniswap_v3, solidly_v2), or balance_changes on Solana |
pool_id, pool_address, pool_verified |
EVM only; joins to dim_pools. Only verified pools are priced and valued |
trader |
EVM: the transaction's sender (NULL in a wallet history); Solana: the signer |
sender, recipient |
EVM only: the event's fields, usually a router and the receiver |
token_in_id, token_out_id, token_in_address, token_out_address |
what the trader paid and received; native for SOL and wrapped SOL on Solana |
amount_in_raw, amount_out_raw |
exact amounts in the tokens' smallest units, as text |
amount_in, amount_out |
divided by 10^decimals; NULL when decimals are unknown |
usd_value, usd_value_side |
the value at the hourly price of one side (in or out): a stablecoin side first, then the native asset, then token_in |
price_method, price_confidence, price_bucket_start |
of the price used |
On EVM, trader is whoever sent the transaction, often a bot or a relayer, and one
transaction through a router can hold several swaps (one per pool crossed).
One row per chain, asset, granularity (hour or day) and UTC bucket; see
How prices are derived.
| Column | Notes |
|---|---|
price_id |
chain:token_address:granularity:bucket_start_unix |
chain, token_address, token_id |
token_address is native for ETH, BTC and SOL, which WETH and wrapped SOL are priced as |
granularity, bucket_start, bucket_start_unix, date_day |
the UTC hour or day |
usd_price |
what chainstar values things at: 1 for stablecoins; the observed price at high or medium confidence; otherwise NULL |
observed_price |
the lower median of the observations kept (for stablecoins: from swaps against other stablecoins) |
price_low, price_high |
lowest and highest observation kept, the spread within the bucket |
price_method |
usd_stablecoin_peg, swaps_vs_stablecoins, swaps_vs_native, swaps_vs_stablecoins_and_native or cross_chain_wrapped_btc |
confidence |
peg, high, medium, low or none |
observations, rejected_observations |
swaps kept, and those rejected as more than 10% from the median |
volume_usd |
USD volume of the swaps kept, rounded to cents |
sources |
distinct pools (EVM) or traders (Solana) among them |
last_block_number |
the chain's newest block in the bucket |
Every Ethereum and Base pool that emitted a swap event, as its contract and factory described it when read (once, at the chain head).
pool_id, chain, pool_address, venue, event_family, factory_address,
factory_confirms, is_verified (the factory confirms the pool and is a known venue),
token0_address, token1_address, token0_id, token1_id, fee (Uniswap v3-style:
hundredths of a basis point, so 500 is 0.05%), tick_spacing, is_stable (Aerodrome),
metadata_block_number, first_seen_block, last_seen_block, swap_events
Every address that sent or received a transfer, or paid for a transaction.
wallet_id, chain, address, is_zero_address, first_seen_block, first_seen_at,
last_seen_block, last_seen_at, transfers_out, transfers_in, transactions_sent
"Wallet" here means any address. Some of them are contracts or programs; this release
does not tell them apart. On Bitcoin, transfers_out counts inputs spent from the
address and transactions_sent the transactions it helped fund, since an output does
not always have a single sender. is_zero_address applies to EVM chains only.
Every asset moved in the loaded data.
token_id, chain, token_address, token_standard, name, symbol, decimals,
metadata_block_number, has_metadata, first_seen_block, last_seen_block (the first
and last block in the loaded data where the asset moved)
- Ethereum, Base:
token_standarderc20; name, symbol and decimals as cryo read them from the contract. - Bitcoin: one row,
bitcoin:native, symbolBTC, 8 decimals. - Solana: one row per SPL mint (
spl-tokenorspl-token-2022) with decimals from the transactions, plussolana:nativefor SOL (9 decimals). Mint names and symbols live in separate metadata programs that chainstar does not read, so they are NULL rather than guessed. Wrapped SOL (So111...112) is an SPL mint like any other, kept apart from native SOL.
Contracts chainstar can identify. In this release that is only the Ethereum and Base
token contracts that emitted Transfer events, so it has one row per erc20 row of
dim_tokens. Bitcoin has no contracts, and Solana programs are not extracted.
contract_id, chain, contract_address, contract_type (always erc20 for now),
first_seen_block, last_seen_block, transfer_events
One row per UTC day from the earliest to the latest loaded block.
date_day, year, quarter, month, day_of_month, iso_day_of_week (1 = Monday),
iso_week, is_weekend
chainstar run finishes with dbt build, which runs these checks and fails the run if
any fail: unique and not-null keys on every table, every fact row joining to its date,
token and wallet dimensions, every EVM swap joining to dim_pools, every ERC-20 token
joining to dim_contracts, accepted values for chains, granularities, methods and
confidences, and every USD price and value positive with each price between its lowest
and highest observation. Where a column does not apply to a chain (a Bitcoin output with
no single sender, for example), its not-null test skips that chain. Two checks warn
instead of failing, because the chain can be right and they still fire: a stablecoin
whose swaps against other stablecoins put it more than 2% from 1 USD in an hour (at least
3 swaps), and ETH priced more than 2% apart on Ethereum and Base in the same hour (both
high confidence).
- Chains: Ethereum, Base, Bitcoin mainnet and Solana mainnet-beta.
- Targets not run against the real service: MotherDuck, BigQuery and Snowflake were
built from their documentation and adapter source and checked as the
Targets table says, not against an account. ClickHouse was tested on a
single 25.8 server, not on ClickHouse Cloud or a cluster (chainstar sets no
ON CLUSTERor replicated engines); S3 lakes on s3mock, not on AWS S3, R2 or MinIO. - ClickHouse and BigQuery batches are not atomic: a sync that stops between loading a batch and recording it loads the batch again next time (see Targets).
- Events: ERC-20 transfers and the swap events of
Swaps and USD prices only on Ethereum and Base. NFT transfers
are not extracted: cryo's ERC-20 dataset skips ERC-721
Transferevents (they have an extra indexed topic), and ERC-1155 uses different events. - USD prices come only from the swaps in the warehouse, so a short range prices only what traded in it, and a token traded only against other tokens is unpriced. Prices are per UTC hour; values use the hourly price only. Stablecoins are taken at 1 USD (a test warns if their swaps disagree); Bitcoin is priced from wrapped BTC on Ethereum and Base and only when those are in the same warehouse. Uniswap v4, PancakeSwap v3, Curve and Balancer are not read. Solana swaps are derived from balance changes, one per transaction, with the limits described there.
- Token names lose their spaces. cryo 0.3.2 strips spaces and control characters
from
name()andsymbol()results, so "Wrapped Ether" arrives asWrappedEther. Tokens that returnbytes32names, or that do not implement these functions, may have odd or empty values. - Symbols are not identities. Any contract can call itself "USDC". Real data is
full of impostor tokens with look-alike or identical symbols, and chainstar does not
filter them. Identify tokens by
token_address. - Token metadata is read at the latest block, not at the time of the transfers. Most
RPC nodes cannot answer calls against old state, and cryo stores a failed call as an
empty value without reporting an error. Metadata is fetched once per token and then
reused; delete
data/<chain>/erc20_metadata/to fetch it again. - Fees exclude Ethereum blob fees and Base's L1 data fee (see
fct_fees). - Bitcoin needs a node that serves
getblockverbosity 3 with undo data: Bitcoin Core 23 or later, not pruned below your range. Other Bitcoin RPC implementations were not tested. The coinbase transactions in blocks 91842 and 91880 repeat txids of earlier coinbases (BIP 30 grandfathers them); loading those blocks would fail the unique key tests. - Solana transfers cover SPL
transferandtransferCheckedand System Programtransfer. Not included: SPL mints and burns (unlike ERC-20, they are separate instructions, not transfers from or to a zero address), Token-2022transferCheckedWithFee, SOL moved bycreateAccount,transferWithSeedor by programs that change lamport balances directly, and staking rewards. SPL token names and symbols are not read. - Solana block times are the RPC's estimate; a block without one fails the
block_timestampnot-null test. - Full rebuilds with
run:chainstar runrebuilds every star-schema table each time, so very large warehouses get slower to update that way;chainstar syncbuilds them incrementally (see Incremental tables). - Block reorganisations are handled by
chainstar sync(see Reorganisations), not byrun,quickstartorwalletwithout--sync: with those, stay a few blocks behind the chain head (Solana reads only finalized slots, which cannot be rolled back). - Your RPC provider sets the limits. Free public endpoints often refuse some
requests, cap request rates, or answer only for recent blocks (the default publicnode
endpoints are asked at 2 to 5 requests per second, see
Public endpoints). cryo fetches ERC-20
transfers with one request per block, and one refused request fails its whole chunk.
chainstar retries a failed extraction (Bitcoin and Solana: each failed request), making
up to three attempts and keeping completed chunks; if a run still fails, run the same command
again, and on a flaky endpoint use a smaller
--chunk-size(for example 10) so each retry makes progress.
Not built yet, in rough order:
- more DEX venues (Uniswap v4, Curve, Balancer, PancakeSwap v3)
- more EVM chains
- SPL mints and burns, and Solana token names from on-chain metadata
- BigQuery and Snowflake verified against real accounts, and MotherDuck with a token
- Homebrew formula
uv sync --all-extras # every warehouse driver, so every test can run
uv run pytest # unit and end-to-end tests; no RPC or cryo needed
uv run ruff check . && uv run ruff format --check .
docker build -t chainstar . # the image; the cryo compile takes about 5 minutesThe tests use small Parquet files cut from real cryo output (tests/fixtures/), and real
Bitcoin and Solana RPC responses sliced to a few transactions (tests/rpc_fixtures/),
from which the Bitcoin and Solana Parquet fixtures are produced by chainstar's own
extractors. The end-to-end tests load all four chains into one warehouse. Some need a
server, and run only when it is named; give each a throwaway one, as CI does:
| Variable | Example | Runs |
|---|---|---|
CHAINSTAR_TEST_POSTGRES_DSN |
postgresql://chainstar:chainstar@127.0.0.1:5432/chainstar_test (postgres:16-alpine) |
Postgres end to end |
CHAINSTAR_TEST_CLICKHOUSE_URL |
http://chainstar:chainstar@127.0.0.1:8123 (clickhouse/clickhouse-server:25.8) |
ClickHouse end to end |
CHAINSTAR_TEST_S3_ENDPOINT, CHAINSTAR_TEST_S3_BUCKET |
http://127.0.0.1:9090, chainstar-test (adobe/s3mock:5.2.3) |
Parquet lakes on S3 |
CHAINSTAR_TEST_BIGQUERY_EMULATOR |
http://127.0.0.1:9050 (ghcr.io/goccy/bigquery-emulator:0.8.1, port published as the same number) |
BigQuery SQL on the emulator |
Each test file's docstring has the docker run line. The Excel export test downloads
DuckDB's excel extension the first time and is skipped when it cannot.
Two workflows publish chainstar; neither needs a stored secret.
- Docker image (
.github/workflows/docker.yml): on every push tomainand everyv*tag, buildsghcr.io/bit-primitive/chainstarfor amd64 and arm64 on native runners (ubuntu-24.04andubuntu-24.04-arm) and pushes it with the workflow's ownGITHUB_TOKEN(packages: write). Tags:latestandmainfrommain;X.Y.ZandX.Yfrom avX.Y.Ztag. One-time step after the first run: GitHub creates new container packages as private, so open the package's settings (github.com/orgs/Bit-Primitive/packages, thenchainstar, then Package settings, Danger Zone, Change visibility) and make it public, sodocker runworks without logging in. GitHub notes this cannot be undone. - PyPI (
.github/workflows/release.yml): on av*tag, checks that the tag matches the version inpyproject.toml, builds the sdist and wheel withuv build, and publishes them with trusted publishing (pypa/gh-action-pypi-publish,id-token: write): no API token is stored anywhere.
One-time PyPI setup, before the first release:
- On pypi.org, sign in to the account that should own the project, open
Your account → Publishing, and add a pending publisher for GitHub Actions:
PyPI project name
chainstar, ownerBit-Primitive, repositorychainstar, workflowrelease.yml, environmentpypi. A pending publisher does not reserve the name; the first successful release does (the name was free in October 2026). - In the GitHub repository, Settings → Environments → New environment, named
pypi. Optionally add yourself as a required reviewer, so each release waits for approval.
To release: set version in pyproject.toml (for example 0.1.0), move the
Unreleased notes in CHANGELOG.md under that version, commit, then
git tag v0.1.0 && git push origin v0.1.0.
Apache-2.0. See LICENSE.