Bitcoin mining infrastructure

simplepool

A single-binary stratum server in pure C11. It hands work to your ASICs, checks every submission itself, submits found blocks, and records the whole thing in a SQLite file you are allowed to read. It runs in two modes — solo, where the miner who finds a block is paid in that block's own coinbase, and pps-classic, where every accepted share earns a fixed, derivable amount paid out over Thunder.

C11 · no runtime dependencies beyond libc, sqlite3, libcurl, hiredis stratum v1 on :3334 SQLite (WAL) ledger MIT

What it is

A stratum server, a share ledger, and a read-only dashboard. That is the whole system.

Miners open a TCP connection to port 3334 and speak stratum v1. simplepool builds block templates from bitcoind's getblocktemplate, hands each connection its own job, re-hashes every submission it receives, and writes each accepted one into data/shares.db. If a submission also clears the network target, it goes straight back out via submitblock.

There is no account system. There is no password — the stratum password field is read and discarded. Your identity on the pool is the payout address you authorize with, which means there is nothing to register, nothing to log into, and nothing the operator can quietly change about who you are.

2payout modes
1binary, no daemon zoo
1writer to the ledger
0accounts to create

Why "share" and not "work unit"

In solo mode a share is not a claim on anything — the block reward goes to whoever finds the block, and shares exist for hashrate estimation and per-rig accountability. The word is kept anyway, deliberately: share is the term every ASIC firmware, monitoring tool and pool dashboard already uses, and the same column and table names carry through unchanged into pps-classic, where shares genuinely are the unit of account. The meaning shifts between modes; the vocabulary does not.

The thing this project is actually about

Auditing your own contribution to a mining pool is normally somewhere between hard and impossible — you are handed a number and asked to trust it. simplepool writes down enough per share that the number can be re-derived from scratch by anyone holding a copy of the database, without trusting the dashboard that reports it. Section 11 is that argument in SQL.

The two modes

One config key — pool_mode — decides the shape of the coinbase, what a stratum username must be, and whether any off-chain accounting happens at all.

pool_mode = solo the default

Every block is paid, on-chain, in its own coinbase, to the miner who found it. Nothing is pooled. If your rig finds the block you get essentially the whole subsidy plus fees; if it doesn't, nobody on this pool earns anything at that height.

Stratum username
your Bitcoin address, bc1q… or base58
Who gets paid
the finder, in the block's coinbase
When
immediately, with the block — no payout worker exists
Variance
all yours
Shares are
a record, not a balance
Needs
a bitcoind. Nothing else.

pool_mode = pps-classic

Every block's coinbase pays a pool-owned BTC wallet. Every accepted share credits your balance at a rate derived from the live block template, whether or not the pool found anything. The operator moves accumulated BTC into a Thunder reserve, and a payout worker drains that reserve to miners.

Stratum username
a bare base58 Thunder address
Who gets paid
every miner, per share
When
daily batch, once your balance clears the minimum
Variance
the pool's
Shares are
the unit of account
Needs
bitcoind, the enforcer, a Thunder node
 solopps-classic
Coinbase outputs miner's address + operator fee pool_btc_address + operator fee
Per-connection coinbase yes — each miner's cb1/cb2 pay that miner no — every miner's coinbase pays the pool
Off-chain accountingnonepps_credits
Pool custodies BTCneveryes, between mining and deposit
Payout assetBTC, on the mainchainBTC on Thunder, a BIP300 sidechain
Payout workernot installedsimplepool-payout.service
Miner's incomelumpy and rare, but completesmooth and proportional
Who eats bad luckthe minerthe pool operator
A third mode existed and was removed

pool_mode = pps put a BIP300 drivechain deposit directly in each coinbase, so the pool would never custody BTC at all. It does not work. Regtest and a live forknet both showed the enforcer does not credit coinbase outputs as deposits: the block confirms, and the sidechain Ctip never moves — the reward is simply stranded. A canonical deposit transaction has to spend real, mature, spendable UTXOs, and a coinbase does not qualify. That is a consensus rule, not a bug, so the mode was deleted rather than patched. pps-classic is what every working drivechain pool converges on instead.

