โ‚ฟ Simchain โ€” Feature Walkthrough

Six acts, eighteen steps. You start a Bitcoin chain, learn to read it, reshape it while it runs, break it on purpose, then make the whole thing reproducible. Run the commands as you go โ€” every step ends with something you can check.
Time: ~60 min, or stop after any act You need: Docker, Docker Compose 2.23.1+, Rust toolchain Progress is saved in this browser
๐Ÿ”ญ

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.

STEP 1

First light

~5 min, mostly waiting โ—โ—โ—
get a three-node Bitcoin network running from a single command.

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.

Thirteen containers coming up. The mining controller then mines to height 204 โ€” past coinbase maturity, with your address funded โ€” before settling into its normal cadence. Follow it with docker compose logs -f btc-simnet-mining-controller.
Height 204 is not arbitrary: coinbase outputs need 100 confirmations before they can be spent, so the chain must mine past that before the faucet or any wallet test can work. Every later step assumes bootstrap has finished.

Smaller stacks exist too. You want all-tools today; the others are for CI:

ProfileContainersAdds
docker compose up (minimal)53 nodes, mining controller, spammer. A chain, with nothing to drive it.
minimal-api6the control plane: CLI, HTTP API, MCP, jobs, scenarios
minimal-organic-reorg9the network agents: partitions and organic reorgs
basic9the full local stack
all-tools13electrs 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.
STEP 2

Meet the three nodes

~3 min โ—โ—โ—
understand why there are three nodes instead of one, and which one is yours.

Plain regtest gives you one node that mines whenever you ask. Simchain separates the roles on purpose:

NodeRoleReachable at
node1Never mines. Wallet disabled, txindex on. Stands in for a third-party node you don't control โ€” this is your endpoint.localhost:18443
node2Miner with a wallet. The one you will mine and spend from by hand.localhost:28443
node3Miner 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
Peers on every node โ€” they form a full mesh, so each is connected to both others.
Point your application at node1 for the whole walkthrough. That is the entire design: if your tests can only pass by mining on demand, they are testing a fantasy.
You can say which node your app should connect to, and why it cannot mine.
STEP 3

Read the chain

~4 min โ—โ—โ—
get a live view you will keep open for the rest of the walkthrough.

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
A block every few seconds, alternating between miners. 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.
Watch for a minute and note the gaps between blocks. Evenly spaced, or irregular? You are about to change that answer deliberately.
You have a pane showing new blocks as they arrive, and you can find the current height without thinking about it.
STEP 4

Try to cheat

~3 min โ—โ—โ—
discover the boundary that makes this different from regtest โ€” by hitting it.
You are about to ask node1 โ€” a regtest node โ€” to mine you a block. Regtest always allows that. What do you think happens?
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/
The first call returns a height. The second returns HTTP 403 Forbidden โ€” and that 403 comes from Bitcoin Core's own per-user rpcwhitelist, not from a proxy in front of it. Mining, mock time, chain-choice and node-administration calls are all denied on node1.
This is the single most important idea in the project. Your application cannot mine its way out of a test. If your code needs a block, it has to wait for one exactly as it would on mainnet โ€” which is the class of bug that otherwise only surfaces in production.

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
Find another call node1 refuses โ€” try invalidateblock, setmocktime or stop. Then try addnode, which is deliberately allowed. Can you work out why that exception exists?
You got a 403 from node1 and a normal answer from node2, using the same username and password.
๐ŸŽ›

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.

STEP 5

Change the weather

~5 min โ—โ—โ—
reshape how blocks arrive, live, and watch it take effect.

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
Within seconds the pace speeds up and turns irregular in your watcher, and the generation counter increments. No container was recreated and no node restarted.
Irregular block timing is where confirmation-latency bugs live. Code that quietly assumes "a block every N seconds" passes on fixed-interval regtest and fails on mainnet, where you might wait forty minutes and then get three blocks in ninety seconds.

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.
Try setting only the mean to 8 and read the error. Then run 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.
Your watcher shows visibly irregular block gaps, and config show reports a higher generation than when you started this step.
STEP 6

Fill the blocks

~6 min โ—โ—โ—
turn empty blocks into full ones, with a real fee market above them.

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
Blocks stop being empty and a backlog forms. A ratio of 3 keeps roughly three blocks' worth of transactions queued, so there is always competition for the next block.
The fee floor is created by paying for it, never by editing Bitcoin Core's relay or mempool policy. That is Simchain's central rule: mainnet behaviour is imitated, not configured away โ€” so a transaction mainnet would reject is rejected here too.

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
The mempool inflates by 100 transactions, then drains into exactly one fat block. --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.
Push 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.
A recent block in your watcher is close to full, and the mempool holds a backlog instead of emptying every block.
STEP 7

