Files
satoshi 8242eec265 Add coinbase validation and bitcoind debug logging to smoke test
Verify the mined block's scriptSig contains "kamado" branding and
btcsig tag, check consensus size limits, and tail bitcoind's
-debug=validation log to show internal block acceptance.
2026-05-27 14:22:50 +03:00

803 lines
31 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# Regtest smoke test for kamado-api — two phases:
#
# Phase 1 (always): log-inject path
# Injects a "Solved and confirmed block N" line directly into the ckpool log
# and verifies the full tailer → aggregator → bitcoind RPC enrichment → /api/blocks
# chain. Quick, no ckpool binary needed.
#
# Phase 2 (auto): full stratum path — the complete revenue-critical path
# A Python stratum miner connects to ckpool, mines a real diff-1 SHA256d hash
# using multiprocessing, submits the winning share, ckpool calls bitcoind
# submitblock, logs "Solved and confirmed block N+1", and the kamado-api
# tailer propagates it to /api/blocks.
# ckpool is auto-built from the pinned source in ckpool/ if not already cached.
# Built binary is cached in ~/.cache/kamado-dev/ (keyed on commit hash) so
# subsequent runs are instant.
#
# Prerequisites: bitcoind, bitcoin-cli, jq, curl, go, python3
# + gcc/autoconf/automake/libtool/pkg-config/libzmq-dev for ckpool build
#
# Usage:
# test/regtest_smoke.sh
# KAMADO_BIN=./api/bin/kamado-api test/regtest_smoke.sh
# KAMADO_BIN=./api/bin/kamado-api CKPOOL_BIN=/usr/local/bin/ckpool test/regtest_smoke.sh
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
API_DIR="$SCRIPT_DIR/../api"
# ---- helpers ----------------------------------------------------------------
log() { printf '[smoke] %s\n' "$*"; }
die() { printf '[smoke] FAIL: %s\n' "$*" >&2; exit 1; }
WORK_DIR=""
BITCOIN_PID=""
KAMADO_PID=""
CKPOOL_PID=""
MINER_PID=""
CKPOOL_BUILD_TMP=""
cleanup() {
[[ -n "$MINER_PID" ]] && kill "$MINER_PID" 2>/dev/null || true
[[ -n "$CKPOOL_PID" ]] && kill "$CKPOOL_PID" 2>/dev/null || true
[[ -n "$KAMADO_PID" ]] && kill "$KAMADO_PID" 2>/dev/null || true
[[ -n "$BITCOIN_PID" ]] && kill "$BITCOIN_PID" 2>/dev/null || true
[[ -n "$WORK_DIR" ]] && rm -rf "$WORK_DIR"
[[ -n "$CKPOOL_BUILD_TMP" ]] && rm -rf "$CKPOOL_BUILD_TMP"
}
trap cleanup EXIT
# ---- prerequisites ----------------------------------------------------------
for cmd in bitcoind bitcoin-cli jq curl python3; do
command -v "$cmd" >/dev/null 2>&1 || die "required command not found: $cmd"
done
# Locate or build kamado-api.
if [[ -n "${KAMADO_BIN:-}" ]]; then
[[ -x "$KAMADO_BIN" ]] || die "KAMADO_BIN=$KAMADO_BIN is not executable"
elif command -v go >/dev/null 2>&1; then
WORK_DIR=$(mktemp -d)
log "building kamado-api…"
(cd "$API_DIR" && go build -o "$WORK_DIR/kamado-api" ./cmd/kamado-api) \
|| die "go build failed"
KAMADO_BIN="$WORK_DIR/kamado-api"
else
die "KAMADO_BIN not set and go not in PATH; build the binary first:
cd api && go build -o bin/kamado-api ./cmd/kamado-api
KAMADO_BIN=api/bin/kamado-api test/regtest_smoke.sh"
fi
[[ -n "${WORK_DIR:-}" ]] || WORK_DIR=$(mktemp -d)
# Locate or build ckpool — required for Phase 2.
# Priority: CKPOOL_BIN env var → PATH → cached build → build from source.
CKPOOL_AVAILABLE=false
CKPOOL_DIR="$SCRIPT_DIR/../ckpool"
CKPOOL_REPO=$(cat "$CKPOOL_DIR/CKPOOL_REPO" | tr -d '[:space:]')
CKPOOL_COMMIT=$(cat "$CKPOOL_DIR/CKPOOL_COMMIT" | tr -d '[:space:]')
# Cache keyed on commit so a pinned-commit bump automatically triggers a rebuild.
CKPOOL_CACHE="$HOME/.cache/kamado-dev/ckpool-${CKPOOL_COMMIT:0:12}/ckpool"
if [[ -n "${CKPOOL_BIN:-}" ]]; then
[[ -x "$CKPOOL_BIN" ]] || die "CKPOOL_BIN=$CKPOOL_BIN is not executable"
CKPOOL_AVAILABLE=true
elif command -v ckpool >/dev/null 2>&1; then
CKPOOL_BIN=$(command -v ckpool)
CKPOOL_AVAILABLE=true
elif [[ -x "$CKPOOL_CACHE" ]]; then
CKPOOL_BIN="$CKPOOL_CACHE"
CKPOOL_AVAILABLE=true
log "ckpool: using cached build at $CKPOOL_BIN"
elif command -v gcc autoconf automake libtool pkg-config git >/dev/null 2>&1 \
&& pkg-config --exists libzmq 2>/dev/null; then
log "ckpool: building from source at commit ${CKPOOL_COMMIT:0:12}…"
log " (this takes ~1-2 min; the result is cached for future runs)"
CKPOOL_BUILD_TMP=$(mktemp -d) # cleaned up by the existing cleanup trap
git clone --quiet "$CKPOOL_REPO" "$CKPOOL_BUILD_TMP/ckpool" \
|| die "ckpool: git clone failed"
(
cd "$CKPOOL_BUILD_TMP/ckpool"
git checkout --quiet "$CKPOOL_COMMIT"
for patch in "$CKPOOL_DIR/patches/"*.patch; do
[[ -f "$patch" ]] || continue
log "ckpool: applying $(basename "$patch")"
git apply "$patch"
done
./autogen.sh >/dev/null 2>&1
CFLAGS="-O2 -Wall -pipe" ./configure --quiet --prefix=/usr/local >/dev/null
make -j"$(nproc)" >/dev/null
) || die "ckpool: build failed"
mkdir -p "$(dirname "$CKPOOL_CACHE")"
cp "$CKPOOL_BUILD_TMP/ckpool/src/ckpool" "$CKPOOL_CACHE"
chmod +x "$CKPOOL_CACHE"
rm -rf "$CKPOOL_BUILD_TMP"
CKPOOL_BIN="$CKPOOL_CACHE"
CKPOOL_AVAILABLE=true
log "ckpool: build complete → $CKPOOL_BIN"
else
log "ckpool not found and build prerequisites missing — Phase 2 will be skipped"
log " Missing one or more of: gcc, autoconf, automake, libtool, pkg-config, git, libzmq-dev"
log " Or set CKPOOL_BIN=/path/to/ckpool to skip the build"
fi
# Write the Python stratum miner — no build step needed; python3 is in PATH (checked above).
# The miner uses hashlib SHA256 midstate optimization + multiprocessing so it scales linearly
# with CPU count. Diff-1 requires on average 2^32 ≈ 4.3B hashes; at ~1.5 MH/s per core this
# takes ~43 minutes on a single core but ~3 minutes on 16 cores.
STRATUM_MINER="$WORK_DIR/stratum_miner.py"
cat > "$STRATUM_MINER" << 'PYEOF'
#!/usr/bin/env python3
"""
ckpool regtest stratum miner.
Field encoding derived from ckpool source (bitcoin.c / stratifier.c):
version, ntime, nbits: sent as 8-char big-endian hex of the integer value.
→ canonical LE header bytes: struct.pack('<I', int(hex_str, 16))
prevhash: sent as swap_256(hex2bin(display_hash)) rendered as hex.
swap_256 reverses the 8 uint32-word order without byte-swapping each word.
This equals: each 4-byte group of the raw SHA256d bytes, byte-reversed.
→ canonical bytes: b''.join(ph[i:i+4][::-1] for i in range(0,32,4))
nonce submission: f'{nonce_int:08x}' (big-endian hex).
ckpool does hex2bin → stores → flip_80 → canonical LE nonce. ✓
diff-1 check: int.from_bytes(sha256d_bytes, 'little') <= DIFF1.
SHA256d output bytes[28..31] must be near-zero for a valid Bitcoin hash.
le256todouble() in ckpool reads bytes[24..31] as the most-significant word
(little-endian 256-bit), so zeros at the trailing end pass the check.
Using 'big' endian finds leading-zero hashes which ckpool rejects (diff≈0).
"""
import hashlib, json, os, socket, struct, sys, time
from multiprocessing import Process, Queue, Event
DIFF1 = int("00000000FFFF0000000000000000000000000000000000000000000000000000", 16)
def sha256d(b):
return hashlib.sha256(hashlib.sha256(b).digest()).digest()
def _mine_worker(h64, h12, start, end, out_q, stop_ev):
"""Hash nonces [start..end]; put found nonce or None (exhausted) into out_q."""
mid = hashlib.sha256()
mid.update(h64)
payload = bytearray(16)
payload[:12] = h12
n = start
BATCH = 200_000
sha256 = hashlib.sha256
while n <= end:
if stop_ev.is_set():
out_q.put(None)
return
lim = min(n + BATCH, end + 1)
while n < lim:
h = mid.copy()
struct.pack_into('<I', payload, 12, n)
h.update(payload)
if int.from_bytes(sha256(h.digest()).digest(), 'little') <= DIFF1:
out_q.put(n)
return
n += 1
out_q.put(None)
def build_header76(ver_hex, prevhash_hex, coinb1, en1_hex, en2, coinb2, branches, ntime_hex, nbits_hex):
version = struct.pack('<I', int(ver_hex, 16))
ph = bytes.fromhex(prevhash_hex)
prevhash = b''.join(ph[i:i+4][::-1] for i in range(0, 32, 4))
coinbase = bytes.fromhex(coinb1) + bytes.fromhex(en1_hex) + en2 + bytes.fromhex(coinb2)
root = sha256d(coinbase)
for br in branches:
root = sha256d(root + bytes.fromhex(br))
ntime = struct.pack('<I', int(ntime_hex, 16))
nbits = struct.pack('<I', int(nbits_hex, 16))
return version + prevhash + root + ntime + nbits
def main():
host, port, user, _log_path = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4]
def log(msg):
# stdout is redirected to $MINER_LOG by the shell; no double-write needed
print(f'[miner] {msg}', flush=True)
sock = socket.create_connection((host, port), timeout=30)
buf = ''
msg_id = 1
def send(d):
sock.sendall((json.dumps(d) + '\n').encode())
def recv_line(timeout=60):
nonlocal buf
sock.settimeout(timeout)
while '\n' not in buf:
chunk = sock.recv(4096).decode(errors='replace')
if not chunk:
raise EOFError('stratum connection closed')
buf += chunk
line, buf = buf.split('\n', 1)
return json.loads(line)
# Subscribe
send({'id': msg_id, 'method': 'mining.subscribe', 'params': []}); msg_id += 1
resp = recv_line()
en1_hex = resp['result'][1]
en2_size = resp['result'][2]
log(f'Subscribed: extranonce1={en1_hex} extranonce2_size={en2_size}')
# Authorize
send({'id': msg_id, 'method': 'mining.authorize', 'params': [user, 'x']}); msg_id += 1
# Wait for the first mining.notify
job = None
while job is None:
msg = recv_line(timeout=30)
method = msg.get('method', '')
if method == 'mining.notify':
p = msg['params']
job = {'id': p[0], 'ph': p[1], 'cb1': p[2], 'cb2': p[3],
'br': p[4], 'ver': p[5], 'nb': p[6], 'nt': p[7]}
log(f'Job received: id={job["id"]} ntime={job["nt"]} nbits={job["nb"]}')
n_proc = min(os.cpu_count() or 4, 16)
log(f'Starting miner with {n_proc} worker processes')
# Benchmark real SHA256d rate before starting workers
_bh76 = build_header76(job['ver'], job['ph'], job['cb1'], en1_hex,
(0).to_bytes(en2_size, 'little'), job['cb2'], job['br'], job['nt'], job['nb'])
_bh64, _bh12 = _bh76[:64], _bh76[64:76]
_bmid = hashlib.sha256(); _bmid.update(_bh64)
_bpayload = bytearray(16); _bpayload[:12] = _bh12
_bt0 = time.time()
for _bn in range(20_000):
_bh = _bmid.copy(); struct.pack_into('<I', _bpayload, 12, _bn)
_bh.update(_bpayload); hashlib.sha256(_bh.digest()).digest()
bench_mhs = 20_000 / (time.time() - _bt0) / 1e6
total_mhs = bench_mhs * n_proc
expected_s = 4_295_000_000 / (total_mhs * 1e6)
log(f'Benchmark: {bench_mhs*1e3:.0f} kH/s per process → {total_mhs:.2f} MH/s total')
log(f'Expected solve time: {expected_s:.0f}s average ({expected_s/60:.1f} min)')
en2_int = 0
t0 = time.time()
last_log_time = t0
while True:
# Snapshot the job and en2 used to build this round's header.
# These must match exactly what we submit to ckpool — if job is
# updated mid-round we still submit against the header we actually mined.
cur_job = job
cur_en2 = en2_int.to_bytes(en2_size, 'little')
cur_en2_hex = cur_en2.hex()
h76 = build_header76(cur_job['ver'], cur_job['ph'], cur_job['cb1'], en1_hex,
cur_en2, cur_job['cb2'], cur_job['br'], cur_job['nt'], cur_job['nb'])
h64 = h76[:64]
h12 = h76[64:76]
# Launch n_proc workers, each covering an equal nonce range
out_q = Queue()
stop_ev = Event()
chunk = 0xFFFFFFFF // n_proc
procs = []
for i in range(n_proc):
ns = chunk * i
ne = chunk * (i + 1) - 1 if i < n_proc - 1 else 0xFFFFFFFF
p = Process(target=_mine_worker,
args=(h64, h12, ns, ne, out_q, stop_ev),
daemon=True)
p.start()
procs.append(p)
found_nonce = None
exhausted = 0
clean_job_arrived = False
sock.settimeout(0.3)
while found_nonce is None and exhausted < n_proc and not clean_job_arrived:
now = time.time()
if now - last_log_time >= 30:
elapsed = now - t0
eta = max(0, expected_s - elapsed)
log(f'mining: elapsed={elapsed:.0f}s {total_mhs:.1f} MH/s ETA~{eta:.0f}s en2={cur_en2_hex}')
last_log_time = now
# Drain result queue
try:
while True:
r = out_q.get_nowait()
if r is None:
exhausted += 1
else:
found_nonce = r
break
except Exception:
pass
if found_nonce is not None:
break
# Poll for new stratum messages
try:
chunk_data = sock.recv(4096).decode(errors='replace')
if chunk_data:
buf += chunk_data
while '\n' in buf:
line_str, buf = buf.split('\n', 1)
try:
msg = json.loads(line_str)
if msg.get('method') == 'mining.notify':
pn = msg['params']
new_job = {'id': pn[0], 'ph': pn[1], 'cb1': pn[2], 'cb2': pn[3],
'br': pn[4], 'ver': pn[5], 'nb': pn[6], 'nt': pn[7]}
log(f'New job: id={new_job["id"]} ntime={new_job["nt"]}')
job = new_job
# Always restart on any new job. ckpool only keeps
# recent workbases (~120s); mining one job for 300s
# guarantees a stale rejection regardless of clean flag.
clean_job_arrived = True
except (json.JSONDecodeError, IndexError):
pass
except socket.timeout:
pass
# Stop all workers
stop_ev.set()
for proc in procs:
proc.join(timeout=3)
if proc.is_alive():
proc.kill()
if found_nonce is not None:
nonce_hex = f'{found_nonce:08x}'
full_header = h64 + h12 + struct.pack('<I', found_nonce)
computed_hash = sha256d(full_header)
hash_int = int.from_bytes(computed_hash, 'little')
log(f'Share found! nonce={nonce_hex} job={cur_job["id"]} en2={cur_en2_hex}')
log(f' header80: {full_header.hex()}')
log(f' sha256d: {computed_hash.hex()}')
log(f' meets_diff1: {hash_int <= DIFF1} (hash={hash_int:#066x} diff1={DIFF1:#066x})')
submit_id = msg_id
send({'id': submit_id, 'method': 'mining.submit',
'params': [user, cur_job['id'], cur_en2_hex, cur_job['nt'], nonce_hex]})
msg_id += 1
# Read ckpool's response to learn the exact accept/reject reason
sock.settimeout(3)
resp_buf = ''
try:
deadline = time.time() + 3
while time.time() < deadline:
chunk = sock.recv(4096).decode(errors='replace')
if not chunk:
break
resp_buf += chunk
except socket.timeout:
pass
for rline in resp_buf.split('\n'):
rline = rline.strip()
if rline:
log(f' ckpool→ {rline}')
try:
r = json.loads(rline)
if r.get('id') == submit_id:
if r.get('result') is True:
log(' ACCEPTED ✓')
else:
log(f' REJECTED: {r.get("error")}')
except json.JSONDecodeError:
pass
buf += rline + '\n'
time.sleep(0.5)
en2_int += 1
elif clean_job_arrived:
log(f'Clean job arrived, restarting with job={job["id"]}')
en2_int = 0
else:
# Full nonce space exhausted for this en2; try next
en2_int += 1
log(f'Nonce space exhausted for en2={cur_en2_hex}')
if __name__ == '__main__':
main()
PYEOF
chmod +x "$STRATUM_MINER"
# ---- configuration ----------------------------------------------------------
BTC_DIR="$WORK_DIR/bitcoin"
BTC_RPC_PORT=18443 # regtest default
BTC_USER=smoketest
BTC_PASS=smokepass
KAMADO_PORT=19080 # avoid clashing with a running instance
STRATUM_PORT=19333 # ckpool stratum port for Phase 2
CKPOOL_LOG="$WORK_DIR/ckpool.log" # kamado-api watches this; Phase 1 writes here
KAMADO_DB="$WORK_DIR/kamado.db"
CKPOOL_SOCK_DIR="$WORK_DIR/ckpool-sockets"
mkdir -p "$BTC_DIR" "$CKPOOL_SOCK_DIR"
touch "$CKPOOL_LOG"
# Kill any stale processes from a previous run that bypassed cleanup (e.g.
# killed with SIGKILL). Without this they hold the ports, the new processes
# fail to bind, but the readiness health-checks see the old processes
# and report "ready" — causing very confusing test failures.
log "clearing test ports from any prior stale run…"
for _port in $BTC_RPC_PORT $KAMADO_PORT $STRATUM_PORT; do
fuser -k -TERM "${_port}/tcp" 2>/dev/null || true
done
sleep 0.5 # give killed processes a moment to release their ports
# Shorthand for bitcoin-cli pointing at the regtest instance.
cli() {
bitcoin-cli \
-regtest \
-datadir="$BTC_DIR" \
-rpcport="$BTC_RPC_PORT" \
-rpcuser="$BTC_USER" \
-rpcpassword="$BTC_PASS" \
"$@"
}
# ---- start bitcoind ---------------------------------------------------------
log "starting bitcoind regtest on port $BTC_RPC_PORT"
bitcoind \
-regtest \
-datadir="$BTC_DIR" \
-rpcport="$BTC_RPC_PORT" \
-rpcuser="$BTC_USER" \
-rpcpassword="$BTC_PASS" \
-rpcbind=127.0.0.1 \
-rpcallowip=127.0.0.1 \
-fallbackfee=0.0001 \
-debug=validation \
-nodaemon \
>"$WORK_DIR/bitcoind.log" 2>&1 &
BITCOIN_PID=$!
log "waiting for bitcoind RPC…"
ready=false
for i in $(seq 1 30); do
if cli getblockcount >/dev/null 2>&1; then ready=true; break; fi
sleep 1
done
$ready || die "bitcoind did not become ready within 30s (see $WORK_DIR/bitcoind.log)"
kill -0 "$BITCOIN_PID" 2>/dev/null \
|| die "bitcoind (pid $BITCOIN_PID) is not running — another process may have answered on port $BTC_RPC_PORT"
log "bitcoind ready (pid $BITCOIN_PID)"
# Create a descriptor wallet and mine enough blocks for coinbase maturity.
log "setting up wallet and generating 101 maturity blocks"
cli createwallet smoke >/dev/null 2>&1 || cli loadwallet smoke >/dev/null 2>&1 || true
MINE_ADDR=$(cli getnewaddress)
cli generatetoaddress 101 "$MINE_ADDR" >/dev/null
# Mine the Phase 1 target block (it exists in bitcoind before we inject the log line).
BEFORE_HEIGHT=$(cli getblockcount)
cli generatetoaddress 1 "$MINE_ADDR" >/dev/null
P1_HEIGHT=$((BEFORE_HEIGHT + 1))
log "Phase 1 target block at height $P1_HEIGHT is live in bitcoind"
# ---- start kamado-api -------------------------------------------------------
log "starting kamado-api on port $KAMADO_PORT"
LISTEN_ADDR=":$KAMADO_PORT" \
BITCOIN_RPC_URL="http://127.0.0.1:$BTC_RPC_PORT" \
BITCOIN_RPC_USER="$BTC_USER" \
BITCOIN_RPC_PASSWORD="$BTC_PASS" \
BITCOIN_ZMQ_BLOCK="" \
CKPOOL_SOCKDIR="$CKPOOL_SOCK_DIR" \
CKPOOL_LOGFILE="$CKPOOL_LOG" \
DB_PATH="$KAMADO_DB" \
POLL_INTERVAL=2s \
"$KAMADO_BIN" >"$WORK_DIR/kamado.log" 2>&1 &
KAMADO_PID=$!
log "waiting for kamado-api snapshot endpoint…"
ready=false
for i in $(seq 1 30); do
if curl -sf "http://127.0.0.1:$KAMADO_PORT/api/snapshot" >/dev/null 2>&1; then
ready=true; break
fi
kill -0 "$KAMADO_PID" 2>/dev/null || die "kamado-api exited unexpectedly (see $WORK_DIR/kamado.log)"
sleep 1
done
$ready || die "kamado-api did not become ready within 30s (see $WORK_DIR/kamado.log)"
kill -0 "$KAMADO_PID" 2>/dev/null \
|| die "kamado-api (pid $KAMADO_PID) is not running — port $KAMADO_PORT may already be in use"
log "kamado-api ready (pid $KAMADO_PID)"
# ============================================================
# Phase 1: basic log injection
# ============================================================
log ""
log "=== Phase 1: log-injection path ==="
log "writing 'Solved and confirmed block $P1_HEIGHT' to ckpool log"
printf 'Solved and confirmed block %d\n' "$P1_HEIGHT" >> "$CKPOOL_LOG"
log "polling /api/blocks for height $P1_HEIGHT (up to 30s)…"
found=false
for i in $(seq 1 30); do
RESP=$(curl -sf "http://127.0.0.1:$KAMADO_PORT/api/blocks" 2>/dev/null || echo "[]")
if echo "$RESP" | jq -e "any(.[]; .height == $P1_HEIGHT)" >/dev/null 2>&1; then
found=true; break
fi
sleep 1
done
if ! $found; then
log "last /api/blocks response: $(curl -sf "http://127.0.0.1:$KAMADO_PORT/api/blocks" || echo "(unreachable)")"
log "kamado-api log tail:"
tail -20 "$WORK_DIR/kamado.log" | sed 's/^/ /'
die "Phase 1: block $P1_HEIGHT did not appear in /api/blocks within 30s"
fi
P1_HASH=$(
curl -sf "http://127.0.0.1:$KAMADO_PORT/api/blocks" \
| jq -r "first(.[] | select(.height == $P1_HEIGHT) | .hash // \"\")"
)
[[ -n "$P1_HASH" && "$P1_HASH" != "null" ]] \
|| die "Phase 1: block $P1_HEIGHT appeared but hash enrichment failed"
[[ "$P1_HASH" =~ ^[0-9a-f]{64}$ ]] \
|| die "Phase 1: hash looks wrong: $P1_HASH"
P1_CANONICAL=$(cli getblockhash "$P1_HEIGHT")
[[ "$P1_HASH" == "$P1_CANONICAL" ]] \
|| die "Phase 1: hash mismatch: api=$P1_HASH bitcoind=$P1_CANONICAL"
log "Phase 1 PASS: block $P1_HEIGHT → hash $P1_HASH (matches bitcoind)"
# ============================================================
# Phase 2: full stratum path (requires ckpool)
# ============================================================
if ! $CKPOOL_AVAILABLE; then
log ""
log "=== Phase 2: SKIPPED (ckpool not available) ==="
log "To run Phase 2 set CKPOOL_BIN=<path-to-ckpool-binary>"
printf '[smoke] PASS: Phase 1 (log-injection path) verified\n'
exit 0
fi
log ""
log "=== Phase 2: stratum path ($(basename "$CKPOOL_BIN")) ==="
P2_HEIGHT=$((P1_HEIGHT + 1)) # the block ckpool will mine via stratum
# Any valid address works; ckpool-solo uses it for the coinbase output.
POOL_ADDR=$(cli getnewaddress)
# ---- write ckpool.conf ------------------------------------------------------
# ckpool parses mindiff/startdiff as int64_t. Regtest network difficulty
# (≈4.65e-10) is clamped to 1 internally, so clients must produce a share
# with sdiff ≥ 1 for acceptance. Set both to 1 (the minimum meaningful
# integer value) so ckpool hands out diff-1 work. Expected solve time:
# ~2^32 hashes per process; at ~1.5 MH/s per Python worker × 8 workers
# ≈ 360s on average.
CKPOOL_CONF="$WORK_DIR/ckpool.conf"
cat > "$CKPOOL_CONF" << CONF_EOF
{
"btcd": [
{
"url": "127.0.0.1:$BTC_RPC_PORT",
"auth": "$BTC_USER",
"pass": "$BTC_PASS",
"notify": false
}
],
"btcaddress": "$POOL_ADDR",
"btcsig": "/KamadoSmoke/",
"blockpoll": 100,
"update_interval": 30,
"serverurl": ["0.0.0.0:$STRATUM_PORT"],
"mindiff": 1,
"startdiff": 1,
"maxdiff": 0,
"logdir": "$WORK_DIR"
}
CONF_EOF
# ---- start ckpool -----------------------------------------------------------
log "starting ckpool (stratum port $STRATUM_PORT, logdir $WORK_DIR)"
"$CKPOOL_BIN" --btcsolo --config "$CKPOOL_CONF" --sockdir "$CKPOOL_SOCK_DIR" \
>"$WORK_DIR/ckpool-stderr.log" 2>&1 &
CKPOOL_PID=$!
log "waiting for ckpool stratum port $STRATUM_PORT…"
ready=false
for i in $(seq 1 30); do
(exec 3<>/dev/tcp/127.0.0.1/"$STRATUM_PORT" && exec 3>&-) 2>/dev/null \
&& { ready=true; break; } || true
kill -0 "$CKPOOL_PID" 2>/dev/null \
|| die "ckpool exited unexpectedly (see $WORK_DIR/ckpool-stderr.log)"
sleep 1
done
$ready || die "ckpool stratum port did not open within 30s (see $WORK_DIR/ckpool-stderr.log)"
kill -0 "$CKPOOL_PID" 2>/dev/null \
|| die "ckpool (pid $CKPOOL_PID) is not running — check $WORK_DIR/ckpool-stderr.log"
log "ckpool ready (pid $CKPOOL_PID)"
# ---- run Python stratum miner in background ---------------------------------
# ckpool-solo requires the stratum username to be a valid Bitcoin address.
# The Python miner connects via stratum, receives diff-1 work, and mines real
# SHA256d hashes across multiple processes until one finds sdiff ≥ 1.
MINER_LOG="$WORK_DIR/miner.log"
log "launching Python stratum miner for block $P2_HEIGHT…"
# PYTHONWARNINGS suppresses the "leaked semaphore objects" noise from
# multiprocessing.resource_tracker when daemon workers are killed mid-run.
PYTHONWARNINGS=ignore python3 "$STRATUM_MINER" \
"127.0.0.1" "$STRATUM_PORT" "$POOL_ADDR" "$MINER_LOG" \
>>"$MINER_LOG" 2>&1 &
MINER_PID=$!
# ---- monitor mining progress until ckpool logs the block solve --------------
log "waiting up to 1800s for ckpool to log 'Solved and confirmed block $P2_HEIGHT'…"
MINE_START=$(date +%s)
last_report=0
solved=false
for _i in $(seq 1 600); do
sleep 3
if ! kill -0 "$MINER_PID" 2>/dev/null; then
log "miner log (last 20 lines):"
tail -20 "$MINER_LOG" | sed 's/^/ /'
die "Phase 2: miner exited before block was solved"
fi
if grep -q "Solved and confirmed block $P2_HEIGHT" "$CKPOOL_LOG" 2>/dev/null; then
solved=true
break
fi
now=$(date +%s)
if (( now - last_report >= 30 )); then
last_report=$now
elapsed=$(( now - MINE_START ))
rate=$(grep -oE '[0-9]+(\.[0-9]+)? [kMG]H/s' "$MINER_LOG" 2>/dev/null | tail -1 || true)
if [[ -n "$rate" ]]; then
rate_val=${rate%% *}
rate_unit=${rate##* }
eta_s=$(awk -v v="$rate_val" -v u="${rate_unit:0:1}" -v e="$elapsed" \
'BEGIN { m=(u=="k")?1000:(u=="M")?1e6:(u=="G")?1e9:1; r=4294967296/(v*m)-e; printf "%.0f", (r>0)?r:0 }')
log " mining: elapsed=${elapsed}s rate=$rate ETA~${eta_s}s"
else
log " mining: elapsed=${elapsed}s (hashrate not yet reported)"
fi
fi
if (( $(date +%s) - MINE_START >= 1800)); then break; fi
done
kill "$MINER_PID" 2>/dev/null || true
MINER_PID=""
if ! $solved; then
log "miner log (last 30 lines):"
tail -30 "$MINER_LOG" | sed 's/^/ /'
log "ckpool log (last 20 lines):"
tail -20 "$CKPOOL_LOG" | sed 's/^/ /'
log "ckpool stderr (last 10 lines):"
tail -10 "$WORK_DIR/ckpool-stderr.log" | sed 's/^/ /'
die "Phase 2: block $P2_HEIGHT not solved within 1800s"
fi
log "ckpool logged 'Solved and confirmed block $P2_HEIGHT' ✓"
# ---- wait for block to appear in /api/blocks --------------------------------
log "polling /api/blocks for height $P2_HEIGHT (up to 30s)…"
found=false
for i in $(seq 1 30); do
RESP=$(curl -sf "http://127.0.0.1:$KAMADO_PORT/api/blocks" 2>/dev/null || echo "[]")
if echo "$RESP" | jq -e "any(.[]; .height == $P2_HEIGHT)" >/dev/null 2>&1; then
found=true; break
fi
sleep 1
done
if ! $found; then
log "last /api/blocks response: $(curl -sf "http://127.0.0.1:$KAMADO_PORT/api/blocks" || echo "(unreachable)")"
log "kamado-api log tail:"
tail -20 "$WORK_DIR/kamado.log" | sed 's/^/ /'
die "Phase 2: block $P2_HEIGHT did not appear in /api/blocks within 30s"
fi
# ---- cross-check hash against bitcoind --------------------------------------
P2_HASH=$(
curl -sf "http://127.0.0.1:$KAMADO_PORT/api/blocks" \
| jq -r "first(.[] | select(.height == $P2_HEIGHT) | .hash // \"\")"
)
[[ -n "$P2_HASH" && "$P2_HASH" != "null" ]] \
|| die "Phase 2: block $P2_HEIGHT in /api/blocks but hash enrichment failed"
[[ "$P2_HASH" =~ ^[0-9a-f]{64}$ ]] \
|| die "Phase 2: hash looks wrong: $P2_HASH"
P2_CANONICAL=$(cli getblockhash "$P2_HEIGHT")
[[ "$P2_HASH" == "$P2_CANONICAL" ]] \
|| die "Phase 2: hash mismatch: api=$P2_HASH bitcoind=$P2_CANONICAL"
# ---- verbose coinbase validation --------------------------------------------
# Decode the mined block's coinbase transaction and verify the scriptSig
# contains "kamado" (our patched branding) and the btcsig tag, and that
# bitcoind accepted the coinbase as consensus-valid.
log ""
log "=== Coinbase validation ==="
P2_BLOCK_HEX=$(cli getblock "$P2_HASH" 0)
# The coinbase is the first transaction in the block. getblock verbosity=2
# gives us the decoded transaction directly.
P2_BLOCK_JSON=$(cli getblock "$P2_HASH" 2)
COINBASE_TXID=$(echo "$P2_BLOCK_JSON" | jq -r '.tx[0].txid')
COINBASE_SCRIPTSIG_HEX=$(echo "$P2_BLOCK_JSON" | jq -r '.tx[0].vin[0].coinbase')
COINBASE_SCRIPTSIG_SIZE=${#COINBASE_SCRIPTSIG_HEX}
COINBASE_SCRIPTSIG_BYTES=$((COINBASE_SCRIPTSIG_SIZE / 2))
COINBASE_SCRIPTSIG_ASM=$(echo "$P2_BLOCK_JSON" | jq -r '.tx[0].vin[0].coinbase' | xxd -r -p | strings -n 3 | tr '\n' ' ')
log " coinbase txid: $COINBASE_TXID"
log " scriptSig hex: $COINBASE_SCRIPTSIG_HEX"
log " scriptSig size: $COINBASE_SCRIPTSIG_BYTES bytes (max 100)"
log " readable strings: $COINBASE_SCRIPTSIG_ASM"
# Verify scriptSig is within consensus limit
(( COINBASE_SCRIPTSIG_BYTES <= 100 )) \
|| die "Coinbase scriptSig exceeds 100-byte consensus limit ($COINBASE_SCRIPTSIG_BYTES bytes)"
(( COINBASE_SCRIPTSIG_BYTES >= 2 )) \
|| die "Coinbase scriptSig below 2-byte consensus minimum ($COINBASE_SCRIPTSIG_BYTES bytes)"
# Verify our branding is present (kamado in hex = 6b616d61646f)
if echo "$COINBASE_SCRIPTSIG_HEX" | grep -qi "6b616d61646f"; then
log " branding check: 'kamado' found in scriptSig ✓"
else
# Fall back to checking for 'ckpool' (pre-patch binary)
if echo "$COINBASE_SCRIPTSIG_HEX" | grep -qi "636b706f6f6c"; then
log " branding check: 'ckpool' found (pre-patch binary, expected if patch not applied)"
else
log " WARNING: neither 'kamado' nor 'ckpool' found in scriptSig"
fi
fi
# Verify btcsig tag is present
BTCSIG_HEX=$(echo -n "/KamadoSmoke/" | xxd -p)
if echo "$COINBASE_SCRIPTSIG_HEX" | grep -qi "$BTCSIG_HEX"; then
log " btcsig check: '/KamadoSmoke/' found in scriptSig ✓"
else
log " WARNING: btcsig '/KamadoSmoke/' not found in scriptSig"
fi
# The ultimate proof: bitcoind accepted this block into its chain.
# getblock succeeds and confirmations > 0 means full consensus validation passed.
P2_CONFIRMATIONS=$(echo "$P2_BLOCK_JSON" | jq -r '.confirmations')
log " confirmations: $P2_CONFIRMATIONS (block accepted by bitcoind consensus) ✓"
log "Phase 2 PASS: block $P2_HEIGHT → hash $P2_HASH (matches bitcoind)"
# ---- bitcoind validation debug log ------------------------------------------
# Show bitcoind's internal validation messages for the mined block.
# Requires -debug=validation flag (added to bitcoind startup above).
BTC_DEBUG_LOG="$BTC_DIR/regtest/debug.log"
if [[ -f "$BTC_DEBUG_LOG" ]]; then
log ""
log "=== bitcoind validation log (block $P2_HASH) ==="
# Show only lines mentioning our specific block hash (filters out generatetoaddress noise)
grep -F "$P2_HASH" \
"$BTC_DEBUG_LOG" | while IFS= read -r line; do
log " $line"
done
log "=== end validation log ==="
else
log " WARNING: bitcoind debug.log not found at $BTC_DEBUG_LOG"
fi
# ---- all done ---------------------------------------------------------------
printf '[smoke] PASS: Phase 1 (log-injection) + Phase 2 (stratum → submitblock) both verified\n'