A small, single-binary solo-mining stratum server in pure C11. It accepts
miner connections on TCP :3334, builds block templates via bitcoind's
getblocktemplate, submits found blocks via submitblock, and records every
accepted share into a local SQLite database. A separate Node.js dashboard
reads that file for stats.
Created by Roberto Santacroce — source: https://github.com/rsantacroce/simplepool.
simplepool is the foundation for a PPS (Pay-Per-Share) mining pool that will eventually pay out using Thunder. We've released a solo-mining version alongside what we believe will be a genuinely useful tool for miners.
The current implementation of shares, calculations, and related mechanics exists primarily to establish the data model and architecture that will underpin the future PPS billing system.
A share is simply your submission from the work you've been assigned — it functions as a unit of account. From years of experience mining with pools, we've observed that auditing your own contributions is extremely difficult, often nearly impossible. Miners are forced to trust the pool's reporting, with little ability to independently verify what they're owed. simplepool aims to address this transparency gap. (Hopefully!)
This repository is the solo build — every share lands in the local SQLite store, every accepted block is paid directly in its own coinbase, and there is no off-chain accounting. The PPS build will be a separate service that consumes the same data model; see the roadmap below.
It is a solo pool with direct payouts: every coinbase has two outputs —
the miner who found the block gets the reward (minus a small operator
fee), and the configured operator_address gets the rest (default 1% =
100 basis points, configurable via fee_bps). Each connected miner gets
its own coinbase rendered against the miner's own address; the merkle
branches, prev-hash, ntime, etc. are shared.
There is no PPS, no inter-miner reward sharing, and no
difficulty-weighted accounting. If your miner finds the block, your
address gets ~99% of the subsidy + fees on-chain in the same coinbase
transaction; if it doesn't, nobody on this proxy gets anything for that
height. The shares and workers tables exist purely so the dashboard
can show a leaderboard, per-worker drilldown, and historical "blocks
found by the pool" view.
The codebase, schema, dashboard, and API all call accepted submissions shares — never "work units." That's a deliberate choice we want to hold even though this is currently solo-mode:
- Share is the canonical stratum term every miner knows. ASIC firmware, mining-pool dashboards, monitoring tools, and blog posts all use it. Reusing a different word here would just confuse the audience.
- The same column / table / API names will carry through to the PPS
build, where shares are the unit of account that gets billed.
Renaming
shares→work_unitsnow and back again later would churn schema, queries, EJS templates, and any external consumer. - The meaning shift between solo and PPS is semantic, not lexical. We surface it with an explanatory banner on the dashboard and with the project blurb above, rather than by renaming things.
In this solo build, a share is an accepted Proof-of-Work submission below the connection's worker target. It is not a payout claim and does not accrue a balance — it exists for hashrate estimation, per-rig accountability, and as the data primitive the upcoming PPS billing engine will consume.
sequenceDiagram
autonumber
participant M as Miner (ASIC)
participant P as simplepool<br/>(stratum :3334)
participant B as bitcoind<br/>(JSON-RPC)
participant S as SQLite<br/>(shares.db)
participant D as Dashboard<br/>(read-only)
Note over P,B: startup — fetch initial template
P->>B: getblocktemplate
B-->>P: height, prev_hash, txs, network_target, value_sats
P->>P: build initial stratum_job_t (merkle branches, nbits, ntime)
Note over M,P: per-connection setup
M->>P: TCP connect :3334
M->>P: mining.subscribe
P-->>M: extranonce1 (4B, per-conn), en2_size
M->>P: mining.authorize "<bc1q…>[.rig_label]"
P->>P: validate bech32/base58 → cache payout_address<br/>arm vardiff window
P-->>M: result: true
P-->>M: mining.set_difficulty (initial_diff)
P-->>M: mining.notify (current job, clean=true)<br/>cb1 / cb2 rendered against THIS miner's address
Note over P,B: background tip watcher
loop every bitcoind_poll_interval_ms
P->>B: getblocktemplate
B-->>P: template
alt new tip
P->>P: rebuild stratum_job_t
P-->>M: mining.notify (new job, clean=true) — broadcast to all conns
end
end
Note over M,P: hot loop — submit shares
loop until disconnect
M->>P: mining.submit (job_id, en2, ntime, nonce, version_bits)
P->>P: assemble coinbase, recompute merkle root,<br/>hash header, compare to worker target
alt below worker target (good share)
P-->>M: result: true
P->>S: INSERT share (worker_id, ts, diff,<br/>is_block, share_hash)
alt also ≤ network target (BLOCK!)
P->>B: submitblock <full hex>
B-->>P: null / "inconclusive" / reject reason
P->>S: INSERT blocks_found (height, hash,<br/>finder, reward, fee)
end
P->>P: vardiff tick — if window elapsed,<br/>retarget difficulty
opt diff changed
P-->>M: mining.set_difficulty (new diff)
end
else above worker target
P-->>M: error: low difficulty
P->>S: INSERT reject (worker_name, reason)
end
end
Note over D,S: read-only dashboard
D->>S: SELECT … (overview / leaderboard / worker / blocks)
D-->>D: render http://pool.…/
Key invariants the diagram glosses over but the code enforces:
- Per-connection coinbase. Each miner's
cb1/cb2pay that miner's address; the operator fee output is identical across miners. Two ASICs on the same address but different.rig_labelget distinctextranonce1values, so their work never overlaps. - WAL writes are batched.
store_record_shareenqueues into a lock-free ring; the writer thread commits batches everycommit_window_ms(default 100) or everycommit_max_shares(default 100), whichever first. - vardiff doesn't invalidate the active job. A
mining.set_difficultyonly relaxes/tightens the per-share check; the currentmining.notifystays valid against it. We do not force a re-notify on a difficulty change.
The mining.authorize username must start with the miner's Bitcoin
address. Format:
<bitcoin_address>[.<rig_label>]
bitcoin_addressis required and must be a valid bech32 (P2WPKH) or base58check (P2PKH / P2SH) address. It is parsed at authorize time and an invalid address is rejected with a clear error and logged in therejectstable.rig_labelis optional and lets a single miner (same address) have multiple rigs in the leaderboard as separate rows. Use anything alphanumeric plus_-.- The password is discarded entirely. There are no accounts and no auth.
Examples: bc1qabc…, bc1qabc….basement-rig, bcrt1q…test.alice.
This is a sibling project to the Rust mining pool that lives elsewhere in
this same monorepo. The two share nothing in code or goals: the Rust pool is
a production-style PPS pool with payouts; simplepool is intentionally minimal
and exists for solo mining + observability only.
Status: wired. The main binary loads config, connects to bitcoind,
opens the SQLite store, builds an initial job from getblocktemplate,
serves stratum on the configured port, and watches for new tips on a
background thread.
Dependencies: sqlite3, libcurl, pthread, plus a C11 compiler.
macOS:
brew install sqlite curl
make
Debian / Ubuntu:
sudo apt install build-essential libsqlite3-dev libcurl4-openssl-dev
make
The binary lands at build/simplepool.
cp proxy.conf.example proxy.conf
# edit proxy.conf
./build/simplepool proxy.conf
Initialise the SQLite database from the shipped schema:
mkdir -p data
sqlite3 data/shares.db < schema.sql
The proxy is the only writer. The database lives at data/shares.db and
runs in WAL mode, so a read-only consumer cannot block writes or corrupt
the file.
For the dashboard we still recommend pointing it at a separate snapshot file rather than the live DB. This isolates the dashboard's query load from the proxy's writer and means a future code change on the dashboard side can never accidentally open the live file read-write.
Use SQLite's online backup — it is atomic and safe to run while the proxy
is writing. A plain cp of a WAL'd database is not safe; always use
.backup:
# one-shot
sqlite3 data/shares.db ".backup data/shares.snapshot.db"
Run it on a timer (cron / systemd-timer / launchd), e.g. every minute:
* * * * * sqlite3 /path/to/data/shares.db ".backup /path/to/data/shares.snapshot.db"
The dashboard reads data/shares.db (the live DB) by default. Point it
at a snapshot via PROXY_DB_PATH if you want — see
dashboard/README.md.
There's a one-shot deploy script that brings a fresh Ubuntu 24.04 box from nothing to fully serving stratum + dashboard behind nginx. It is idempotent: re-run it after every code change.
./scripts/deploy-to-server.sh \
--host user@host \
--root /home/user/simplepool \
--hostname pool.example.com \
--ssh-key ~/.ssh/id_yourkey
What the script does, end to end:
git fetch && git reset --hard origin/mainon the remote checkout.apt-get installbuild deps + nodejs + sqlite3 + nginx + ufw.makethe C proxy.npm installindashboard/.- Initialise
data/shares.dbfromschema.sqlif missing. - Run the one-shot ms→seconds timestamp migration (idempotent — it
only updates rows where
ts > 10^10, which can only be milliseconds). - Render the two systemd unit templates in
deploy/systemd/with the right user / root path, install to/etc/systemd/system/,enable --nowboth. - Drop the nginx vhost from
deploy/nginx/intosites-available, symlink tosites-enabled,nginx -t && reload. Open ports 80 / 443 / 3334 viaufwif active.
Files in deploy/ are templates with @USER@ and @ROOT@
placeholders the script substitutes — feel free to hand-install them if
you want to do the steps yourself.
Stratum is raw TCP, not HTTP, so it does not go through nginx by
default. Miners connect directly to host:3334. Point your stratum
hostname (e.g. stratum.example.com) at the box's IP via DNS; if you
ever need TLS for stratum, you'd add a stream { ... } block to nginx
or use stunnel.
sudo systemctl status simplepool simplepool-dashboard nginx
sudo journalctl -u simplepool -f # stratum log
sudo journalctl -u simplepool-dashboard -f # dashboard log
sudo systemctl restart simplepool # after pulling new code
To pull edits made directly on a server back into a local checkout (so
you can commit + push from here), use
scripts/sync-from-server.sh.
operator_address = bc1q... # required: recipient of the fee_bps cut
fee_bps = 100 # 100 = 1%; valid range 0..1000 (max 10%)
coinbase_tag = /simplepool/ # short string baked into the coinbase scriptSig
fee_bps = 0 disables the fee output (single-payout coinbase, all to
the miner). If the computed fee would be below the relay dust threshold
(~546 sats) the operator output is dropped automatically and the miner
gets the full reward.
One accepted share = one row in the shares table, tagged with the
worker_id resolved from the (sanitized) stratum username — which now
encodes the miner's payout address. The workers row stores
payout_address separately so the dashboard can also roll up by
address across multiple rigs.
If a share also satisfies the network target, it is additionally
recorded in blocks_found with height, hash, finder_id,
finder_address, reward_sats (paid to the miner), and fee_sats
(paid to operator_address). The matching shares row has
is_block = 1 and the block hash.
The repo ships a best-effort integration test that exercises the proxy
end-to-end against a regtest bitcoind:
# bitcoind must already be running with -regtest, RPC on 127.0.0.1:18443,
# user/password "drivepool"/"drivepool" (or override with env vars).
chmod +x tests/test_integration.sh
./tests/test_integration.sh
The script:
- Skips with exit 0 if
bitcoin-cli,nc, orsqlite3are missing, or if no regtest node is reachable. - Mines 101 blocks if needed so templates are non-empty.
- Writes
tests/integration.proxy.confand starts./build/simplepoolon127.0.0.1:13334with the DB at/tmp/simplepool-int.db. - Sends a tiny
mining.subscribe/mining.authorize/ stalemining.submitsequence overnc, thenSIGINTs the proxy. - Asserts that
workershas at least one row,workers.payout_addressis populated, andrejectshas at least one row.
For the broader stack flow (Docker compose, Rust pool, dashboard) see
../docs/TESTING.md.
Makefile # build / clean / test / format / install
schema.sql # SQLite schema (WAL, 4 tables — workers, shares,
# rejects, blocks_found)
proxy.conf.example # key = value config
src/
main.c # entry point: config + bitcoind + store + stratum + tip watcher
config.{c,h} # tiny key=value config parser
coinbase.{c,h} # BIP34 coinbase tx builder; bech32 + base58check decoders
log.{c,h} # tiny pthread-safe stderr logger
share.{c,h} # share-validation math
sha256.{c,h} # vendored SHA-256
stratum.{c,h} # stratum v1 server
store.{c,h} # SQLite writer with batching
bitcoind.{c,h} # libcurl-based JSON-RPC client
cjson/ # vendored cJSON (MIT) — see src/cjson/README.md
include/ # public headers (empty for now)
tests/ # unit tests + integration shell script
deploy/ # systemd unit templates + nginx vhost templates
scripts/ # deploy + sync helpers
dashboard/ # Node/Express read-only stats UI
The solo build is intentionally minimal; the items below extend it
toward the full simplepool PPS pool without changing the share/block data
model that already lives in schema.sql.
- Move persistence behind Redis. Add a Redis-backed write path alongside the SQLite store so the hot share queue isn't bound to a single-writer file. SQLite stays as the durable archive; Redis absorbs the high-frequency writes and makes the share stream consumable by other services in real time.
- PPS billing as a separate, non-blocking service. Run the Pay-Per-Share build on its own port / instance. The billing engine consumes the share stream (Redis) and settles payouts over Thunder. Strict separation: a billing outage must never block the stratum proxy from accepting work or submitting blocks.
- Miner registration for the PPS pool. Endpoint + flow for miners to register a payout address, a withdrawal threshold, and any per-account settings the PPS engine needs. The solo build doesn't need this — solo miners are identified by the address embedded in the stratum username — but PPS does.
- Status and observability. Expose Prometheus-style metrics
(
/metrics), structured logs, and per-connection health for both the proxy and the billing service. The goal is for any miner to be able to audit their own contribution end to end without having to trust an opaque "pool dashboard." - Richer dashboard metrics. Build on the current overview / per- worker / blocks pages with per-rig hashrate variance, expected-vs- observed payouts, network-difficulty overlays, and historical charts that go beyond the rolling 24-hour window.
- Decouple the dashboard from the live database. Have the dashboard read its own derived store (a Redis replica or a periodic materialised view) rather than the proxy's primary SQLite file. That keeps the dashboard's read pattern from ever touching the hot write path.
Roberto Santacroce — https://github.com/rsantacroce/simplepool
Issues, pull requests, and notes from miners running this in the wild are all welcome.
simplepool is released under the MIT License, © 2026 Roberto
Santacroce (see LICENSE).
The vendored cJSON code in src/cjson/ is also MIT-licensed (see
src/cjson/README.md).