Get yourself paid

~5 min โ—โ—โ—
put real coins in an address you control โ€” your first concrete artifact.

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).

Before you run it: what fee do you think this transaction pays?
A transaction paying exactly zero fee, confirmed in the next normal block โ€” even though the mempool you just filled is competitive.
A zero-fee transaction is normally unmineable. The miners give this one a virtual priority delta so it gets included โ€” again without touching relay policy. Change the miner's preferences, never the network's rules. That distinction is worth internalising.
Safety limits here are fixed, not settings: at most 100 outputs and 10,000 vB per transaction, a confirmed reserve each miner wallet must retain, and a cap on total value per request.
Find your funded transaction in the local explorer at localhost:1080 and look at its fee. Zero, mined anyway.
An address you control shows a confirmed balance, and you found the transaction's zero fee.
๐Ÿšช

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.

STEP 8

The dashboard

~5 min โ—โ—โ—
do Act II again in a browser, and find the built-in test wallet.

Open http://localhost:8090/. Worth clicking through:

  • Status โ€” the same data as status --watch.
  • Live retuning โ€” change BLOCK_INTERVAL_MEAN_SECS here 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.
Dashboard, CLI, HTTP API and MCP are four adapters over one operation set โ€” one mutation lock, one job coordinator, one source of truth. Nothing you click can drift from what your CI does.
An active-job banner while a job runs. Only one mutation job runs at a time across every surface; a second incompatible request is rejected outright, not queued, because a queued chain mutation can be unsafe by the time it would run.
The test wallet's mnemonic is public and printed in the UI. Regtest only โ€” never send real funds to an address derived from it.
You changed a setting from the browser and saw the generation counter move in your terminal.
STEP 9

API and agents

~5 min โ—โ—โ—
see that the CLI is a thin client, and hand the chain to an AI agent.

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_generation is 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."
An agent debugging your wallet code can reshape the chain underneath it โ€” add fee pressure, trigger a reorg, split the network โ€” without you translating each request into commands.
The API binds to 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.
You changed a setting with 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.

STEP 10

Rewrite history

~5 min โ—โ—โ—
make confirmed transactions unconfirmed on purpose, and see what your app assumes.
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
Chain height steps backwards, then replacement blocks rebuild from the live mempool โ€” including one injected transaction and one deliberate double spend. Your watcher prints a reorg banner.
Ask the honest question of your own code: does it handle a confirmed transaction becoming unconfirmed? A transaction permanently replaced by a conflicting one? Most code has simply never been asked.

Variations worth trying:

  • --empty mines empty replacement blocks, so orphaned transactions stay unconfirmed โ€” a chaos reorg.
  • --double-spend-pct N permanently drops a share of orphaned wallet transactions by mining conflicting spends.
  • Continuous mode re-runs a reorg every N blocks, for sustained instability.
This is an administrative reorg: 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.
Fund an address, wait for one confirmation, then reorg deeper than that confirmation. Does your wallet code still believe it has the money?
You saw the height go down and then back up, and jobs watch reported the reorg job succeeded.
STEP 11

Unwind it

~3 min โ—โ—โ—
move the tip backwards without replacing it โ€” a reorg's quieter cousin.
cargo run -p simchainctl -- rewind --blocks 3 --wait
All three nodes land on the same genuinely lower height, with no replacement blocks mined. Transactions from the discarded blocks can return to node-local mempools.
Useful when you want to replay the same block range under different conditions, or to test how your indexer copes with a tip that simply moved backwards rather than sideways.
The job never goes below bootstrap height 204, persists rollback information before mutating, and uses 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.
Your watcher shows a lower height than before, and it stayed there.
STEP 12

Split the network ๐Ÿ†

~10 min ยท the big one โ—โ—โ—
run two real Bitcoin chains at once, then make them fight.
the network agents โ€” --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
Pane A climbs by 3 blocks; Pane B climbs by 5. Different heights, different tips, same starting point โ€” two real, independently valid Bitcoin chains growing side by side on your machine. The --heal-delay-secs hold keeps them apart long enough to actually look at.
Before the hold expires: which branch wins, and what happens to the three blocks on the other one?
When the hold ends the job heals the split by itself. Because 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.
That is an organic reorg. Nobody told any node to abandon a branch โ€” each independently chose the most-work chain, which is exactly what happens on mainnet. Step 10 simulated the symptom; this reproduced the cause.

