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_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.
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 |
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 |
fct_swaps
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).
fct_token_prices
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 |
dim_pools
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
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, 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.
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, 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).