The stack

In solo mode everything to the right of bitcoind is optional. In pps-classic the enforcer and a Thunder node join the picture, because that is where miners actually get paid.

simplepool component topology Miner ASICs connect over stratum to simplepool, which talks to bitcoind for block templates and writes accepted shares into a SQLite file. The dashboard and the Thunder payout worker read that same file; the payout worker also talks to a Thunder node. Miner ASICs stratum v1 simplepool :3334 bitcoind (+ enforcer) work GBT submitblock shares.db SQLite · WAL one writer dashboard read-only · :8081 payout worker pps-classic only Thunder node sidechain
SQLite is the source of truth and simplepool is its only writer; everything downstream reads. In pps-classic the operator also drives BTC → Thunder deposits from the admin dashboard through the enforcer's wallet — the one arrow left off the diagram, because it is a human pressing a button rather than a running data path.

Optionally, setting redis_url mirrors accepted shares, rejects, blocks, tip changes and PPS credits onto Redis pub/sub channels (pool:shares, pool:rejects, pool:blocks, pool:tip, pool:credits). SQLite stays authoritative; the publish is fire-and-forget and a Redis outage cannot cost you a share.

Life of a share

From plugging in an ASIC to a row in the ledger. Identical in both modes except where noted.

  1. miner → pool

    mining.subscribe

    The pool allocates this connection a 4-byte extranonce1 and replies with it. The value comes from an atomic counter XORed with the current millisecond, so two rigs subscribing in the same nanosecond cannot collide, and a rig reconnecting days later after the counter has wrapped still gets something fresh.

  2. miner → pool

    mining.authorize "<address>[.<rig>]"

    The username is parsed as an address and validated on the spot — bech32 or base58check in solo mode, bare base58 Thunder in pps-classic. An invalid address is rejected with a clear error and written to the rejects table rather than silently accepted. The password is discarded.

  3. pool → miner

    mining.set_difficulty + mining.notify

    The connection gets a starting difficulty and the current job. In solo mode the job's cb1/cb2 are rendered against this miner's address, so two rigs on the same pool are working on genuinely different coinbases. The merkle branches, previous hash, nbits and ntime are shared.

  4. pool ↔ bitcoind

    Tip watcher

    A background thread re-fetches getblocktemplate every bitcoind_poll_interval_ms (default 30 s). On a new tip the job is rebuilt and broadcast to every connection with clean_jobs = true.

  5. miner → pool

    mining.submit

    Carries job_id, the miner's extranonce2, ntime, nonce, and the exact rolled version bits. The pool does not take the miner's word for the hash: it reassembles the coinbase from the cached cb1/cb2 and the two extranonces, recomputes the merkle root, rebuilds the 80-byte header, and double-SHA256s it itself.

  6. pool

    Two comparisons, one hash

    The resulting hash is compared against the connection's worker target and against the network target. Above the worker target it is rejected as low difficulty and logged in rejects. Below it, a row lands in shares. Below the network target as well, it is also a block.

  7. pool → bitcoind

    Block submission

    A block-shaped share is serialised in full and pushed via submitblock, then recorded in blocks_found with the height, hash, finder, reward and fee. The same submission counts as a paid share and as a block — one hash, both thresholds.

  8. pool

    Vardiff tick, then the write

    If the vardiff window has elapsed the connection is retargeted and gets a fresh mining.set_difficulty. Writes are batched: shares queue into a lock-free ring and a writer thread commits every commit_window_ms (100 ms) or every commit_max_shares (100), whichever comes first.

One detail that trips people up

A mining.set_difficulty does not invalidate the job you are working on. The difficulty only changes the threshold each submitted share is measured against; the current mining.notify stays valid across it, and the pool does not force a re-notify.

Dividing the search space

The fairness guarantee simplepool makes is narrow and checkable: no two connections are ever searching the same (header, coinbase, nonce) triple.

