Act I ยท Look
Nothing here can break anything. You start the stack and learn to read what it is telling you, which is the skill every later act depends on.
First light
~5 min, mostly waiting โโโFrom the repository root:
./scripts/fresh-chain.sh --profile all-tools
This wipes any previous chain and boots a brand-new one with every optional tool enabled, so nothing later in the walkthrough is missing. The first run also builds the images.
docker compose logs -f btc-simnet-mining-controller.
Smaller stacks exist too. You want all-tools today; the others are for CI:
| Profile | Containers | Adds |
|---|---|---|
docker compose up (minimal) | 5 | 3 nodes, mining controller, spammer. A chain, with nothing to drive it. |
minimal-api | 6 | the control plane: CLI, HTTP API, MCP, jobs, scenarios |
minimal-organic-reorg | 9 | the network agents: partitions and organic reorgs |
basic | 9 | the full local stack |
all-tools | 13 | electrs and the mempool.space explorer |
fresh-chain.sh always starts from block 0 and destroys the chain you already have.
To resume an existing one use docker compose --profile all-tools up -d instead.
docker compose ps lists thirteen running containers, and the mining controller's log has stopped talking about bootstrap.Meet the three nodes
~3 min โโโPlain regtest gives you one node that mines whenever you ask. Simchain separates the roles on purpose:
| Node | Role | Reachable at |
|---|---|---|
| node1 | Never mines. Wallet disabled, txindex on. Stands in for a third-party node you don't control โ this is your endpoint. | localhost:18443 |
| node2 | Miner with a wallet. The one you will mine and spend from by hand. | localhost:28443 |
| node3 | Miner with no host port at all, reachable only inside Docker โ which makes it the one to isolate in Act IV. | Docker only |
Ask a node who it is connected to:
docker exec btc-simnet-node1 bitcoin-cli -regtest \
-rpcuser=foo -rpcpassword=rpcpassword getconnectioncount
Read the chain
~4 min โโโOpen a second terminal pane and leave one of these running. Do not skip it โ almost every later step is judged by what this pane does.
# block-by-block feed, prints a banner when the chain reorganises
./scripts/chainwatch.sh
# or: aggregate chain and worker status, refreshed
cargo run -p simchainctl -- status --watch
status also shows each node's
height and tip, the mining and spam workers, the mempool, and a generation counter โ remember
that counter, it is how you will confirm your changes landed in Act II.
Try to cheat
~3 min โโโcurl -sS --user foo:rpcpassword \
--data-binary '{"jsonrpc":"2.0","id":1,"method":"getblockcount","params":[]}' \
http://127.0.0.1:18443/
curl -i --user foo:rpcpassword \
--data-binary '{"jsonrpc":"2.0","id":2,"method":"generatetoaddress","params":[1,"bcrt1qexample"]}' \
http://127.0.0.1:18443/
rpcwhitelist, not from a proxy in front of it. Mining,
mock time, chain-choice and node-administration calls are all denied on node1.
The same credentials are unrestricted on node2, which is how you will still mine on demand:
docker exec btc-simnet-node2 bitcoin-cli -regtest \
-rpcuser=foo -rpcpassword=rpcpassword getblockcount
invalidateblock, setmocktime or
stop. Then try addnode, which is deliberately allowed. Can you work out
why that exception exists?
Act II ยท Touch
Now you change things โ on the running chain, with no restarts, and with the result visible in your watcher within seconds. Everything here is reversible.
Change the weather
~5 min โโโLook at the current settings first:
cargo run -p simchainctl -- config show
Now switch the cadence from evenly spaced to Poisson โ the distribution real mining follows:
cargo run -p simchainctl -- config set \
BLOCK_INTERVAL_MODE=poisson \
BLOCK_INTERVAL_MIN_SECS=5 \
BLOCK_INTERVAL_MEAN_SECS=8 \
BLOCK_INTERVAL_MAX_SECS=12
For runs you need to reproduce exactly, pin miner selection and the RNG:
cargo run -p simchainctl -- config set \
MINER_WEIGHTS=node2:1,node3:1 \
MINING_RNG_SEED=42
And take manual control when you want it:
cargo run -p simchainctl -- mining pause
cargo run -p simchainctl -- mine --node node2 --blocks 1 --wait
cargo run -p simchainctl -- mining resume
BLOCK_INTERVAL_MEAN_SECS must fall inside [MIN, MAX] or the change is
rejected. Defaults ship as min 10 / max 20, so a mean of 8 on its own is refused โ set all three
together whenever you move the mean outside the current bounds.
config set from two
terminals at once โ one gets a 409. That is compare-and-swap protecting you from a
half-applied config, not a bug.
config show reports a higher generation than when you started this step.Fill the blocks
~6 min โโโEmpty blocks are the other great regtest lie. Turn the pressure up:
cargo run -p simchainctl -- config set \
ENABLE_SPAM=true \
SPAM_FILL_BLOCK_RATIO=3
For a controlled spike instead of steady pressure, provision capacity and then burst:
cargo run -p simchainctl -- mining pause
cargo run -p simchainctl -- spam prepare --node node2 --txs 100 --data-bytes 8000
cargo run -p simchainctl -- spam burst --node node2 --txs 100 --data-bytes 8000
cargo run -p simchainctl -- mine --node node2 --blocks 1 --wait
cargo run -p simchainctl -- mining resume
--data-bytes selects DATA mode: each transaction carries one OP_RETURN payload of that
size, filling blocks fast without growing the UTXO set.
prepare mines the confirmation blocks for the burst's dedicated capacity; the burst
itself never mines. Skip it and a burst short of capacity fails outright rather than half-filling.
Swap --data-bytes for --outputs-per-tx N if you want wallet-shaped
transactions instead of OP_RETURN payloads.
SPAM_FILL_BLOCK_RATIO up until the mempool never drains, then watch the median
fee rate of each new block. You have just built a fee market.
Get yourself paid
~5 min โโโYou need a regtest address. The quickest source is the dashboard's built-in test wallet
(Step 8), but any bcrt1 address works:
cargo run -p simchainctl -- faucet --to bcrt1q...=1.5btc --wait
Pass several --to ADDRESS=AMOUNT pairs to fund a batch. Amounts take decimal BTC
(1, 0.25, 1btc) or integer satoshis (25000000sat).
Act III ยท Doors
Short act. Everything you just did through the CLI is available through three other doors โ and they are genuinely the same operations, not parallel implementations.
The dashboard
~5 min โโโOpen http://localhost:8090/. Worth clicking through:
- Status โ the same data as
status --watch. - Live retuning โ change
BLOCK_INTERVAL_MEAN_SECShere and watch the generation bump, exactly as in Step 5. - Pause / resume workers with a click.
- Faucet โ test wallet card โ a deterministic BIP84 wallet from a public mnemonic, with reveal and copy buttons. Copy an address, paste it into the faucet form, fund it from the browser. This is the easy way to do Step 7.
- Jobs โ every mine, burst and reorg as a durable server-side job with progress and results.
API and agents
~5 min โโโAnything you have run so far, your CI can do with curl:
curl -s localhost:8090/api/v1/status | jq .
token="${SIMCHAIN_CONTROL_TOKEN:-simchain-control-dev-token}"
generation=$(curl -s localhost:8090/api/v1/config | jq .generation)
curl -s -X PATCH localhost:8090/api/v1/config \
-H "Authorization: Bearer $token" \
-H 'Content-Type: application/json' \
-d "{\"settings\":{\"SPAM_FEE\":\"0.0005\"},\"base_generation\":$generation}"
- Reads are open on localhost; mutations need the bearer token.
base_generationis the same compare-and-swap the CLI used in Step 5.- Also worth knowing:
/api/v1/jobs,/api/v1/faucet,/api/v1/config/schema.
And for coding agents
The same operations are exposed as MCP tools at http://localhost:8090/mcp, behind the
same token. Once your editor's agent is connected you can ask in plain language:
"Slow mining to a 20 second mean and tell me the current height."
"Partition node3, mine 5 blocks on it, heal, and report the winning tip."
127.0.0.1 only and rejects any non-loopback Host header,
which is what stops a hostile web page from pointing its own hostname at your loopback. Do not put
it behind a public reverse proxy.
curl, and got a 409 when you deliberately reused a stale base_generation.Act IV ยท Break
The reason the project exists. You will rewrite history, unwind it, split the network in two and let both halves mine, then make the wires slow. Keep your watcher visible throughout.
Rewrite history
~5 min โโโcargo run -p simchainctl -- reorg start --depth 2 --node node3 \
--adds-new-txs 1 --double-spend-pct 1 --wait
cargo run -p simchainctl -- jobs watch
Variations worth trying:
--emptymines empty replacement blocks, so orphaned transactions stay unconfirmed โ a chaos reorg.--double-spend-pct Npermanently drops a share of orphaned wallet transactions by mining conflicting spends.- Continuous mode re-runs a reorg every N blocks, for sustained instability.
invalidateblock is node-local and does not
propagate over P2P, so the coordinator applies it to each node. For a reorg produced by the real
mechanism, that is Step 12 โ and it is far more satisfying.
jobs watch reported the reorg job succeeded.Unwind it
~3 min โโโcargo run -p simchainctl -- rewind --blocks 3 --wait
reconsiderblock to restore the original branch if it fails partway. If an electrs
profile is running, its disposable index may not follow a rollback-only shape โ
./scripts/recover-explorer.sh rebuilds it and waits for its tip to match node1.
Split the network ๐
~10 min ยท the big one โโโ--profile minimal-organic-reorg or richer. all-tools has them.Set up your watchers before splitting, or you will only see the aftermath. node3 has no host RPC port by design, so inject the watcher into the container itself:
./scripts/inject-tools.sh btc-simnet-node3
# Pane A โ connected side (node2, host-exposed RPC)
./scripts/chainwatch.sh -P 28443 -i 1
# Pane B โ isolated side, watched from inside the container
docker exec -it btc-simnet-node3 chainwatch \
-H 127.0.0.1 -P 18443 -u foo -p rpcpassword -i 1
Both panes should agree on height and tip. Now cut the mesh:
cargo run -p simchainctl -- partition start --node node3 \
--main-blocks 3 --isolated-blocks 5 --heal-delay-secs 15
--heal-delay-secs hold keeps them apart long enough to actually look at.
isolated-blocks (5) is
greater than main-blocks (3), node3's branch has more work: Pane A jumps up to node3's
height and prints a reorg banner, while Pane B stays quiet โ it was already on the winning tip.
Bitcoin Core remembers the branch that lost. Ask it:
docker exec btc-simnet-node1 bitcoin-cli -regtest \
-rpcuser=foo -rpcpassword=rpcpassword getchaintips
getchaintips still shows
the losing branch as a valid-fork entry afterwards.
--main-blocks 5 --isolated-blocks 3) so the connected side wins
instead, and watch which pane prints the banner this time. Then try equal counts and find out why
the tool refuses.
Slow the wires
~5 min โโโcargo run -p simchainctl -- degrade start --node node2 \
--delay-ms 5000 --loss-pct 0 --seconds 30
# while that job reports observing_degraded_network:
cargo run -p simchainctl -- mine --node node2 --blocks 1 --wait
mine returns immediately โ RPC is on the separate control network and is never shaped โ
but node1 and node3 only learn about the new tip about five seconds later, when the delayed P2P
announcement lands. In status --watch, node2's height ticks up first and the others lag.
Act V ยท Repeat
Everything you have done by hand becomes a file you can check in, and a chain state you can restore in seconds. This is the part that turns a demo into a test suite.
Record the run
~6 min โโโOpen scenarios/all-features-live.yml and read it: every action from Acts II and IV as
ordered, version-controlled steps with assertions and checkpoints. Then run a small one:
cargo run -p simchainctl -- scenario validate scenarios/pause-then-burst.yml
cargo run -p simchainctl -- scenario explain scenarios/pause-then-burst.yml
cargo run -p simchainctl -- scenario run scenarios/pause-then-burst.yml \
--result results/burst.json
partition or degrade steps needs the network agents.
On a stack without them the whole scenario is rejected at submission with a 503 naming
the profile to start โ deliberately, so it cannot fail halfway through after already mutating the
chain. Set restore_settings: true to auto-revert the config map afterwards.
Save your world
~4 min โโโ./scripts/snapshot.sh save after-walkthrough
./scripts/snapshot.sh list
./scripts/snapshot.sh restore after-walkthrough --profile all-tools
Act VI ยท Ship
Point your own application at it, learn the one thing that would otherwise confuse you a week from now, and you are done.
Plug your app in
~5 min โโโFour ways in, all pointed at node1 โ the node you do not control:
| Interface | Endpoint | For |
|---|---|---|
| Bitcoin Core RPC | localhost:18443 | wallets, custody systems, anything speaking Core RPC |
| ZMQ | 28332โ28336 | LND / CLN, indexers, watchers โ all five topics |
| Electrum RPC | localhost:60001 | Electrum-protocol clients, via electrs |
| Explorer | localhost:1080 | a local mempool.space following node1 |
All five ZMQ topics are published โ rawblock, rawtx,
hashblock, hashtx and sequence. The last is the reorg-aware one:
it reports mempool add/remove and block connect/disconnect, so a consumer can follow the events from
Act IV correctly.
./scripts/recover-explorer.sh to rebuild and catch up with node1.
The one trap
~4 min โโโCompose boot settings live in .env: images, credentials, host ports, node policy,
wallet names, and the initial mining and spam policy. Every one has a default, so the stack
runs with no .env at all.
BLOCK_INTERVAL_MEAN_SECS in .env and restart the stack. What
happens to the block cadence?
# edit .env, then:
docker compose --profile all-tools up -d
state.json on its first ever
boot and treats that file as authoritative from then on. Restarts replay state.json, not
.env. This is precisely why Act II used the CLI rather than a restart: a running chain
needs a control surface, not a config file.
If you genuinely want to re-read .env, reset only the control plane's own memory. The
chain is untouched:
docker compose stop btc-simnet-control-plane
docker volume rm btc-simnet-control-state
docker compose --profile all-tools up -d btc-simnet-control-plane
.env owns boot-time infrastructure; once state.json exists, it owns live
mining and spam policy.
.env and restarting does not change the block cadence.Graduation
~2 min โโโYou have run every feature Simchain has:
- Act I โ three nodes, one of which refuses to help you cheat.
- Act II โ a running chain is reshaped through a control surface, never a restart.
- Act III โ dashboard, CLI, API and MCP are four doors into one operation set.
- Act IV โ reorgs, rewinds, splits and propagation delay, on demand.
- Act V โ all of it as checked-in YAML, with snapshots to skip the bootstrap.
- Act VI โ your application in the middle of it, and the
.envtrap behind you.
Tear down when you are finished โ the chain survives on named volumes and resumes on the next
up:
docker compose --profile "*" down
# or discard the chain too
docker compose --profile "*" down -v
All eighteen steps complete
You have started, reshaped, broken, automated and shipped against a Bitcoin network that behaves like mainnet. Go point it at something you actually care about.