Bitcoin Counters are numbered inscriptions — files stored in Bitcoin witness
data (a COUNT envelope) — bound to a Counterparty asset. The inscription number
is assigned in sequence; the file is its content; the asset is the handle by
which it is identified, owned, and traded.
This tool indexes them (parse → join → number → store), mints and
transfers them using a taproot (BIP86) wallet kept inside Bitcoin Core
(Core holds the keys and signs; this is the same wallet bitcoin-cli manages),
and serves a web explorer plus a read-only JSON API.
For each block (ascending):
- Parse — in each transaction, scan the inputs' witness data for a valid
COUNTenvelope (OP_FALSE OP_IF "COUNT" <0x01 content_type> [<0x02 asset>] <0x00> <body…> OP_ENDIF …). The optional0x02tag names a target asset and marks a reinscription (see below). - Join — for each tx with exactly one valid envelope (across all its inputs),
bind it to the Counterparty issuance in the same transaction (matched by
txid). The block's issuances are fetched once and each candidate is looked up by itstxid, so the asset is whatever that transaction itself created. - Validate (via Counterparty Core, the oracle) — the issuance must be
status == "valid", the asset's first/creation issuance (asset_eventscontainscreation), and notBTC/XCP. - Number & store — assign the next gap-free number (from 0), write the file to a content-addressed blob store, and insert the record into SQLite.
We never reimplement Counterparty consensus — Counterparty Core decides issuance validity, asset identity, and ownership. ("Bitcoin Core" is the separate Bitcoin node; the two are always named in full to avoid confusion.)
A counter can also be attached to an existing asset you already own — a
reinscription. Here the COUNT envelope carries an extra 0x02 tag naming
the target asset, and the transaction carries no Counterparty message at
all. The indexer authorises it by proving the transaction spent an input from
the asset's owner (issuance-rights holder) as of that block — reconstructed
from Counterparty's issuance history (creation → reissuances → ownership
transfers). It is ownership, not token balance, that grants the right, and
ownership is checked at the height of the inscription, so a later transfer can
neither retroactively authorise nor invalidate it.
Each reinscription is a new, permanently-numbered counter, so one asset can
back many counters. The lowest-numbered counter on an asset is its original;
any later ones are reinscriptions (the explorer lists them all on the asset).
Mint one with inscribe --reinscribe --asset <ASSET> (see Usage).
- Python 3.10+
- A synced bitcoind with
txindex=1(RPC reachable; cookie auth supported) - A synced Counterparty Core v2 API
pip install -e . # installs deps + the `counters` console commandThe repo ships a Dockerfile and a docker-compose.yml with two services:
counters— the web explorer + read-only JSON API on port8081.indexer— the indexing engine (runsindex --from-genesis); needs a reachable bitcoind and Counterparty Core.
cp .env.example .env # set your bitcoind / Counterparty Core endpoints
docker compose up -d --build # build + start both services
docker compose up -d counters # ...or just the explorer (no backends required)
docker compose logs -f counters # follow logs
docker compose down # stopThe explorer is then at http://127.0.0.1:8081. The index (SQLite + blobs)
persists in the counters-data volume, mounted at /data inside the
containers. On Linux, host.docker.internal resolves to the Docker host (wired
up via extra_hosts), so the defaults in .env.example point at bitcoind /
Core running on the host.
Compose forwards the connection and indexer-behaviour variables from
.env(BTC_RPC_*,CP_API_URL,COUNTER_POLL_INTERVAL,COUNTER_CONFIRMATIONS). Theindexerservice sets its start floor with--from-genesisrather thanCOUNTER_START_HEIGHT.
| Variable | Default | Meaning |
|---|---|---|
BTC_RPC_URL |
http://127.0.0.1:8332 |
bitcoind JSON-RPC URL |
BTC_COOKIE_FILE |
~/.bitcoin/.cookie |
bitcoind cookie (preferred auth) |
BTC_RPC_USER / BTC_RPC_PASSWORD |
— | fallback if no cookie |
CP_API_URL |
http://127.0.0.1:4000 |
Counterparty Core v2 API |
COUNTER_DATA_DIR |
data/ |
SQLite + blobs location |
COUNTER_START_HEIGHT |
0 |
first block a fresh scan starts at |
COUNTER_CONFIRMATIONS |
0 |
blocks behind tip to stay |
COUNTER_POLL_INTERVAL |
15 |
seconds between tip polls in run |
A fresh scan starts at block 0. Raise the floor with
--from-taproot(block 709632 — no taproot reveal can exist earlier) or--from-genesis(block 955251 — counter #0; nothing valid precedes it), or setCOUNTER_START_HEIGHT. Stored progress always wins, so this only applies to a fresh DB — to rescan,rm -rf datafirst.
Invoke as counters-proto <command> after pip install -e ., or equivalently
python -m counters_proto <command>.
# --- indexing ---
counters-proto index -v # scan from block 0, then follow the tip
counters-proto index --from-taproot # skip pre-taproot blocks (fresh DB only)
counters-proto index --from-genesis # start at counter #0's block (fresh DB only)
counters-proto sync --stop-at 720000 # one-shot catch-up (bounded for tests)
# --- reads (need only a synced index) ---
counters-proto status # bitcoind / Counterparty / index heights
counters-proto list # 20 most recent
counters-proto list --recent 50
counters-proto list --owner bc1p... # by mint-time owner
counters-proto list --block 800000-800100 # by block range
counters-proto info 0 # metadata by number
counters-proto info RAREPEPE # ...or by asset name / longname
counters-proto info 0 --json # metadata as JSON
counters-proto info 0 --raw > cat.png # stream the file bytes
counters-proto info 0 --save cat.png # write the file to disk
counters-proto validate <txid> # is this tx a counter, and why / why not
# --- web explorer + read-only JSON API ---
counters-proto server # indexer + explorer on http://127.0.0.1:8081
counters-proto server --no-index # serve only (index runs elsewhere)
counters-proto server --host 0.0.0.0 --port 8081 # bind publicly / pick a port
# --- wallet (taproot BIP86, bc1p; keys held by Bitcoin Core) ---
counters-proto wallet --name mywallet create # new wallet; prints a 12-word seed ONCE
counters-proto wallet --name mywallet restore # re-import from a BIP39 seed (read on stdin) + rescan
# recover an OLD Counterparty wallet (Counterwallet / Freewallet — pre-BIP39 Electrum v1, legacy 1... addresses).
# The seed type is auto-detected; --counterwallet only forces it for a phrase valid as BOTH schemes. See wallets.md.
counters-proto wallet --name old restore --dry-run # preview the derived 1... addresses; imports nothing
counters-proto wallet --name old restore # import the legacy keys into Core + rescan
counters-proto wallet --name mywallet receive # next taproot (bc1p) address
counters-proto wallet --name mywallet balance # BTC + aggregated Counterparty balances
counters-proto wallet --name mywallet inscriptions # counters held by the wallet
counters-proto wallet --name mywallet send RAREPEPE 1 bc1p... # transfer a counter
counters-proto wallet --name mywallet send RAREPEPE 1 bc1p... --dry-run # compose+sign, no broadcast
# mint a counter from a file (commit + reveal). --dry-run builds, signs, and
# package-validates both txs WITHOUT broadcasting (prints raw hex + cost).
counters-proto wallet --name mywallet inscribe --file cat.png --dry-run
counters-proto wallet --name mywallet inscribe --file cat.png # free numeric asset
counters-proto wallet --name mywallet inscribe --file cat.png --asset ZOMBIEPEPES # named (0.5 XCP)
counters-proto wallet --name mywallet inscribe --file v2.png --asset RAREPEPE --reinscribe # attach to an asset whose issuance rights you hold (no new asset, no XCP)
counters-proto wallet --name mywallet inscribe --file cat.png --fee-rate 8 --commit-fee-rate 4The 12-word seed is the only backup and is shown once at create time. The keys are imported into a Bitcoin Core descriptor wallet, which holds them and does all signing; this tool never touches private keys after derivation.
--namedefaults tocounter.
python -m pytest # if pytest installed
python tests/test_envelope.py # zero-dependency runnercounters_proto/
config.py protocol constants + env-driven Config
bitcoind.py JSON-RPC client (cookie auth, getblock witnesses)
envelope.py script tokenizer + COUNT envelope parser
counterparty.py Core v2 client (the oracle)
store.py SQLite schema + content-hash blob store + queries
builder.py COUNT leaf script + P2TR commit-address derivation
tap.py BIP340 Schnorr + BIP341/342 taproot + tx serializer
bip32.py BIP32/BIP86 derivation (pure-Python RIPEMD160 + ecdsa)
electrum1.py Electrum-v1 recovery for old Counterwallet/Freewallet seeds
electrum1_words.txt the 1626-word Electrum-v1 list (verbatim from Electrum, MIT)
electrum2.py Electrum 2.x (standard/segwit) seed recovery
progress.py ord-style progress bar
__main__.py CLI command tree (parser + dispatch)
indexer/ the indexing engine
indexer.py pipeline + run loops
commands/ CLI command handlers
read.py status / info / list / validate
wallet.py create / restore / receive / balance / inscriptions
inscribe.py mint flow: create-issuance or reinscribe; build/sign commit & reveal
send.py transfer a counter (compose send + sign + broadcast)
serve.py server command entry point
server/ web explorer + read-only JSON API
app.py stdlib HTTP server (static SPA + /counters /counter /content)
static/ index.html + logos/favicon (served assets)
pyproject.toml installs the `counters` console command
Dockerfile container image (entrypoint: the `counters` CLI)
docker-compose.yml explorer + indexer services, data volume, host networking
.env.example sample environment (copy to .env)
wallets.md seed-phrase / wallet-type import support (BIP39, Counterwallet, …)
docs/ protocol + CLI reference PDFs
tests/
test_envelope.py parser unit tests