A block header is 80 bytes, and only three parts of it can vary while you search: the 4-byte nonce, whichever version bits the pool has permitted you to roll, and the merkle_root — which you change indirectly, by changing the coinbase transaction.

The 80-byte header

version4 B · rollable
prev_block_hash32 B · fixed
merkle_root32 B · via coinbase
ntime4 B
nbits4 B · network target
nonce4 B · the sweep

Where the extranonce lives

The coinbase scriptSig is assembled at share-check time and carries both halves of the standard stratum split:

height pushBIP34
coinbase_tage.g. /simplepool/
extranonce14 B · pool assigns
extranonce24 B · miner sweeps

assigned once per connection yours to search

Together those give each connection 264 distinct coinbases before it would need to reconnect for a fresh extranonce1 — effectively unbounded at any real hashrate. Each extranonce2 value yields a distinct coinbase, therefore a distinct coinbase txid, therefore a distinct merkle root, therefore a fresh 232 nonce space to sweep.

Version rolling

If a miner advertises support via mining.configure, the pool negotiates a version-bit mask — currently 0x1fffe000, the 16 bits from position 13 to 28. That multiplies the space behind a single (extranonce1, extranonce2) pair by 216, so one extranonce2 value covers 232 × 216 = 248 ≈ 280 trillion headers.

The pool never re-derives a rolled version on its own. The miner states the exact version it hashed, the pool reconstructs that header and re-hashes it, and any bit flipped outside the mask makes the submission invalid.

Two rigs, one address

Authorizing as bc1q….basement and bc1q….garage gives you two connections, hence two different extranonce1 values, hence no overlapping work — and two separate rows in workers, so the leaderboard and the per-worker drilldown can tell your boxes apart while the dashboard still rolls them up by address.

Difficulty & vardiff

Every share is measured against two thresholds. One decides whether it counts; the other decides whether it is a block.

Worker target

The difficulty the pool is currently holding this connection at, announced with mining.set_difficulty. A hash at or below it is an accepted share. It exists so your rig reports in at a sane rate instead of once a decade.

Network target

The real chain difficulty, straight from the block template. A hash at or below it is a valid block. It is far below any sane worker target, so a block-finding hash necessarily satisfies the share check too.

Both are 256-bit big-endian numbers, and for a hash h:

share accepted  ⇔  h ≤ worker_target
block found     ⇔  h ≤ network_target

What "difficulty 0.016" means

Bitcoin's pdiff-1 target is 0xffff × 2208. A share at difficulty D is one whose hash is below pdiff_1 / D, so given a worker target the difficulty recorded on the share row is simply:

difficulty = pdiff_1_target / worker_target

Worked example, from a real rig

worker_target = 0x000003e7fc18…            (5 leading hex zeros)
              = 0x03e7fc18 × 2^204

difficulty    = (0xffff × 2^208) / (0x03e7fc18 × 2^204)
              = 65535 × 16 / 65407512
              ≈ 0.01603

That is the number stored in shares.difficulty on every row this connection produces, and — in pps-classic — the number your credit is computed from. Hashrate follows from the share rate:

shares_per_second = H / (D × 2^32)

14 shares in a minute at D = 0.016
  → 0.233 shares/s
  → H = 0.233 × 0.016 × 2^32 ≈ 16 MH/s

The dashboard's hashrate column uses exactly this formula over a rolling window (24 h by default), which is why it is an estimate with visible variance rather than a reading off your ASIC.

Vardiff

Each connection is retargeted to hold a chosen share rate — 12 shares per minute by default, roughly one every five seconds. The knobs:

KeyDefaultWhat it does
vardiff_enabled10 pins every connection to initial_diff
vardiff_target_spm12target shares per minute per connection
vardiff_window_sec30how often to retarget
vardiff_min / vardiff_max1 / 1e12clamps
initial_diff1what a connection starts at

Vardiff changes the reporting rate, not your expected earnings. Over any window, difficulty × share count is what you contributed, and holding a rig at a higher difficulty just means fewer, heavier shares carrying the same total.

