Step-by-step checks to confirm each landed piece behaves as advertised. Tick boxes as you go; each section is independent and you can skip around. Expected output is shown literally; deviation = something to investigate.
If a step fails, the section header points at the commit that owns the
behaviour, so git show <hash> is a quick way to inspect.
Sections 0–12 were written for the original pps-thunder work and are
organised by the commits that landed it. They still hold, but they predate
four of the five pool modes. What covers the modes now is one end-to-end
regtest suite each, all of them in CI, all of them mining a real chain:
| Mode | Suite |
|---|---|
solo |
tests/test_solo_regtest.sh |
pps-classic |
tests/test_e2e_regtest.sh |
pplns-thunder, pplns-btc |
tests/test_pplns_regtest.sh |
pplns-btc payouts |
tests/test_pplns_btc_payout_regtest.sh |
pplns-coinbase |
tests/test_pplns_coinbase_regtest.sh |
| Thunder payout rail | tests/test_payout_regtest.sh |
Running bash tests/<suite>.sh is a stronger check than any manual section
below, because it asserts against the chain rather than against a log. Section
13 is the manual pass for pplns-coinbase, which is the mode with a policy an
operator has to decide on rather than merely configure.
- macOS arm64 (the prebuilt regtest binaries are aarch64-darwin). On Linux/x86 some sections still work (unit tests, payout worker, audit, Redis broadcast) — only the regtest stack needs the aarch64 binaries.
-
brew install hiredis sqlite curl(hiredis for the C build) -
node --version≥ 20
Owns: every src commit. Quickest end-to-end sanity check.
make test
- Last lines of output show:
test_coinbase: all tests passed test_broadcast: ok test_thunder: ok - Each suite reports zero failures.
-
coinbasesuite shows 8 assertions including:ok: coinbase split fee math ok: coinbase_build_from_template (redirect + preserve commitments) ok: coinbase_build_from_template fee split - No spurious WARN lines from
test_broadcast(silenced in commit76f8753).
Owns: every src commit (must not break the existing behaviour).
In a fresh terminal:
cp proxy.conf.example /tmp/solo.conf
# edit /tmp/solo.conf: set bitcoind_url/operator_address to anything;
# leave pool_mode = solo (the default) and redis_url empty.
./build/simplepool /tmp/solo.conf
- Logs read
pool_modeis solo by default (nopool_mode=pps-classicline appears). - Process binds
:3334and waits for bitcoind. Same behaviour as pre-branchmain. - Kill it. Reload
main, compare: solo behaviour is identical.
In one terminal:
redis-server --daemonize yes # or `brew services start redis`
redis-cli ping # → PONG
Edit /tmp/solo.conf to set:
redis_url = redis://127.0.0.1:6379
Restart simplepool. In another terminal:
redis-cli PSUBSCRIBE 'pool:*'
- simplepool startup log says
broadcast: connected to redis 127.0.0.1:6379/0. - On bitcoind tip changes (or initial GBT) you see
pool:tipmessages withheight,hash,observed_at_s. - Connect a miner; accepted shares appear on
pool:sharesas JSON withworker,payout_address,ts_ms,difficulty,is_block. - Stop redis (
brew services stop redis); simplepool keeps running, logsredis connect ... failed, share processing is unaffected,dropped_redis_downincrements in shutdown stats.
./build/test_thunder
- Prints
test_thunder: ok. (Covers bare base58 + thes9_<b58>_<hex6>deposit-format wrapper + garbage rejection.)
The byte-level assertions live in tests/test_coinbase.c. Both the bare
builder (coinbase_build_split) and the template rewriter
(coinbase_build_from_template) are covered. To see a real
pps-classic coinbase end-to-end, do section 11.
-
make teststep 1 passed; the classic layout is asserted at the byte level (spendable output, then operator fee, then witness commitment, with any backend commitment outputs preserved in place). - No
drivechainassertions remain —pool_mode = ppsand itscoinbase_build_drivechain*builders were removed.
Without running the full regtest stack, you can still verify the config keys and the schema:
sqlite3 /tmp/pps-test.db < schema.sql
sqlite3 /tmp/pps-test.db ".schema pps_credits"
sqlite3 /tmp/pps-test.db ".schema payouts_in_flight"
- Both tables exist and match the columns documented in CLASSIC_PAYOUTS.md.
- Editing
proxy.confto setpool_mode = pps-classicwithoutpool_btc_addressmakes simplepool refuse to start withconfig: 'pool_btc_address' is required when pool_mode=pps-classic. - Setting
pool_mode = ppsmakes simplepool refuse to start withconfig: 'pool_mode = pps' was removed …. - A leftover
pool_thunder_reserve_address/thunder_sidechain_number/thunder_op_return_hexline logsis obsolete and ignoredand does not stop startup.
cd payout && npm install
Set up a synthetic DB:
mkdir -p /tmp/payout-test && cd /tmp/payout-test
sqlite3 shares.db < /Users/rob/projects/simplepool/schema.sql
sqlite3 shares.db "
INSERT INTO workers (name, first_seen, last_seen, payout_address) VALUES
('alice', 1000, 2000, '11111111111111111111');
INSERT INTO pps_credits (worker_id, accrued_sats, paid_sats, last_updated)
VALUES (1, 50000, 0, 1000);
"
Dry-run:
PAYOUT_DRY_RUN=1 \
PAYOUT_DB_PATH=./shares.db \
THUNDER_RPC_URL=http://127.0.0.1:6009 \
THUNDER_FROM_ADDRESS=any \
PAYOUT_MIN_SATS=10000 \
PAYOUT_INTERVAL_MS=2000 \
PAYOUT_SETTLE_INTERVAL_MS=2000 \
node /Users/rob/projects/simplepool/payout/index.js
- Prints
payout: DRY alice -> 11111111111111111111 50000 sats. - No DB writes (verify with
sqlite3 shares.db "SELECT * FROM pps_credits"—paid_satsstill 0). - Ctrl-C exits cleanly.
Start a tiny mock Thunder RPC in one terminal:
cat > /tmp/payout-test/mock-thunder.mjs <<'EOF'
import http from 'node:http';
let n = 0;
http.createServer((req, res) => {
let body = ''; req.on('data', c => body += c);
req.on('end', () => {
const r = JSON.parse(body);
let result;
if (r.method === 'balance') result = { available_sats: 1_00000000 };
else if (r.method === 'transfer') result = `txidfake${++n}`;
else result = null;
res.writeHead(200, {'content-type': 'application/json'});
res.end(JSON.stringify({jsonrpc:'2.0', id:r.id, result}));
});
}).listen(16009, () => console.log('mock listening on :16009'));
EOF
node /tmp/payout-test/mock-thunder.mjs &
Run the worker against it (no dry-run):
cd /tmp/payout-test
PAYOUT_DB_PATH=./shares.db \
THUNDER_RPC_URL=http://127.0.0.1:16009 \
THUNDER_FROM_ADDRESS=any \
PAYOUT_MIN_SATS=10000 \
PAYOUT_INTERVAL_MS=2000 \
PAYOUT_SETTLE_INTERVAL_MS=2000 \
node /Users/rob/projects/simplepool/payout/index.js
After ~3 seconds, Ctrl-C. Then:
sqlite3 shares.db "SELECT * FROM pps_credits;"
sqlite3 shares.db "SELECT * FROM payouts_in_flight;"
-
pps_creditsnow showspaid_sats = 50000(the 50k owed was paid). -
payouts_in_flightis empty (atomic finalize cleaned up).
Now simulate a crash: pre-populate an in-flight row before starting:
sqlite3 shares.db "
UPDATE pps_credits SET accrued_sats = 80000, paid_sats = 0;
INSERT INTO payouts_in_flight (worker_id, sats, txid, started_at)
VALUES (1, 80000, 'crashedTxid', 100);
"
node /Users/rob/projects/simplepool/payout/index.js
- On startup the worker logs
payout: 1 stuck in-flight row(s) (>300s old)with the txid. -
listDueskips alice; she is NOT paid again (no double-pay). - Ctrl-C.
sqlite3 shares.db "SELECT * FROM pps_credits;"still showspaid_sats = 0.
Synthetic dataset proving the audit catches a withholder:
cd /tmp/payout-test
sqlite3 shares.db "DELETE FROM shares; DELETE FROM workers;
INSERT INTO workers (id, name, first_seen, last_seen, payout_address) VALUES
(10, 'alice.honest', 1000, 2000, 'thAlice'),
(11, 'bob.honest', 1000, 2000, 'thBob'),
(12, 'eve.withhold', 1000, 2000, 'thEve');"
python3 - <<'PY'
import sqlite3, time
db = sqlite3.connect('shares.db')
now = int(time.time())
def shares(wid, n, n_blocks):
rows = [(wid, now - 3600 + i, 1.0,
1 if i < n_blocks else 0,
f'{wid:02x}{i:08x}'.ljust(64,'0') if i < n_blocks else None)
for i in range(n)]
db.executemany('INSERT INTO shares (worker_id,ts,difficulty,is_block,block_hash) VALUES (?,?,?,?,?)', rows)
shares(10, 3000, 30)
shares(11, 3000, 28)
shares(12, 3000, 0)
db.commit(); db.close()
PY
PAYOUT_DB_PATH=./shares.db node /Users/rob/projects/simplepool/payout/audit.js
- Output begins with
!! 1 SUSPICIOUS worker(s)flaggingeve.withhold. - eve's row shows
z ≈ 4.4,actual=0,expected ≈ 19. - alice and bob have z ≤ 0 (slight over-performance, normal).
-
--jsonmode emits a parseable object withsuspicious: trueonly for eve.
scripts/regtest/setup.sh # ~90 MB of prebuilt binaries incl. Thunder
scripts/regtest/start.sh
scripts/regtest/status.sh
-
status.shshows all four processesup:bitcoind,electrs,bip300301_enforcer,thunder. - Thunder log ends with
Verified existence of cusf.mainchain.v1.ValidatorServiceandstarting RPC server at 127.0.0.1:6009. -
curl -sS -u user:password -H 'content-type: application/json' \ --data '{"jsonrpc":"1.0","id":1,"method":"getblockcount","params":[]}' \ http://127.0.0.1:18443/returns a number. -
curl -sS -H 'content-type: application/json' \ --data '{"jsonrpc":"2.0","id":1,"method":"balance","params":[]}' \ http://127.0.0.1:6009/returns{"result":{"total_sats":0,"available_sats":0}}— Thunder RPC is reachable (matches what the payout worker expects).
Activate the sidechain, then set up Thunder's wallet:
scripts/regtest/activate-thunder.sh
scripts/regtest/thunder-init.sh
-
activate-thunder.shfirst run printssidechain 9 is now ACTIVE; second run is a no-op. -
thunder-init.shprints a fresh mnemonic + wallet address + formatted deposit string (s9_<base58>_<hex6>); second run short-circuits withalready initialised.
Issue a canonical deposit to the Thunder-owned address it printed, then observe the enforcer's TwoWayPeg event stream:
DEP='<paste the deposit format from thunder-init.sh>'
scripts/enforcer-rpc.sh cusf.mainchain.v1.WalletService/CreateDepositTransaction \
"{\"sidechain_id\":9,\"address\":\"$DEP\",\"value_sats\":100000000,\"fee_sats\":1000}"
scripts/enforcer-rpc.sh cusf.mainchain.v1.MiningService/GenerateToAddress \
'{"blocks":1, "address":"bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080"}'
scripts/enforcer-rpc.sh cusf.mainchain.v1.ValidatorService/GetCtip \
'{"sidechain_number":9}'
- Ctip returns non-empty with
value: "100000000"(or the running total if earlier deposits happened).
Note: Thunder's own balance staying at 0 after a canonical deposit is
expected until Thunder produces a sidechain block via BMM. BMM is a
Thunder-side concern (thunder-cli's mine command blocks on
mainchain coordination) — outside the scope of the pool. The
enforcer-side Ctip update is the authoritative signal that the
deposit was consensus-accepted.
This is the loop-closer: pool emits a pps-classic coinbase, the miner
finds a block, bitcoind-patched accepts it. tests/test_e2e_regtest.sh
automates this whole section for CI.
After section 10 the stack is up. Run scripts/regtest/validate.sh —
it bootstraps 150 blocks, then prints a proxy.conf snippet. Save it:
cat > /tmp/regtest-proxy.conf <<'EOF'
listen_addr = 127.0.0.1
listen_port = 13334
bitcoind_url = http://127.0.0.1:18444
operator_address = bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080
fee_bps = 100
coinbase_tag = /simplepool-regtest/
pool_mode = pps-classic
pool_btc_address = bcrt1qqypqxpq9qcrsszg2pvxq6rs0zqg3yyc5phstwt
pps_sats_per_diff = 1000
initial_diff = 0.0000001
vardiff_enabled = 0
db_path = /tmp/regtest-shares.db
EOF
sqlite3 /tmp/regtest-shares.db < schema.sql
In terminal A:
./build/simplepool /tmp/regtest-proxy.conf
- Log shows
pool_mode=pps-classic: pool_btc_address=bcrt1qqypq…, pps_sats_per_diff=1000.00. - Log shows
stratum listening on 127.0.0.1:13334.
In terminal B:
node scripts/regtest/cpuminer.js --timeout 60
- Miner connects, subscribes, authorizes with the default 20-base58 Thunder address.
- After many "low difficulty" rejects (share target is still smaller
than network target), a
submit -> trueandblock found and accepted ✓appear within a few seconds. - Terminal A simplepool log shows
BLOCK FOUND: height=N finder=11111111111111111111 reward=4950000000 fee=50000000followed bysubmitted block to bitcoind successfully.
Inspect the coinbase:
POOL_BTC_ADDRESS=bcrt1qqypqxpq9qcrsszg2pvxq6rs0zqg3yyc5phstwt \
OPERATOR_ADDRESS=bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080 \
scripts/regtest/inspect-coinbase.sh
- One output pays the pool wallet 49.5 BTC (
>>> pool output found). - One output pays the operator fee P2WPKH 0.5 BTC
(
>>> operator fee output found), and it is the smaller of the two. - No
OP_DRIVECHAINoutput is reported — the script exits non-zero if one shows up. - Script prints
==> classic coinbase shape OK.
The critical empirical result. With section 11 finished:
scripts/enforcer-rpc.sh cusf.mainchain.v1.ValidatorService/GetCtip \
'{"sidechain_number":9}'
- Returns
{}. The coinbase deposit did NOT credit the Ctip, even though the block was accepted into the chain.
Now issue a canonical deposit to confirm the rule difference:
scripts/enforcer-rpc.sh cusf.mainchain.v1.WalletService/CreateDepositTransaction \
'{"sidechain_id":9, "address":"11111111111111111111",
"value_sats":100000000, "fee_sats":1000}'
scripts/enforcer-rpc.sh cusf.mainchain.v1.MiningService/GenerateToAddress \
'{"blocks":1, "address":"bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080"}'
scripts/enforcer-rpc.sh cusf.mainchain.v1.ValidatorService/GetCtip \
'{"sidechain_number":9}'
- Now returns
{"ctip": {"txid": {...}, "value": "100000000"}}. - Side-by-side: same enforcer, same active sidechain, only the deposit source differs. Coinbase-as-deposit doesn't work; deposit tx from spendable UTXOs does.
This is the architectural finding that killed pool_mode = pps and
drove the operator-triggered deposit design in
CLASSIC_PAYOUTS.md.
Owns: the coinbase-direct rail. The automated version of all of this is
bash tests/test_pplns_coinbase_regtest.sh; do that first. This section is
the manual pass, and it exists because this mode has a policy an operator
has to agree with, not just a config to fill in.
-
pool_btc_addressset alongsidepool_mode = pplns-coinbaseis refused at startup: "'pool_btc_address' must not be set when pool_mode=pplns-coinbase". There is no pool wallet in this mode. -
pplns_payout_floor_sats = -1is refused ("must be >= 0"). -
coinbase_max_bytes = 150is refused ("too small to hold a coinbase and a single payout"). - No payout worker is installed.
soloandpplns-coinbaseneed none — seepayout/README.md.
A block cannot pay everyone in a large window, so miners have to know both halves: what one block may not pay them, and what happens to it. Check this rather than assume it.
- Startup, beside the identity line: "payout floor N sats — a miner whose share of a block is worth less than that is NOT PAID…". It prints even when the node is unreachable, because it is a config fact.
- Per template, when someone in the window is below it: "N of M miner(s) in the window are below the …-sat payout floor and will earn NOTHING from the next block". Only re-logged when the count changes.
- Per block: either "paid all N miner(s)" or "N claim(s) worth X sats had no room and were REDISTRIBUTED across the miners who did fit".
- The dashboard, before anyone connects. Open
/and read the "About the numbers" card: it must state the floor in sats and say the amount is shared out among the miners that block could pay, and that the miner goes first in the queue for the next one. If it does not, the proxy is on a build that predatespool_meta.pplns_payout_floor_sats— the card stays silent rather than inventing a default, so checksqlite3 shares.db "SELECT pplns_payout_floor_sats FROM pool_meta". - You have published the floor on your pool page. Nothing in the software can do this one for you.
Not out of the pool's own database — that is the pool marking its own
homework. bitcoin-cli getblock <hash> 2 | jq '.tx[0].vout':
- One output per miner in the window, plus the operator's.
- No output pays an address the pool controls beyond the operator fee. There is no pool wallet, so a third address means something is wrong.
- The outputs sum to the whole block reward. A coinbase paying out less than it may destroys the difference.
- The operator output is exactly
fee_bpsof the block, and no more, on every block — including ones that could not pay the whole window. This is the check that matters most: until #76 a dropped claim rode on the operator's output, and on a 100-miner window that came to 25% of the block against a 1% advertised fee. - The miners who were paid received more than their own window share, and the outputs still sum to the whole block. That is the redistribution arriving — if the total is short, value was destroyed rather than shared.
-
sqlite3 shares.db "SELECT COUNT(*) FROM pps_credits"is 0. This mode credits no balance, ever. Any row means a pooled mode's accrual path ran. - The payout queue balances:
sqlite3 shares.db "SELECT ROUND(COALESCE((SELECT SUM(delta) FROM pplns_pending_fractions),0) + COALESCE((SELECT SUM(owed_fraction) FROM pplns_fractions),0), 9)"is 0. It is a record of whose turn it is, not money — a non-zero sum means somebody's turn was invented or destroyed. - After a block is found but before it confirms, its rows are in
pplns_pending_fractionsand not inpplns_fractions. An orphaned block paid nobody and must rotate nobody; the confirmation pass is what applies them. It applies them at ONE confirmation, so a block reorged out after that has already rotated the queue and is not reversed — a turn out of order, not money, and the next block corrects it.
-
On a rented port, set
max_coinbase_bytes=on that listener rather than server-wide. The ceiling is a marketplace rule that binds only on the port the rented hashrate connects to, and every byte of it costs a payout — a 100-miner window pays 9 at 400 bytes and 93 at 3000. -
Measure a real coinbase:
bitcoin-cli getblock <hash> 2 | jq -r '.tx[0].hex' | wc -c÷ 2 = bytes. Compare againstcoinbase_max_bytes. -
On a drivechain, note the BIP300/301
OP_RETURNcount. They spend the same budget the payouts do, so the number of miners a block can pay moves with sidechain activity. Reported in production: the same 16 payouts cost 817 bytes against four commitments and 769 against three.
scripts/regtest/stop.sh
brew services stop redis 2>/dev/null
rm -rf /tmp/payout-test /tmp/regtest-proxy.conf /tmp/regtest-shares.db /tmp/solo.conf /tmp/pps-test.db
- No leftover processes (
pgrep -f bip300301_enforcerreturns nothing). -
.regtest/directory still present (gitignored; safe to keep for repeat runs).
If every box ticks:
- the PPS plumbing is correct end-to-end at the byte level,
- the payout worker has correct at-most-once semantics,
- the audit catches synthetic withholders,
- the regtest stack reproduces the architectural finding that coinbase deposits aren't Ctip-credited by the current LayerTwo-Labs enforcer.
The two-step deposit that finding forced is the admin dashboard's "Deposit to Thunder" action — see CLASSIC_PAYOUTS.md.