Back in 2016-2017, I was bored at Qualys, and I spent that boredom on Bitcoin. That period left a trail of proofs of concept and experiments, most of them long forgotten, and a handful of posts on this weblog: addresses, RPCs, a genesis block…
One of them had a longer life: chainstate,
a C++ tool reading the chainstate directory of Bitcoin Core, which I wrote
about in Bitcoin: Parsing the chainstate.
The chainstate is where Bitcoin Core keeps the UTXO set: every transaction
output that has not been spent yet. That is all the coins that can still move,
and so, once grouped by address, what every wallet actually holds. I even ran a
small website, balances.crypto-nerdz.org, selling the whole dump of
balances for a few satoshis.
I recently rebuilt it in Rust, or rather had Claude regenerate it from the old tool and from Bitcoin Core’s sources. Here’s rs-chainstate.
What’s in the chainstate Link to heading
The chainstate is a LevelDB database. Each unspent output is one record:
- the key is
C, the transaction id and the output index; - the value is the height of the block holding the transaction (with a coinbase bit), then the amount, then the output script.
Everything is compressed the Bitcoin Core way: integers are its own VARINT
(not the CompactSize used in transactions), amounts go through
CompressAmount, and scripts through ScriptCompression, which squeezes the
common script types into a one-byte tag and a hash or a key. On top of that,
every value is XORed with an obfuscation key stored in the database itself, so
that antivirus software stops flagging LevelDB files because a transaction
happened to contain a virus signature.
Decompressing the script is the same switch as in the C++ version, minus the hand-written base58, bech32 and public key decompression, which now come from the bitcoin crate:
let script = match size {
0 => { /* P2PKH: 20-byte key hash */ }
1 => { /* P2SH: 20-byte script hash */ }
2..=5 => { /* P2PK: a 32-byte key, 4 and 5 meaning it is stored compressed */ }
n => ScriptBuf::from_bytes(take(buf, usize::try_from(n - 6)?)?.to_vec()),
};
Everything else, SegWit and Taproot outputs included, is stored as a raw script, and turned into an address by the crate.
Don’t open bitcoind’s database Link to heading
The 2018 post already warned that the chainstate had to be copied before
being parsed. Opening a LevelDB database is never read-only: it replays the
log, rewrites the MANIFEST and deletes obsolete files. Do that on bitcoind’s
own directory and you are in for a reindex.
This time the tool enforces it. snapshot copies the directory without
opening it as a database, and drops a marker file into the copy; the other
commands refuse to open a directory without that marker.
They also refuse a directory whose LOCK is held, and that one was a nice
trap: rusty-leveldb locks with
flock(2), while bitcoind’s LevelDB uses fcntl(2) locks. On Linux, the two
don’t see each other, so rusty-leveldb would happily open a database bitcoind
is writing to. rs-chainstate probes the fcntl lock itself, with F_GETLK.
Running it Link to heading
The whole thing, from bitcoind’s directory to a CSV of balances:
$ cat export
#!/bin/sh
cd ~/dev/rs-chainstate
rm -fr chainstate
cargo run --release -- snapshot /data/bitcoin/data/chainstate chainstate
cargo run --release -- dump-balances --p2pk-as-p2pkh --skip-zero --header --minimum 1000 -o balances_1000.csv ./chainstate
$ time sh ./export
Running `target/release/rs-chainstate snapshot /data/bitcoin/data/chainstate chainstate`
snapshot written to chainstate
Running `target/release/rs-chainstate dump-balances --p2pk-as-p2pkh --skip-zero --header --minimum 1000 -o balances_1000.csv ./chainstate`
________________________________________________________
Executed in 356.39 secs fish external
usr time 287.56 secs 10.00 micros 287.56 secs
sys time 15.45 secs 976.00 micros 15.45 secs
Six minutes, for a 15 GB chainstate read from a spinning disk. The snapshot itself is instant: on btrfs, the copy shares the original file’s blocks instead of duplicating them. In 2018 the chainstate weighed 2.8 GB and held about 59.5 million outputs; today it’s over 165 million.
A few words on the options:
dump-balancesdecodes every output and sums the amounts by address, in one pass.dumpalone prints the outputs, one per line, andbalancescan aggregate such a dump later.--p2pk-as-p2pkh: the oldest coins, most of the 2009-2010 coinbases, are pay-to-pubkey outputs, which have no address. Block explorers show them as the P2PKH address of their key, since the same private key spends both; this option does the same.--skip-zeroand--minimum 1000drop empty outputs and dust.- Balances are aggregated in memory, which peaked around 11 GB here. If that
doesn’t fit,
--temp-dirspills them to disk, partitioned by address hash, for the same output.
Outputs without an address show up as p2pk:<pubkey> (without
--p2pk-as-p2pkh), multisig:<script> or nonstandard:<script>.
The result Link to heading
$ head -6 balances_1000.csv
address;amount;utxo_count
34xp4vRoCGJym3xR7yCVPFHoCNxv4Twseo;24859759275302;5108
3M219KR5vEneNb47ewrPfWyb5jQ2DjxRP6;17734150534516;128
bc1ql49ydapnjafl5t2cp9zqpjwe6pdgmxy98859v2;14084998554169;56
bc1qgdjqv0av3q56jvd82tkdjpy7gdp9ut8tlqmgrpmv24sq90ecnvqqjwvw97;13001007903066;51
bc1qjasf9z3h7w3jspkhtgatgpyvvzgpa2wwd2lr0eh5tx44reyn2k7sfc27a4;9693240685262;8
A 2.7 GB file: 51,906,457 addresses holding at least 1,000 satoshis, across 156,685,439 outputs, for a total of 20,099,132 BTC. 970,366 of those addresses hold at least one bitcoin. The richest one sits on 248,597 BTC.
Some lines further down are fun to look at:
$ grep -E '^(1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa|nonstandard:76a90088ac);' balances_1000.csv
nonstandard:76a90088ac;260936304319;23
1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa;5767544735;80439
1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNais the genesis block’s address. Its 50 BTC coinbase was never added to the UTXO set, so it isn’t in the chainstate at all: these 57.67 BTC, in 80,439 outputs, are tributes people have been sending to it ever since.76a90088acisOP_DUP OP_HASH160 0 OP_EQUALVERIFY OP_CHECKSIG, a P2PKH script with an empty hash, which no key can ever satisfy. Those are the 2,609 BTC that Mt.Gox burned with a buggy transaction in 2011.
Next Link to heading
Nothing much, honestly. The C++ version was an excuse to learn how Bitcoin stores its state, and this one is the same itch scratched again, with a fraction of the code and none of the hand-rolled crypto. The sources are on GitHub.