Solo mode

pool_mode = solo. The whole payout mechanism is the coinbase transaction. There is no ledger of debts, because the pool never owes anyone anything.

The coinbase

Every connection gets a coinbase built against its own payout address, so the block a given rig is hashing on already pays that rig if it lands:

output 0 — the findersubsidy + fees, minus fee_bps
output 1 — operator_addressfee_bps of the reward · default 1%
output 2 — witness commitmentwhen segwit txs are present

With fee_bps = 0 the fee output disappears entirely and the coinbase is a single payout to the miner. The same happens automatically when the computed fee would land below the relay dust threshold (~546 sats): the operator output is dropped rather than made unspendable, and the miner takes the full reward.

What you get, precisely

  • Find a block → your address receives ~99% of subsidy + fees, on-chain, in that block, confirmed the moment the block is.
  • Don't find a block → nothing. Not a smaller amount; nothing. No other miner on the pool earns at that height either.
  • No inter-miner sharing, no difficulty-weighted accounting, no balance, no withdrawal, no minimum, no pool custody at any point.

The shares and workers tables still fill up. They exist so the dashboard can show a leaderboard, a per-rig drilldown, and the pool's block history — and so that the data model is already the one pps-classic needs. A share here is evidence of work, not a claim.

Username

<bitcoin_address>[.<rig_label>]

bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4
bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4.basement-rig
bcrt1q….test.alice                            # regtest

The address is required and must be valid bech32 (P2WPKH) or base58check (P2PKH / P2SH) — it is decoded at authorize time, and a typo is rejected immediately rather than discovered when a block is found and paid to nowhere. The optional rig_label is alphanumeric plus _ and -.

pps-classic mode

pool_mode = pps-classic. Every accepted share earns a fixed amount whether or not anyone finds a block. The pool takes the variance; the miner gets a smooth income stream paid out over Thunder.

The value flow, end to end

  1. on-chain

    The coinbase pays the pool

    Ordinary output to pool_btc_address for the full net-of-operator-fee reward, plus the operator fee output. No drivechain magic — the pool briefly custodies BTC, which is the tradeoff that makes the rest work at all.

  2. per share, automatic

    Each accepted share credits pps_credits

    accrued_sats += floor(difficulty × rate), written by the C proxy and by nothing else. Both the credit and the rate that produced it are stamped onto the share's own row.

  3. operator, manual

    BTC is deposited into the Thunder reserve

    From the admin dashboard: a real CreateDepositTransaction through the enforcer's wallet, spending accumulated pool UTXOs into OP_DRIVECHAIN + OP_RETURN. This does move the Ctip. Each one is recorded in the deposits table with the txid and the Ctip sequence before and after.

  4. payout worker, daily

    The reserve is drained to miners

    Everyone whose accrued − paid clears PAYOUT_MIN_SATS is paid in a single batched Thunder transaction, once every 24 hours. Section 10 is the mechanism.

The rate is derived, not configured

The obvious way to run PPS is to pick a sats-per-difficulty number and hold it. simplepool deliberately doesn't: a fixed rate goes stale the moment difficulty moves, and can quietly invert into paying miners more than each share is worth. Instead the rate is recomputed from every block template:

gross = coinbasevalue / network_difficulty     # fair value of one diff-1 share
rate  = gross × (1 − fee_bps / 10000)         # what the pool actually pays

credit_per_share = floor(difficulty × rate)   # truncated to whole sats

So the rate tracks both block value and difficulty automatically, and fee_bps is the only fee knob in the system. Every rate the pool publishes is appended to rate_history together with the template inputs it came from, which is what makes check 2 possible.

Do not set pps_sats_per_diff

It exists only as an escape hatch. A value there is used verbatim and is treated as already net of fee — so it silently bypasses fee_bps — and it cannot track difficulty. The proxy logs the fee your pinned value actually implies and warns when that disagrees with fee_bps by more than 25 bps. Leave it commented out.

Username

<thunder_base58_address>[.<rig_label>]

