Local stack for validating that simplepool's pool_mode=pps coinbase
shape is accepted as a valid drivechain deposit by the canonical
LayerTwo-Labs enforcer.
┌──────────────┐ GBT 18444
│ enforcer │◀────── simplepool (pps mode)
│ │
┌────────┤ ├────────┐
│ ZMQ │ │ gRPC │
│ 29010 └──────────────┘ 50051 │
▼ │ ▼
┌──────────┐ │ (events,
│ bitcoind │◀────────┘ sidechain CRUD)
│ regtest │ RPC 18443
│ patched │
└──────────┘
- bitcoind-patched (v30.2): BIP300/301-aware Bitcoin Core fork, regtest mode.
- bip300301_enforcer: validates BIP300 deposits, serves the
getblocktemplatesimplepool talks to. Its wallet runs with--wallet-sync-source=disabled— it stays in sync purely from incoming blocks, which is complete on a from-genesis regtest chain, so no electrs/Electrum server is needed. - thunder (L2-S9): the actual sidechain node, connected to the
enforcer via gRPC on 50051, RPC on 6009 (matches what
payout/lib/thunder.jsexpects out of the box).
Env knobs (all scripts): REGTEST_DIR relocates chain state, logs and
pidfiles away from .regtest/; REGTEST_BIN_DIR relocates the binary
cache (downloaded zips + extracted binaries) independently of the data
(default: $REGTEST_DIR/bin), so data can be wiped or moved without
re-downloading; REGTEST_SKIP_THUNDER=1 skips downloading/starting
thunder; REGTEST_WALLETLESS=1 starts the enforcer with no wallet at
all (template rewards go to a fixed regtest address). CI uses all of
these. Sidechain activation works walletless: the block producer's
persisted ack policy (SetAckAllProposals) makes every enforcer-built
coinbase carry the M1/M2 commitments, and blocks are mined via
MiningService/GenerateToAddress (enforcer PR #477 — the
enforcer prebuilt must be new enough to have it).
scripts/regtest/setup.sh # download prebuilts + write configs
scripts/regtest/start.sh # bring up bitcoind, enforcer, thunder
scripts/regtest/status.sh # ps-style summary
scripts/regtest/activate-thunder.sh # propose + mine sidechain #9 until active
scripts/regtest/thunder-init.sh # generate Thunder wallet mnemonic + address
scripts/regtest/validate.sh # activate, mine 150, probe GBT, print runbook
scripts/regtest/inspect-coinbase.sh # after mining: parse tip's coinbase
scripts/regtest/stop.sh
activate-thunder.sh and thunder-init.sh are both idempotent —
re-running once the state is set up is a no-op.
Everything lives under .regtest/ (gitignored): binaries in
.regtest/bin/, chain state in .regtest/data/, logs in
.regtest/logs/, pidfiles in .regtest/run/.
Running start.sh brings up the full stack cleanly on aarch64-darwin
(macOS Apple Silicon):
- bitcoind-patched v30.2 listens on
127.0.0.1:18443. - electrs indexes the regtest chain on
127.0.0.1:60401. - enforcer syncs to tip in ~5s and serves
getblocktemplateon127.0.0.1:18444.
validate.sh mines 150 blocks to a P2WPKH miner address, calls GBT
on the enforcer, and prints the next-step runbook.
All infra steps verified. A tiny CPU stratum miner
(cpuminer.js) connects to a pool_mode=pps simplepool
running against the enforcer, finds a regtest block in seconds, and
submits it. The block is accepted into the regtest chain by bitcoind-
patched and the enforcer; bitcoind classifies the coinbase output as
"type": "drivechain"; the OP_RETURN destination immediately follows;
inspect-coinbase.sh confirms the 5-output layout.
Critical finding from running the loop: the enforcer DOES NOT credit coinbase outputs as drivechain deposits. A side-by-side test:
| deposit source | enforcer Ctip update |
|---|---|
| simplepool coinbase, OP_DRIVECHAIN(9) value 49.5 BTC | NO (Ctip stays empty) |
WalletService/CreateDepositTransaction 1 BTC |
YES (Ctip → 100,000,000 sats) |
Both blocks are accepted into the chain. The difference is at the consensus-level deposit-recognition layer of the enforcer: it requires the deposit tx to spend real, mature, post-coinbase UTXOs to prove the BTC committed for crossover was actually spendable. Coinbase outputs fail that check.
This empirically answers the question my early research flagged as unclear: "Can a coinbase output be a valid Thunder deposit?" The answer is no, at least against the current LayerTwo-Labs enforcer.
The PPS design's working assumption — "every block's coinbase deposits directly to Thunder, so the pool never custodies BTC" — needs revision. Options for the follow-up:
- Two-step deposit. Coinbase pays the pool's BTC P2WPKH; a separate
service spends accumulated coinbase UTXOs into a proper
CreateDepositTransactionperiodically. Pool DOES custody BTC, briefly. Lowest implementation cost. - Per-block deposit tx. Pool builds and broadcasts a deposit tx in the same block as the coinbase. Higher coordination cost.
- Re-examine the enforcer's deposit rule for a path that does work (e.g. is there a flag, or did the rule change in a recent release?).
The drivechain coinbase builders we landed are still useful — they produce a well-formed (if not Ctip-crediting) coinbase shape, and the parsing/structural assertions stand. The shape becomes useful again if option 3 turns up a way to make the rule permit it.
scripts/regtest/setup.sh # one-time, downloads prebuilts
scripts/regtest/start.sh
scripts/regtest/validate.sh # activates sidechain #9, bootstraps,
# prints a proxy.conf snippet
# in another terminal — start the pool with the printed config:
./build/simplepool /tmp/regtest-proxy.conf
# in a third terminal — find a block:
node scripts/regtest/cpuminer.js --timeout 60
# after a block lands, parse its coinbase:
scripts/regtest/inspect-coinbase.sh
# expect:
# output count: 5
# [N] value=49.5 type=drivechain asm=OP_NOP5 9 1
# [N+1] value=0 type=nulldata asm=OP_RETURN <payload>
# [N+2] value=0.5 type=witness_v0_keyhash
To verify the deposit-recognition finding for yourself, side-by-side with a canonical deposit:
# Ctip BEFORE a canonical deposit (after the coinbase deposit attempt):
scripts/enforcer-rpc.sh cusf.mainchain.v1.ValidatorService/GetCtip \
'{"sidechain_number":9}'
# → {} (no Ctip — coinbase deposit was ignored)
# Issue a canonical deposit:
scripts/enforcer-rpc.sh cusf.mainchain.v1.WalletService/CreateDepositTransaction \
'{"sidechain_id":9, "address":"11111111111111111111", "value_sats":100000000, "fee_sats":1000}'
# ... and mine it:
scripts/enforcer-rpc.sh cusf.mainchain.v1.MiningService/GenerateToAddress \
'{"blocks":1, "address":"bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080"}'
# Ctip AFTER:
scripts/enforcer-rpc.sh cusf.mainchain.v1.ValidatorService/GetCtip \
'{"sidechain_number":9}'
# → {"ctip": {"txid": {...}, "value": "100000000"}}
End-to-end BIP300 validation crosses three async processes, a sidechain activation flow that takes multiple blocks, and a stratum miner — wrapping all of that in a single green/red CI check would hide where breakage actually occurred. Each script does one job loud-and-clear so a failure points at exactly one component.
That said, there IS a one-shot composition of these scripts for CI:
tests/test_e2e_regtest.sh (run by
.github/workflows/integration_tests.yaml). It runs the minimal stack
(no thunder, walletless enforcer), prints a loud stage banner before
each step, and dumps the relevant logs on failure — so a red CI run
still points at the exact component that broke.