The star schema
The star schema for chainstar. Raw blockchain data to clean dimensional tables in your own warehouse, from one command.
Keys 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.
fct_transfers
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_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.
fct_fees
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 |
dim_wallets
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.
dim_tokens
Every asset moved in the loaded data.
token_id, chain, token_address, token_standard, name, symbol, decimals,
metadata_block_number, has_metadata
- 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.
dim_contracts
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
dim_dates
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
Tests
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, and every ERC-20 token joining to dim_contracts. 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.