JPbJrEKEaA69dAADY2qfW7dfyYQ
JPbJrEKEaA69dAADY2qfW7dfyYQ.shed-01
Bare base58 only

The deposit-format wrapper s9_<base58>_<hex6> — what format-deposit-address hands you — is rejected at authorize time. Thunder's own OP_RETURN parser does not recognise it at the byte level, so a miner who accrued a balance against it would have accrued something unpayable. Failing at connect is the kind alternative.

Where the fee lands

fee_bps is one number applied in up to two places, and whether that is one deduction or two depends entirely on your addresses.

In solo

One place only: the coinbase splits fee_bps to operator_address and the rest to the finder. 100 bps = 1%, capped at 1000 bps = 10%.

In pps-classic

Two places: the coinbase splits fee_bps between operator_address and pool_btc_address, and the PPS rate is reduced by fee_bps before anyone is credited.

Which makes the choice of addresses a real economic decision:

ArrangementEffectConsequence
operator_address == pool_btc_address the coinbase split is a no-op — the pool receives the whole block — and the fee is collected once, via the rate the pool runs with a fee_bps margin over its expected payout. That margin is the buffer that absorbs bad luck.
they differ the operator takes the cut on-chain, per block, before the pool entity sees it the pool entity runs at break-even in expectation with no buffer, while still carrying full PPS variance. A bad run becomes a shortfall.

Both are coherent; neither is a bug. Pick deliberately, and if you pick the second one, know that you have separated who collects the fee from who carries the risk.

Payouts over Thunder

pps-classic only. The design goal is narrow and unglamorous: never pay twice, and never claim to have paid when you haven't.

Once a day, in one transaction

Payouts run as a daily batch. Once every 24 hours, everyone whose accrued − paid clears PAYOUT_MIN_SATS (10 000 sats by default) goes out together in a single Thunder transaction.

Batching is not an optimisation, it is a requirement. Thunder only advances when a mainchain block commits to it, and its wallet cannot spend the change of an unconfirmed transaction — so paying N miners individually would cost N sidechain blocks, and past a handful of miners the queue would drain slower than it fills. The cost of batching is failure isolation: one bad address fails the whole batch. That is an acceptable trade here, because every recipient is an address the proxy already validated at authorize time, and a failed batch credits nobody and strands nobody — the next run simply retries.

Three clocks, not one

The daily cadence governs when a payout starts. It deliberately does not govern what happens to a batch already in flight, because two of the states a run can end in are ruined by a long wait:

After a run that…Next tickWhy
did nothing, or settled cleanlyPAYOUT_INTERVAL_MS — 24 h the ordinary cadence
broadcast a batch, or is still waiting on onePAYOUT_SETTLE_INTERVAL_MS — 30 s nobody in the batch is credited until a tick sees it in a Thunder block, and the stall-recovery nudge only fires from a tick
failed to broadcast, or found the reserve shortPAYOUT_RETRY_INTERVAL_MS — 5 m nothing was sent and nobody was credited, so the run did not happen — it is retried, not skipped to tomorrow
could not determine a settlement5 m, and loudly terminal until a human reconciles it

To pay out early, the admin dashboard has a Trigger payout now button. Restarting the worker also runs one immediately.

paid means mined, not sent

pps_credits.paid_sats moves only when a transaction has actually been observed in a block. Crediting at broadcast was tried and abandoned: a transaction sitting in a mempool has discharged no debt, so counting it as paid makes accrued − paid understate what the pool really owes — measured at 265 BTC for over four hours on a test network — and leaves no way back if the transaction never lands.

Telling "confirmed" from "gone" is the hard part, because Thunder offers no single durable answer. Two sources are consulted and only positive evidence from either is accepted: get_transaction reporting a block hash (authoritative but transient — it reads back as null once the chain moves past it), and the wallet UTXO set containing an outpoint bearing our txid (durable, because Thunder only admits confirmed UTXOs). Absence is never read as confirmation, and never as eviction either: "the node forgot it" and "it confirmed a while ago" look identical from outside, and guessing wrong in one direction pays twice. So unknown stays unknown, payouts halt, and a human is asked.