Bitcoin Core remembers the branch that lost. Ask it:

docker exec btc-simnet-node1 bitcoin-cli -regtest \
  -rpcuser=foo -rpcpassword=rpcpassword getchaintips
Only P2P traffic is cut โ€” control traffic rides a separate Docker network, which is why you can watch both sides throughout. If you forget the watcher panes, getchaintips still shows the losing branch as a valid-fork entry afterwards.
Reverse the numbers (--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.
You watched two panes show different heights at the same time, then saw one reorg onto the other's chain.
STEP 13

Slow the wires

~5 min โ—โ—โ—
make nodes disagree about the tip for a few seconds โ€” the way they really do.
the network agents, same as Step 12.
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.
In normal regtest every node learns everything instantly, so "all nodes agree on the tip right now" is an assumption that never gets tested. Here it is false for five seconds on demand.
Impairment is lease-based with a TTL: if the control plane dies mid-job, the agent clears its own qdisc and the link heals itself. Degradations use a per-node lane rather than the global mutation lock, so a manual mine on the same node may overlap, as may degradations on other nodes.
You saw node2's height move before node1's, with a visible gap between them.
๐Ÿ”

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.

STEP 14

Record the run

~6 min โ—โ—โ—
replace this whole walkthrough with one YAML file your CI can run.

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
Steps tick through while the chain reacts in your watcher. The server validates the whole document before reserving the mutation coordinator, waits for height 204, and persists step events and results.
This is the CI story. Scenarios are durable server-side jobs โ€” disconnecting the client does not cancel the run โ€” and named checkpoints let a CI job block on an exact chain state, run its own assertions against it, then release.
A scenario containing 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.
Write a five-line scenario of your own: wait for a height, burst, mine one block, assert the mempool drained. That file is now a regression test.
A scenario ran to completion and wrote a result JSON you can read.
STEP 15

Save your world

~4 min โ—โ—โ—
stop paying the bootstrap cost, ever again.
./scripts/snapshot.sh save after-walkthrough
./scripts/snapshot.sh list
./scripts/snapshot.sh restore after-walkthrough --profile all-tools
A restore brings back blocks, chainstate, miner wallets, the mempool and the recorded Compose profile โ€” not merely the block height.
Every fresh start otherwise re-mines to height 204 before anything interesting can happen. A snapshot turns the state you just spent an hour building into a fixture you can hand to a test suite.
Take snapshots from a live stack, and let a shutdown finish on its own โ€” force-killing bitcoind mid-write can corrupt the chainstate you were trying to preserve.
You restored a snapshot and the chain came back at the height you saved it at.
๐Ÿš€

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.

STEP 16

Plug your app in

~5 min โ—โ—โ—
connect your real application and re-run Act IV underneath it.

Four ways in, all pointed at node1 โ€” the node you do not control:

InterfaceEndpointFor
Bitcoin Core RPClocalhost:18443wallets, custody systems, anything speaking Core RPC
ZMQ28332โ€“28336LND / CLN, indexers, watchers โ€” all five topics
Electrum RPClocalhost:60001Electrum-protocol clients, via electrs
Explorerlocalhost:1080a 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.

Now go back and re-run Step 10 or Step 12 with your own application attached and watching. A reorg your code has never seen is the entire reason this project exists.
electrs maintains a disposable index. After a rewind or deep reorg it may need ./scripts/recover-explorer.sh to rebuild and catch up with node1.
Your application is connected to node1 and reacted to a reorg you triggered.
STEP 17

The one trap

~4 min โ—โ—โ—
learn the one behaviour that confuses everybody โ€” before it confuses you.

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.

You edit 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
Nothing changes. Same cadence, same spam behaviour.
The control plane copies mining and spam policy into 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
Height and mempool never move โ€” no node container was restarted โ€” but the workers pick up the new values immediately as the control plane reseeds and reconciles.
That reset discards your policy history, never your chain. Remember the two layers: .env owns boot-time infrastructure; once state.json exists, it owns live mining and spam policy.
You can explain in one sentence why editing .env and restarting does not change the block cadence.
STEP 18

Graduation

~2 min โ—โ—โ—
know what you now know.

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 .env trap 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
The real final boss: take the flakiest Bitcoin-related test in your own project, point it at node1, and run it while a reorg lands mid-test. Fix what breaks.
๐Ÿ†

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.