⚠ Architectural finding from end-to-end regtest: the BIP300 enforcer does NOT credit coinbase outputs as drivechain deposits — a side-by-side test showed a canonical deposit updates the sidechain Ctip while a coinbase-shaped deposit does not. The PPS design's "every coinbase deposits directly to Thunder" working assumption needs revision; see scripts/regtest/README.md for the empirical evidence and architectural impact discussion. The drivechain coinbase builders and PPS plumbing landed on this branch are still useful — the shape is correct and the pieces compose — but a follow-up "two-step deposit" service is needed to actually move BTC into Thunder.
Twelve commits on top of main that turn simplepool from a pure solo-
mining proxy into a dual-mode proxy that can also run as a Thunder-
paying PPS pool, with at-most-once payouts, a regtest validation stack
that self-activates BIP300 sidechain #9, a tiny CPU stratum miner that
closes the validation loop, and a statistical fraud audit. SQLite
remains the source of truth; Redis is added as a real-time event
stream alongside it.
1ebcea1 Add block-withholding statistical audit (payout/audit.js)
6037a25 Script BIP300 sidechain #9 activation on regtest
e0b849e Add payouts_in_flight ledger for at-most-once payouts
76f8753 Silence WARN-level logs in broadcast test
b0da16f Add BIP300 regtest validation stack
a05a96f Add Thunder payout worker service
571dad4 Support PPS with backend-supplied coinbasetxn (CUSF enforcer)
8783c0b Wire pool_mode=pps end-to-end: Thunder username, drivechain coinbase, PPS accrual
f29ea16 Add Thunder address decoder and drivechain-deposit coinbase builder
b1f3537 Add Redis broadcast for pool events alongside SQLite
- New module src/broadcast.c / .h. Background publisher thread, bounded ring queue, fire-and-forget. SQLite remains source of truth; observers never block on Redis I/O.
- Channels published as JSON:
pool:shares,pool:rejects,pool:blocks,pool:tip,pool:credits. - Config: set
redis_urlinproxy.conf(empty = module disabled).
- New config key
pool_mode = solo | pps(defaultsolo, behaviour unchanged). Inpps:- Stratum usernames must be bare Thunder addresses — base58 of
the 20-byte hash. The
s9_<base58>_<hex6>deposit-format wrapper is rejected (Thunder itself doesn't recognize it at the byte level, so a miner authorized with it would accrue unpayable PPS balance). Validated in src/thunder.c. - Every block's coinbase is a BIP300 drivechain deposit into the
configured
pool_thunder_reserve_addressinstead of paying the miner on BTC. Operator fee stays in BTC. - Each accepted share credits the worker's
pps_credits.accrued_satsatpps_sats_per_diff * difficulty.
- Stratum usernames must be bare Thunder addresses — base58 of
the 20-byte hash. The
- New coinbase builders (src/coinbase.c):
coinbase_build_drivechain— bare build, emits[OP_DRIVECHAIN(9), OP_RETURN(payload), operator BTC fee, witness commit].coinbase_build_drivechain_from_template— same shape, but rewrites a backend-supplied coinbasetxn (CUSF enforcer path).
- New
pps_creditstable —worker_id,accrued_sats,paid_sats,last_updated. C proxy only writesaccrued_sats; payout worker only writespaid_sats.
- New service under payout/ — Node.js, separate process.
- Polls SQLite every 30s for workers with
accrued - paid >= min_sats, calls Thunder's JSON-RPCtransfer(addr, sats, fee), then updatespaid_satson success. Per-miner payouts are not batched — one bad address can't block others. - New
payouts_in_flighttable acts as a write-ahead log: INSERT before broadcast, atomic (txidwrite +paid_sats +=+ DELETE row) in one SQLite transaction after.listDue()skips any worker with an in-flight row → no double-pay even if the worker crashes mid-payout. reportStuck()surfaces rows older than 5 min on startup so an operator can reconcile manually; runbook in payout/README.md.
- New payout/audit.js — standalone read-only CLI.
- For each worker over a window:
expected_blocks = pool_blocks × (worker_accrued_diff / pool_accrued_diff);z = (expected − actual) / sqrt(expected). - Flags suspicious when
expected ≥ 5andz ≥ 3(~1-in-740 false positives under honest Poisson sampling). - No schema changes; runs against the same
data/shares.db. Human and--jsonoutputs.
- Under scripts/regtest/ —
setup.sh,start.sh,stop.sh,status.sh,activate-thunder.sh,validate.sh,inspect-coinbase.sh. - Downloads aarch64-darwin prebuilts of
bitcoind-patched v30.2,electrs, andbip300301_enforcerand orchestrates them locally. activate-thunder.shdrives the BIP300 propose/ack flow via the enforcer's gRPC at127.0.0.1:50051(regtest activation threshold = 6 votes). Verified end-to-end: proposal at height 1 → activation at height 7.
make test
Runs 7 suites: share, bitcoind, stratum, store, coinbase (now
12 tests including 4 drivechain ones), broadcast, thunder. All must
pass. PPS / drivechain coinbase byte layouts are asserted at the byte
level here.
Set pool_mode = solo (or omit — that's the default) in proxy.conf,
run ./build/simplepool proxy.conf, point a miner at :3334 with a
BTC address as the username. Same as before.
scripts/regtest/setup.sh # downloads ~50 MB of prebuilt binaries
scripts/regtest/start.sh # starts bitcoind + electrs + enforcer
scripts/regtest/validate.sh # activates sidechain #9, bootstraps 150 blocks,
# probes GBT, prints next-step runbook
# run the pool against the enforcer (see validate.sh output for the
# exact proxy.conf snippet — uses pool_mode=pps, bitcoind_url=
# http://127.0.0.1:18444, etc.)
./build/simplepool /tmp/regtest-proxy.conf
# in another terminal, connect any stratum miner to 127.0.0.1:13334
# with a Thunder-shaped base58 username
# after a block is mined
scripts/regtest/inspect-coinbase.sh
# teardown
scripts/regtest/stop.shRun a local Redis, set redis_url = redis://127.0.0.1:6379 in
proxy.conf, then in another shell:
redis-cli PSUBSCRIBE 'pool:*'
Every accepted share / reject / block / tip change / PPS credit will print as JSON.
cd payout && npm install
PAYOUT_DRY_RUN=1 \
PAYOUT_DB_PATH=../data/shares.db \
THUNDER_RPC_URL=http://127.0.0.1:6009 \
THUNDER_FROM_ADDRESS=any \
PAYOUT_INTERVAL_MS=2000 \
node index.js
Logs which workers it would pay, makes no Thunder RPC calls, doesn't
touch paid_sats. Drop PAYOUT_DRY_RUN=1 once a Thunder node is
reachable to actually pay.
cd payout
PAYOUT_DB_PATH=../data/shares.db node audit.js # human-readable
PAYOUT_DB_PATH=../data/shares.db node audit.js --window-hours 168 # week-long window
PAYOUT_DB_PATH=../data/shares.db node audit.js --json # for cron
Safe to run while the proxy is writing — DB is opened read-only and the queries hit indexed columns only.
- Stratum miner choice for the regtest E2E walk-through. Regtest
difficulty is
0x207fffff(trivially low), so any cpuminer or ckpool client wired at127.0.0.1:13334will find a block in seconds; not scripted because the right miner depends on the developer's environment. - Smart fee model for Thunder payouts. Flat 100-sat fee for now; will need revisiting once Thunder fee dynamics are observable.
- Confirmation-tracking for Thunder txs. Thunder's RPC doesn't expose per-tx confirmation counts, so we treat a successful broadcast as final. Matches how the rest of Thunder tooling works today.
- Auto-reconciliation of stuck in-flight payout rows. Operator-driven only — we can't safely tell apart "broadcast didn't happen" from "broadcast happened, finalize crashed" without a Thunder-side mempool/chain lookup. Runbook in payout/README.md.
| src/broadcast.c/.h | Redis publisher |
| src/thunder.c/.h | Thunder address decoder |
| src/coinbase.c | coinbase_build_drivechain[_from_template] |
| src/stratum.c | Thunder username auth + drivechain coinbase render |
| src/store.c | pps_credits + payouts_in_flight tables |
| src/main.c | OP_RETURN payload precompute + PPS accrual wiring |
| proxy.conf.example | All new config keys documented inline |
| payout/ | Thunder payout service (at-most-once protocol) |
| payout/audit.js | Block-withholding statistical audit |
| scripts/regtest/ | BIP300 regtest stack with sidechain auto-activation |