The at-most-once protocol

  1. write-ahead

    INSERT INTO payouts_in_flight

    One row per worker in the batch, txid = ''. From this moment listDue() skips those workers, so nothing can queue them twice.

  2. network

    Broadcast the batch

    One Thunder transaction for everyone. On failure the rows are removed, paid_sats is untouched, and the next run tries again.

  3. local

    Stamp the txid — and stop

    The rows stay in flight. Nobody is credited here. A broadcast is not a settlement.

  4. a later tick

    Confirmed → one atomic transaction

    paid_sats += for every worker in the batch and the in-flight rows are deleted, together, in a single SQLite transaction. It commits whole or not at all — there is no partial credit across a batch.

The one genuinely ambiguous state is a crash between steps 1 and 2: a broadcast that happened is indistinguishable from one that did not. Those rows are reported by listStuck() at every start and left for an operator to resolve, because the two possibilities demand opposite actions and nothing on the machine can tell them apart.

Auditing every number

The point of the data model. These checks run against a copy of shares.db and consult nothing live — no API, no dashboard, no trust in the operator.

What is written down per share

Each accepted share row carries the difficulty it was measured at, the rate in force when it was accepted (rate_used), and the sats it was credited (credited_sats). Storing the multiplicand alongside the product is the whole trick: the credit can be re-derived years later without knowing what the rate happened to be at the time, and without asking the pool.

Four queries

-- 1. Arithmetic. Every credited share must re-derive from the pair stored
--    on its own row. Nothing current is consulted.
SELECT COUNT(*) FROM shares
 WHERE rate_used > 0
   AND credited_sats <> CAST(difficulty * rate_used AS INTEGER);

-- 2. Provenance. Every rate the pool published must follow from the template
--    inputs recorded beside it. Catches a rate applied consistently but
--    derived wrongly — which (1) cannot see.
SELECT COUNT(*) FROM rate_history
 WHERE ABS(rate_sats_per_diff
       - (block_value_sats * 1.0 / network_difficulty)
         * (1 - fee_bps / 10000.0)) > 1e-9;

-- 3. Linkage. No share may be credited at a rate the pool never published.
SELECT COUNT(*) FROM shares s
 WHERE s.rate_used > 0
   AND s.ts >= (SELECT MIN(ts) FROM rate_history)
   AND NOT EXISTS (SELECT 1 FROM rate_history r
                    WHERE r.rate_sats_per_diff = s.rate_used);

-- 4. Solvency. What the pool mined must cover what it owes.
SELECT (SELECT SUM(reward_sats) + SUM(fee_sats) FROM blocks_found)
     - (SELECT SUM(credited_sats) FROM shares) AS margin_sats;

The first three must return 0. Query 4 should be positive, and close to Σ difficulty × gross × fee_bps/10000 once luck is accounted for — a negative result means the pool cannot pay out of what it has earned, which is the number that actually matters.

Exact equality in check 1 is the right test rather than a tolerance: the proxy is built without -ffast-math, so SQLite reproduces the same IEEE-754 multiply and truncation bit for bit. Shares accepted before rate_used existed carry 0 and are excluded from checks 1 and 3 — their credited_sats is still authoritative, there is simply no stored multiplicand to check it against, and the audit page reports them as unverifiable rather than as failures.

Luck, quantified

SELECT ROUND((SELECT SUM(difficulty) FROM shares)
             / (SELECT network_difficulty FROM pool_meta)) AS expected_blocks,
       (SELECT COUNT(*) FROM blocks_found)                 AS actual_blocks;

Block-withholding audit

A miner can hash honestly, submit every share, and quietly discard the one submission that happens to be a block — collecting PPS credit while contributing nothing. payout/audit.js is a standalone read-only CLI that looks for it: over a window, each worker's expected block count is pool_blocks × (worker_diff / pool_diff), and z = (expected − actual) / √expected. It flags a worker when expected ≥ 5 and z ≥ 3 — about a 1-in-740 false positive rate under honest Poisson sampling. No schema changes; safe to run while the proxy is writing.

Why it can be run by anyone

SQLite runs in WAL mode with exactly one writer. Take a snapshot with sqlite3 shares.db ".backup snap.db" — atomic, and safe while the pool is writing — and every query above works on the copy. A plain cp of a WAL database is not safe; use .backup.

The data model

One SQLite file, data/shares.db, in WAL mode. The proxy is the only writer; the dashboard and the audit tools only read.

TableWritten byWhat it holds
workersproxy one row per address[.rig] seen, with the payout address kept separately so the dashboard can roll up across rigs
sharesproxy one row per accepted share: worker, timestamp, difficulty, hash, is_block, and in pps-classic credited_sats + rate_used
rejectsproxy one row per rejected submission with the reason — bad address, stale job, low difficulty
blocks_foundproxy height, hash, finder, finder address, reward_sats, fee_sats
rate_historyproxy every PPS rate published, with the template inputs it was derived from — the basis of audit check 2
pool_metaproxy the effective rate and network difficulty. The dashboard reads the rate from here rather than from its own config, so an audit can never disagree with the process that did the crediting
templatesproxy one row per materially distinct block template, pruned by templates_retention_days
node_statusproxy backend height and tip, for the dashboard's node card
pps_creditsproxy and payout worker accrued_sats (proxy only, monotonic) and paid_sats (payout worker only, monotonic). Owed = the difference
payouts_in_flightpayout worker the write-ahead log that makes payouts at-most-once
payouts, tx_attemptspayout worker settled payouts, and every transaction attempt with its stage and raw bytes for forensics
depositsdashboard one row per operator-triggered BTC → Thunder deposit, with Ctip sequence before and after
Invariants held by code, not by constraints

accrued_sats and paid_sats must both only ever increase, and each has exactly one writer. A decrease in either means somebody edited the database by hand — which is worth knowing, and is why it is stated here rather than enforced by a trigger that would hide it.

Connect a miner

There is nothing to sign up for. Point the ASIC at the host and put your address in the username field.

Solo

URL       stratum+tcp://pool.example.com:3334
Worker    bc1qw508d6…kv8f3t4.rig-01
Password  (anything — it is discarded)

pps-classic

URL       stratum+tcp://pool.example.com:3334
Worker    JPbJrEKEaA69dAADY2qfW7dfyYQ.rig-01
Password  (anything — it is discarded)

Stratum is raw TCP, not HTTP, so it does not pass through the pool's nginx. Miners connect straight to host:3334; only the dashboard is behind the reverse proxy. If you need TLS on stratum itself, that is an nginx stream {} block or stunnel, not something the pool does for you.

If a connection is refused

SymptomCause
Authorize fails immediately The username isn't a valid address for this mode — a BTC address on a pps-classic pool, a Thunder address on a solo pool, or the s9_…_… deposit wrapper. Check the rejects table for the reason.
Shares rejected as low difficulty Normal in small numbers. Persistent means the rig is ignoring mining.set_difficulty.
Shares rejected as stale share The job expired — a new tip arrived. Expected around block boundaries.
Connects, no work The pool has no template: its bitcoind is unreachable or still syncing.

Configuration

One key = value file, proxy.conf. The keys that change behaviour rather than tuning it:

KeyDefaultMeaning
pool_modesolo solo or pps-classic. Decides the coinbase shape and what a username must be.
operator_address Required. Receives the fee_bps cut. The proxy refuses to start without it.
fee_bps100 Fee in basis points; 100 = 1%, hard cap 1000 = 10%. 0 drops the fee output entirely.
pool_btc_address pps-classic only, and required there: the coinbase pays here.
pps_sats_per_diffunset Leave it unset. See the warning in section 8.
listen_addr / listen_port0.0.0.0 / 3334 Where stratum listens.
bitcoind_url JSON-RPC endpoint for getblocktemplate / submitblock.
bitcoind_user / bitcoind_pass Optional — omit both for an unauthenticated backend and the call goes out with no auth header. Cookie auth is not supported.
bitcoind_poll_interval_ms30000 Template refresh. On a drivechain pool this is also the worst-case delay before a sidechain's BMM request can reach a job — lower it to 5000–10000 if sidechains need to merge-mine reliably.
coinbase_tag/simplepool/ Short string baked into the coinbase scriptSig.
db_path./data/shares.db The ledger.
commit_window_ms / commit_max_shares100 / 100 Write batching — commit on whichever comes first.
templates_retention_days30 How much template history the dashboard keeps. 0 keeps everything.
redis_urlempty Set to mirror events onto Redis pub/sub. Empty disables it.
log_levelinfo debug logs every RPC request and raw response.

The payout worker is configured entirely by environment variables, not by this file — PAYOUT_INTERVAL_MS, PAYOUT_SETTLE_INTERVAL_MS, PAYOUT_MIN_SATS, THUNDER_FROM_ADDRESS and friends. The Thunder reserve address is deliberately not a proxy key: the coinbase never touches Thunder, so only the dashboard and the payout worker have any business knowing it.

Running one

One line on a fresh Ubuntu or Debian server, then a single command for everything after.

curl -fsSL https://raw.githubusercontent.com/LayerTwo-Labs/simplepool/main/scripts/install.sh | sudo bash

The installer downloads the published build for the machine's architecture, verifies it against the release SHA256SUMS, then asks for the pool mode, your bitcoind RPC, your addresses, a dashboard domain and admin password, and whether to set up nginx, TLS and the firewall. It writes proxy.conf, loads the schema, installs three systemd units, and tells you what miners should connect to. Every answer is saved, so re-running it is how you change your mind about any of them. Pass --from-source to clone and compile instead.

simplepoolctl status            # services, ports, versions, ledger totals
simplepoolctl doctor            # binary runs? bitcoind answers? DB writable?
simplepoolctl logs payout -f    # one service, or all of them
sudo simplepoolctl restart proxy
sudo simplepoolctl upgrade      # next release, then restart
sudo simplepoolctl uninstall    # --purge also deletes the ledger

What runs

UnitModeJob
simplepool.serviceboththe stratum proxy — the only thing that is strictly required
simplepool-dashboard.servicebothread-only public stats on :8081, plus /admin behind basic auth
simplepool-payout.servicepps-classicthe daily Thunder payout batch

Which commit is running

The build commit is compiled into the binary, so simplepool --version reports what is actually executing rather than what the source tree next to it currently says. A build from a tree with uncommitted changes says so on its own line, because in that case the commit printed above it does not describe the binary. The dashboard's /api/versions answers the same question for the whole stack — simplepool, the enforcer, Thunder, bitcoind — over plain HTTP with no auth.

What it can't do

Stated plainly, because a pool that only advertises its guarantees is telling you half the story.

  • It cannot prove a miner hashed anything. It can only check the work it was shown. A rig that finds a block and drops it on the floor looks identical to an unlucky one on any individual sample — which is why the withholding audit is statistical, and why it needs an expectation of at least five blocks before it will say anything.
  • In pps-classic the pool custodies BTC. Between mining a block and depositing into Thunder, the reward sits in a pool-controlled wallet. The design that avoided this — a drivechain deposit in the coinbase — does not work at the consensus level. This is a real trust assumption and it is not engineered away.
  • Deposits are manual. An operator presses a button per deposit. If nobody does, the reserve runs dry and payouts skip with a logged warning until someone notices.
  • An ambiguous crash needs a human. A crash between writing the in-flight rows and broadcasting leaves a state where "it was sent" and "it wasn't" are indistinguishable. The worker refuses to guess, halts, and says so.
  • The Thunder payout fee is a flat 100 sats per batch for now, pending observable fee dynamics on that chain.
  • Solo mode is solo. If your rig doesn't find the block, you earn nothing for that height. That is the design, not a shortfall.