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 five modes, which differ in who carries the variance and who holds the money in between — solo, where the miner who finds a block is paid in that block's own coinbase; pps-classic, where every accepted share earns a fixed, derivable amount and the operator absorbs the difference out of a reserve; pplns-thunder / pplns-btc, where a matured block is divided among the shares that produced it, so the pool never owes more than it has just been paid; and pplns-coinbase, the same accounting with the custody taken out — the block's own coinbase pays the whole window directly.

C11 · no runtime dependencies beyond libc, sqlite3, libcurl, hiredis stratum v1 on :3334 (+ up to 7 ports) 5 payout modes 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 carries no secret; the only thing read from it is an optional d=<n> difficulty hint. 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.

5payout 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 five modes

One config key — pool_mode — decides the shape of the coinbase, what a stratum username must be, when any off-chain balance moves, and therefore who is exposed when the pool has a bad month.

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

pool_mode = pplns-thunder

Every block's coinbase pays a pool-owned BTC wallet, as in pps-classic — but nothing is credited when a share arrives. A block that has matured 100 blocks deep is divided among the shares that produced it, and each miner is credited its proportion of the reward plus the fees, net of the operator fee.

Stratum username
a bare base58 Thunder address
Who gets paid
everyone in the window of a block actually found
When
on maturity, then the same daily payout batch
Variance
the miners'
Shares are
a claim on blocks the pool finds
Needs
bitcoind, the enforcer, a Thunder node

pool_mode = pplns-btc

The same accounting, paid on the mainchain instead. There is no sidechain in it at all: the payout worker asks the enforcer's own wallet to send, so the pool holds no keys and builds no transactions.

Stratum username
your Bitcoin address, bc1q… or base58
Who gets paid
everyone in the window of a block actually found
When
on maturity, then the same daily payout batch
Variance
the miners'
Shares are
a claim on blocks the pool finds
Needs
bitcoind, and the enforcer with --enable-wallet

pool_mode = pplns-coinbase

The same accounting again, with the custody taken out. There is no pool wallet, no payout worker, no ledger row and no maturity wait: the block's own coinbase pays the whole window directly, one output per miner, largest claim first. A reorged block simply never paid, so there is nothing to claw back.

The window is snapshotted onto the job when the template is built, so the coinbase pays the work that exists now. On a drivechain the coinbase comes from the enforcer and its BIP300/301 commitment OP_RETURNs are preserved byte-for-byte — only the enforcer's own reward output is replaced, by the window.

Stratum username
your Bitcoin address, bc1q… or base58
Who gets paid
everyone in the window who clears the payout floor
When
in the block itself — there is no "when"
Variance
the miners'
Shares are
a claim on the next block this pool finds
Needs
bitcoind, or the enforcer for a drivechain
pplns-coinbase pays what one block cannot fit to the other miners

A coinbase is a fixed budget of bytes, and every payout spends some of it. Two limits follow: coinbase_max_bytes (default 1000) budgets the whole serialized coinbase, commitments included, because that is what a rented-hashrate marketplace measures when it refuses a job as oversized — settable per listener, since the ceiling only binds on the port the rented hashrate connects to. And pplns_payout_floor_sats (default 546, the dust limit) is the least a claim must be worth to get an output at all.

A claim that clears neither is shared out among the miners that block could pay — never given to the operator, which still takes only its fee. The block pays out to the satoshi and the pool holds nothing.

Being small therefore costs you frequency, not money. A miner's share of the window tracks its hashrate, so without help the largest claims would take the same slots in every block and the same addresses would never be paid at all. A quarter of each coinbase's slots are reserved for whoever has waited longest, tracked as a signed fraction of one block reward per worker that sums to zero. That is not a balance: nothing is withheld from a coinbase and released later, and deleting the table would cost nobody a payment — the pool would just forget whose turn it was.

The floor is stated at startup, per template, per block, and on the dashboard before a miner connects, because the operator's log is the one place the miner it affects cannot look.

If the pool cannot measure the window, it publishes no job at all. The window is read over a bounded walk of the shares table; when that walk cannot prove it covered the configured window — an IO error, a lock held past its timeout — it errors rather than returning a short answer, and the template is held back. Miners keep working the last job until it recovers. That costs hashrate on a new tip and is still the only safe direction: here the window is rendered into a coinbase and published, so a wrong one is mined, irreversible, and invisible afterwards. pplns window walk did not cover … in the log is that guard firing, not a crash.

This was the other way round, and measurement changed it

Until #76 a dropped claim rode on the operator's output, defended as a dust policy. With 100 miners on a 1/n hashrate spread and the default budget, 28 were paid, 72 were cut by the byte cap and none by the dust floor, and the operator received 25% of the block on a 1% fee. The take also rose as the coinbase shrank — 46% at 400 bytes against 2% at 3000 — so starving your own miners was the revenue-maximising move. Neither the dust framing nor the incentive survived contact with the numbers.

 solopps-classicpplns-thunderpplns-btcpplns-coinbase
Coinbase outputs miner's address + operator fee pool_btc_address + operator fee pool_btc_address + operator fee one per miner in the window + operator
Per-connection coinbase yes — each miner's cb1/cb2 pay that miner no — every miner's coinbase pays the pool no — every miner's coinbase pays the pool no — every miner's coinbase pays the whole window
Stratum usernameBitcoin addressThunder address Thunder addressBitcoin addressBitcoin address
Off-chain accountingnonepps_credits pps_credits, same table none — the block is the ledger
When a balance movesnever — the coinbase is the payment as each share arrives when a block matures, 100 deep never — the coinbase is the payment
Paid for work that found nothingnoyes no
Transaction fees sharedyes, to the finderno — subsidy-derived rate yes, to the window
Pool custodies BTCneveryes, between mining and deposit yes, between mining and deposityes, in the enforcer wallet never
Operator reserve needednoneyes — measured in block rewards none
Payout assetBTC, on the mainchainBTC on Thunder, a BIP300 sidechain BTC on ThunderBTC, on the mainchain BTC, on the mainchain
Payout workernot installedsimplepool-payout.service simplepool-payout.service same, with PAYOUT_RAIL=btc not installed
A claim one block cannot paycannot arise accrues until the payout worker can batch it shared out among the miners that block could pay; you go first in the queue for the next one
Miner's incomelumpy and rare, but completesmooth and proportional proportional, but only when the pool finds a block the same, above the payout floor — nothing below it
Who eats bad luckthe minerthe pool operator the miners, together
A sixth 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

How much of this you run depends on the mode. In solo and pplns-coinbase everything to the right of bitcoind is optional — the coinbase is the payment, so there is no worker and no wallet. pps-classic and pplns-thunder add the payout worker and a Thunder node, because that is where miners actually get paid; pplns-btc adds the worker but pays on L1 through the enforcer's wallet instead, so no Thunder node appears at all.

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 3 of 5 modes 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.

The proxy takes templates from the enforcer's template server, never from bitcoind directly: only the enforcer's carry the BIP300/301 commitments a sidechain needs. It long-polls (BIP22), so a new tip reaches miners as soon as the enforcer has it rather than on the next poll.

Optionally, the slipstream service sits beside the proxy and takes transactions from anyone. It hands them to the pool's bitcoind; the enforcer's template mempool mirrors that node's, so they reach the pool's templates without any change to the enforcer.

Life of a share

From plugging in an ASIC to a row in the ledger. Identical in every mode except where noted.

  1. miner → pool

    mining.subscribe

    The pool allocates this connection a 4-byte extranonce1 and replies with it. The value is the next tick of a server-wide atomic counter, seeded from the clock when the process starts — so it is unique across every live connection until the counter wraps at 232 subscribes, and unpredictable across restarts. It is deliberately not mixed with the clock again per subscribe: an earlier version XORed it with the current millisecond, and that collides whenever the two advance in step — which is exactly what a rig opening several connections at once does. Identical extranonce1 means identical coinbases, identical headers and the same share found twice. A server-wide dedupe on the header hash now backs this up regardless.

  2. miner → pool

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

    The username is parsed as an address and validated on the spot — bech32 or base58check where the rail is Bitcoin (solo, pplns-btc), bare base58 Thunder where it is Thunder (pps-classic, pplns-thunder). An invalid address is rejected with a clear error and written to the rejects table rather than silently accepted. The password is ignored except for one optional hint, d=<n>, which asks for a difficulty floor (see the protocol reference).

  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). The job is rebuilt on a new tip, and also on a timer to pick up a fresher ntime and newly arrived transactions. Only the tip change carries clean_jobs = true: it is an instruction to throw away work in flight, and it is true only when the chain has moved under the miner. The periodic refresh sends clean_jobs = false, and the pool goes on accepting submits against the older job out of its retention ring.

  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.

    Three things are checked before that work is spent: the extranonce2 is the width the pool advertised, the ntime is inside the window the chain would accept (see Rolling ntime), and the connection is under its submit ceiling. Each of those describes a header that could never become a block, so validating one is effort spent to reach the same answer more slowly.

  6. pool

    Two comparisons, one hash

    The resulting hash is compared against the worker target — the difficulty this job went out under, not whatever the connection has drifted to since — 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. What is recorded is a block candidate: if the node refuses the submission the row is written rejected with its reason, and if it is accepted the row is pending until the block is verified to be in the chain — a later reorg moves it to orphaned. Only confirmed is counted, and only it pays the pool. The share itself is unaffected: it met the thresholds it met, and in pps-classic the pool absorbs the variance either way.

  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
extranonce28 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.

extranonce2 is 8 bytes rather than the classic 4, and the reason is not search space — 4 bytes already outruns any hashrate. It is so that a stratum proxy in front of the pool can subdivide the field, taking the high bytes as a downstream-miner id and passing the low bytes down as that miner's own extranonce2. At 4 bytes a proxy spending 3 on addressing leaves its miners a single byte, which some firmware refuses to run with; at 8 it can spend 3 and still hand down the conventional 4.

The width is not advisory. cb1 ends with the scriptSig length varint, fixed at render time from the two extranonce sizes, so an extranonce2 of any other width yields a coinbase whose declared length disagrees with its contents — an invalid transaction that still hashes like a valid one. The pool rejects such submissions (wrong extranonce2 size) rather than credit a share for work that could never become a block.

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.

Rolling ntime

A miner may also advance the header's ntime while it holds a job, which multiplies its space again for free. Nothing has to be negotiated for this: the pool takes the timestamp the miner submits, puts it in the header verbatim, and treats every distinct value as a distinct share.

It is bounded, though loosely, and the bound exists for the chain's sake rather than the pool's. Consensus refuses a block whose timestamp is more than two hours ahead of network-adjusted time — but such a header still hashes, still clears a share target, and still looks like perfectly good work here. Without a check the pool would credit it, and if it happened to beat the network target, assemble a block that submitblock then throws out: a solved block lost with nothing but a warning in the log to show for it.

So a submitted ntime must fall within −600 s to +7200 s of the value its own job went out with. Measuring from the job rather than the wall clock keeps that conservative: a job only gets older while the miner holds it, so anything inside this window is inside the consensus window too. The backward tolerance is there because a stratum proxy that rewrites the field, or a rig with a skewed clock, can land slightly behind — that is honest work and rejecting it would cost the miner shares. Both ends are deliberately loose. This is here to catch a broken client, not to police timestamps.

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.

One refinement: the divisor is the span the shares actually cover — first share in the window to now — not the nominal width of the window. Dividing by time nothing was mined in reports a rate nobody ran at, and it goes wrong at exactly the moment someone is most likely to be looking. A pool eleven hours old reads half its true rate against a 24 h window. A rig ten minutes into a rented contract reads 1⁄144 of what it is doing, so the arrival an operator most wants to see is the one the leaderboard flattens into noise. Both heal on their own as the window fills, which is why it survived so long: by the time anyone doubts the number, it is right again.

The span is clamped at both ends. Never longer than the nominal window — a share selected by ts >= now − windowSec cannot be older than it. Never shorter than a minute, because the span of a single share a few seconds old tends to zero and would turn one lucky submit into a gigahash spike. With no shares at all it falls back to the nominal window, so an idle pool divides zero by 24 h and reports zero rather than a clamped fraction of nothing.

A submit is judged at its own job's difficulty

A mining.set_difficulty takes effect on the next job the miner is notified of, not the one already in its hands. So every share for a job the miner already holds was mined against the difficulty that job went out under, and on a slow chain those keep arriving long after the retarget that changed the connection.

The pool records the difficulty each job was notified under, per connection, and judges the submit against that. A share is credited at the difficulty it was judged under — never at one it did not actually meet.

Why not just remember the previous value

Because vardiff can retarget more than once while a miner still holds one job, and a single remembered value is gone after the second. Judging against the job itself needs no time limit either: a job the retention ring has dropped cannot be submitted against at all, so the job's own lifetime is the bound.

One difficulty cannot serve everyone

A connection's share rate is its hashrate divided by the difficulty it was assigned. For a home ASIC that wants to be low enough to report regularly. For rented hashrate it cannot be: a marketplace aggregates a whole fleet behind a single connection, so 1 PH/s at difficulty 1024 is about 227 shares per second down one socket. The marketplaces know this and refuse to deliver below their own floor — Braiins wants at least 1024 and recommends 65536, NiceHash requires 500000.

Vardiff cannot bridge that gap. It moves by at most 4× per window, so climbing from 1 to 65536 takes eight windows — four minutes at the default — and the reject flood on the way there is what gets a rented order cancelled. The miner has to arrive at the right difficulty, which is what a second port is for:

listener = port=3335 min_diff=65536 label=braiins
listener = port=3336 min_diff=500000 label=nicehash

Each listener binds its own port and every connection accepted there starts at that difficulty and never vardiffs below it, while listen_port keeps serving home miners exactly as before. The pool publishes the list, and the dashboard's identity strip names each port with what it is for — nothing about a stratum URL tells a miner which one suits its hardware.

A port above the chain costs blocks

By default share difficulty is never raised above the network difficulty, because a miner filters locally against the stratum target: a share target harder than the network target makes it discard valid blocks before the pool ever sees them. A port configured for 500000 on a chain sitting at 1200 therefore serves 1200 — correctly, and silently.

Silently is the problem. A marketplace measures the difficulty it was actually handed, not the one the port advertises, and cancels the order without the pool ever learning why. So a listener may state min_diff, and that floor is kept: the port serves what it promised even where the chain is easier.

What pays for it is blocks. At 500000 over a chain at 1200 the miners on that port discard roughly 416 of every 417 solutions they find. That is a real trade and it is confined to the ports that ask for it — a listener without min_diff, and listen_port itself, are clamped exactly as before. The pool warns at startup for every port in that position and the dashboard reports it too: the “Stratum ports can hold their difficulty” health check names both numbers, and says which of the two things is happening.

The ceiling on submissions

A port is a convention, not an enforcement: nothing stops a fleet arriving on the home-miner port anyway, and 1 PH/s against difficulty 1 is roughly 232,000 submits per second on one connection. Validating one costs about 9 microseconds, so that single connection would ask for more than two cores — and every share it landed would pass through the write ring, which drops events once full. Work a miner was told was accepted, and that no query can afterwards see.

max_submits_per_sec (default 20000, per connection) bounds it. Past the ceiling a submit is refused before its parameters are even read, so a flood costs a reply rather than a full validation, and nothing reaches the ledger. Refusing is also the honest answer: the miner learns its difficulty is wrong, where accepting the work and then losing it in a full ring would tell it everything is fine.

The default sits far above anything a correctly configured miner reaches. With vardiff on, the steady state is vardiff_target_spm — 0.2/s at the default — for a connection of any size, because that is what vardiff converges on. The largest plausible honest burst, a 1 EH/s order at difficulty 65536 before vardiff has climbed, is about 3,500/s.

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 for listen_port; a listener overrides them per port
initial_diff1what a connection on listen_port starts at
listener … min_diffunsetthe floor that port promises. Unlike vardiff_min it is kept even where the network difficulty is lower, because a marketplace measures the difficulty on the wire and cancels an order that arrives under what the port advertised. The cost is that miners on that port discard blocks they solved; the pool warns at startup and the dashboard health check reports it. A port that sets no min_diff — including listen_port — is clamped to the network difficulty exactly as before.
idle_timeout_sec600reap a socket that has not authorized
idle_timeout_authorized_sec7200reap an authorized miner. Much longer on purpose: silence between shares is a working miner's normal state, not an idle connection.
max_conns500concurrent stratum connections across every listener; past it, accept closes immediately. Each one costs a thread and an fd.
pps_min_network_difficulty0pps-classic only. Below this network difficulty nothing accrues — see When fair value stops being fair. 0 disables the check, which is only safe on a chain whose difficulty is already calibrated.
pps_refuse_shares_below_min1while accrual is suspended, refuse authorize and reject submits rather than accepting work that will not be credited. Turn off only if the miners are yours.
block_interval_sec600target seconds between blocks. Feeds both the difficulty floor and the automatic issuance ceiling. Change it only for a chain that genuinely retargets to a different interval.
redis_publish_timeout_ms / redis_reconnect_backoff_ms200 / 2000bounds on the optional Redis broadcast. SQLite stays the source of truth, so a slow or absent Redis costs a timeout and nothing else.

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.

When fair value stops being fair

The derived rate is a share's expected value, and that is only correct while the pool's solutions can actually become blocks. A chain accepts one block per interval however fast work arrives at it. So once the pool's own difficulty throughput exceeds a block's worth per interval, the formula is pricing shares against blocks that will never be minted — and it overstates by exactly that ratio.

This is not a rounding error. A 40 TH/s pool on a forknet that began at difficulty 1 accrued 15,561,471 BTC of liability in under four hours, against 943.60 BTC it had actually mined. Nothing was paid out only because the payout worker had not run yet.

Two guards cover it, and they cover each other:

GuardWhat it isBlind spot
pps_min_network_difficulty The operator's floor. Below this network difficulty nothing accrues at all. Works from the very first share. Has to be set, and set correctly — it is 0 by default.
The issuance ceiling Automatic, no configuration. Caps the rate at what the chain can actually mint, measured against the pool's observed difficulty per second. Needs a hashrate measurement, so it cannot cover the first minute after a restart.

Set the floor to the difficulty at which your pool alone would find one block per interval:

pps_min_network_difficulty = hashrate_H/s * block_interval_sec / 2^32

  10 TH/s  →  ~1,400,000        100 TH/s →  ~14,000,000
  40 TH/s  →  ~5,600,000          1 PH/s  →  ~140,000,000

That is a floor, not a target: you share the chain with everyone else mining it, so the genuinely safe difficulty is higher. Leaving it at 0 is only safe on a chain whose difficulty is already calibrated — mainnet, testnet, signet. On a young forknet during its difficulty ramp, 0 is how a pool accrues millions of BTC of liability in minutes.

While the gate is closed, miners are turned away

Accrual being suspended is not a quiet state. With pps_refuse_shares_below_min on (the default), mining.authorize is refused and submits are rejected, with this reason:

pool is not crediting shares right now: network difficulty is
below the minimum this pool will pay PPS at. Point your miner
elsewhere until it retargets.

That is deliberate. The alternative — accepting work and crediting nothing — is a miner mining for free without being told, which is worse than being refused, because from the miner's side it is indistinguishable from working. Turn it off only if the miners are yours and you know they are working for nothing.

The gate applies to pps-classic only, because it is the only mode that prices a share on arrival. Nothing else has an accrual to suspend: solo and pplns-coinbase pay out of the coinbase itself, and the two custodial PPLNS rails value a share in hindsight out of a block actually found — so a chain whose difficulty has collapsed simply produces smaller claims rather than promises the pool cannot keep. The gate never touches any of them, whatever the chain's difficulty is doing.

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.

How each mode pays, step by step

The same five modes as above, drawn as sequences rather than described. What changes between them is when a miner's work turns into money — and, in two of them, whether the pool ever holds that money at all.

solo — paid in the block you found

The coinbase is rendered per connection, so every miner is served a job whose coinbase pays that miner. Finding a block and being paid are the same event; there is nothing after it.

solo mode: the finder is paid in the block it found A miner subscribes, simplepool builds a coinbase paying that miner and hands out work. When the miner finds a block the coinbase already pays it, so no ledger and no payout step exist. MinerASICsimplepool:3334bitcoind+ enforcerBitcoin L1the chain authorize <bitcoin-address>getblocktemplatetemplatebuild coinbase paying THIS minernotify (cb1 / cb2)submit (a block!)submitblockblock acceptedThe miner is already paid — the coinbase is the payment. No ledger, no payout worker, no wait.

pps-classic — paid on arrival, out of a reserve

A share is priced the moment it is accepted, whether or not it ever becomes a block. That is the appeal and the cost: the pool owes money before it has earned any, and the gap has to be funded by an operator reserve measured in block rewards.

pps-classic: every share is priced on arrival, the pool carries the risk Each accepted share is credited immediately at a rate derived from the template. The coinbase pays the pool wallet, and a payout worker settles balances over Thunder on a daily batch. MinerASICsimplepool:3334shares.dbSQLitepayout workersystemdThundersidechain #9 authorize <thunder-address>submit (an ordinary share)price it: rate x difficultycredit NOW, block or notThe pool owes this miner before it has earned anything. That gap is the operator reserve.submit (a block)The coinbase pays the POOL wallet, not the miner.daily: who is owed?one batched transfercredit paid_sats on CONFIRMATION

pplns-thunder / pplns-btc — paid when a block matures

Nothing is promised in advance. A share is a claim on blocks this pool actually finds, so the pool never owes more than it has just been paid and there is no reserve to size. The two rails differ only in where the balance is finally settled, which is what a stratum username has to be.

pplns-thunder and pplns-btc: a matured block is split across the work that found it Shares are recorded but not priced. When a block reaches 100 confirmations it is divided across the window of shares that produced it, and the payout worker settles the resulting balances over Thunder or on Bitcoin L1. MinerASICsimplepool:3334shares.dbSQLitepayout workersystemd submit (an ordinary share)record it — credited 0Nothing is promised. The pool never owes more than it has just been paid.submit (a block)row: hash + window size...100 confirmations later, on a new tip...reconcile: still in the chain?split the block across the windowdaily: who is owed?pay: Thunder, or L1 via the enforcerpaid_sats on confirmation

pplns-coinbase — the same accounting, no custody

The window is snapshotted onto the job when the template is built, so the coinbase carries one output per miner it has room for. No pool wallet, no ledger row, no maturity wait. A coinbase only fits so many payouts, so what one block cannot pay is shared among the miners it could — never the operator — and whoever was left out goes first in the queue for the next block. See the five modes for the policy in full.

pplns-coinbase: the block pays the whole window, directly The window is snapshotted onto the job when the template is built, so the coinbase carries one output per miner it has room for. There is no pool wallet, no ledger and no maturity wait. What one block cannot fit is shared among the miners it could pay, and those left out go first in the queue for the next block. MinerASICsimplepool:3334shares.dbSQLiteBitcoin L1the chain who is in the window NOW?claims + who has waited longestorder: biggest claims, plus reserved slotsA coinbase fits only so many outputs. What it cannot pay is shared among the miners it can — never theoperator, who takes only its fee.notify — pays the whole windowsubmit (a block)submitblockstage who was skipped (sums to zero)Staged, not applied: an orphaned block paid nobody and rotates nobody. The confirmation pass decides.Nobody is owed money — only a turn. Skipped miners go first in the next block.

The payout worker, in the three modes that have one

solo and pplns-coinbase never run this. For the other three it is the same worker and the same protocol whichever rail it is driving; only the client at the end differs. The ordering is the whole design: the write-ahead row goes in before the money moves, and paid_sats is credited only once the transaction confirms.

the payout worker: never pay twice, never claim to have paid A write-ahead in-flight row is written before the transaction is sent, so a crash mid-payout is recoverable; paid_sats is credited only once the transaction confirms. payout workersystemdshares.dbSQLitethe railThunder / L1 who clears PAYOUT_MIN_SATS?write in-flight row FIRSTWritten before the money moves. A crash here is recoverable; the reverse order is not.ONE transaction for the whole batchtxidstore txid against the in-flight row...later ticks, until it confirms...is the txid confirmed yet?NOW credit paid_sats, clear in-flightCrediting on confirmation, not on send, is what makes a lost transaction a retry rather than atheft.
Why the order matters more than the rail

Crediting on send rather than on confirmation would make a dropped transaction indistinguishable from a completed one: the ledger would say paid, the chain would say nothing, and the miner would be out of pocket with no way to demonstrate it. Writing the in-flight row first costs one extra round trip and turns every crash into a retry rather than a loss.

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, and the modes that need none

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

Which modes this applies to

Three of the five. pps-classic and pplns-thunder settle over Thunder, described below; pplns-btc runs the same worker and the same protocol with PAYOUT_RAIL=btc, paying on Bitcoin L1 through the enforcer's own wallet rather than a sidechain.

solo and pplns-coinbase have no payout worker at all — in both, the coinbase is the payment, so there is no balance to hold and nothing to drain. Do not install it for those: it would run, find an empty ledger, pay nobody, and give you a service to monitor and misdiagnose for no reason. The sequence diagrams above show why the step does not exist there.

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.

A batch is only built when the node can settle one

Thunder ≥ 0.17.1 signs and broadcasts inside create_transfer, and that RPC takes one destination. A batch spanning several addresses is therefore paid, in full, to whichever address happened to be listed first. Refusing the result afterwards does not help: by then the money is on the network and in someone else's balance.

This is not hypothetical. It fired twice on 2026-08-24 — 72,598,492 and 82,735,787 sats, each aimed at one address out of two. The second broadcast did the lasting damage: the failure was treated as an ordinary abort, the in-flight rows were dropped, and the ledger kept no record of a live transaction that was sitting in the mempool holding the wallet's only UTXO. Every later tick built a fresh batch against those same inputs and died at the create stage with utxo double spent, which looks like a clean abort. 216 identical failures over 24 hours, 0.88 BTC owed, nobody paid.

So the payout worker now learns what the node can do rather than assuming. A transfer that comes back unsigned proves create_transfer handed back something to splice, and from then on every due worker goes out together. A node that broadcast on create has answered the other way and is never asked again. Unproven is treated as restrictive — one address per transaction — because the cost of guessing wrong is somebody else's balance, and every probe of the question is itself a real transfer.

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.

While any such row exists the tick halts entirely — not just for the workers named in it. That distinction is the second hole the incident above went through. A row still carrying txid = '' is invisible to the settlement path, and excluding only its own workers left every other worker free to start a second payout while a transfer may already be live on those UTXOs. Thunder picks inputs without excluding what its own mempool has spent, so one of the two ends up live and untracked, which is precisely the state that cost a day of payouts.

Halting costs nothing. Accruals keep accumulating in pps_credits, so whoever came due meanwhile is paid on the next tick once the block lands. Which is also why a broadcast batch that has not confirmed should almost never be retracted: it is usually a correct payout waiting for a Thunder block, and the right response is to get a block, not to rewrite the payment. Retract only when the transaction pays the wrong people or the wrong amounts.

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.
--    CONFIRMED ONLY: a blocks_found row is a *candidate* until the chain
--    says otherwise, and a refused or reorged one pays nothing.
SELECT (SELECT COALESCE(SUM(reward_sats),0) + COALESCE(SUM(fee_sats),0)
          FROM blocks_found WHERE status = 'confirmed')
     - (SELECT COALESCE(SUM(credited_sats),0) 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
         WHERE status = 'confirmed')                       AS actual_blocks;

A wide gap on a chain the pool is genuinely hashing is worth chasing before anything else — SELECT status, COUNT(*) FROM blocks_found GROUP BY status says how the candidates settled. rejected means the node refused the submission (its reason is in submit_error), orphaned means it was accepted and then reorged out, and pending means nothing has been able to verify it yet — a normal resting state against a backend that serves only getblocktemplate and submitblock. None of the three earns anything.

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, wrong extranonce2 size. A connection past the submit ceiling is recorded periodically with a count, not once per refusal, so a flood cannot bury the ledger it shares a table with
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, the network difficulty, and the stratum ports the pool listens on with the difficulty policy of each — including promised_min_diff, recorded separately from the rate-loop bound so the dashboard can tell a port holding a floor (and losing blocks for it) from one quietly serving less than it advertises. Those need opposite advice, so one number could not express both. The dashboard reads all of it from here rather than from its own config, so an audit can never disagree with the process that did the crediting — and the port list it shows miners is the one the proxy actually bound
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
pplns_fractionsproxy pplns-coinbase only. A signed fraction of ONE block reward per worker: positive means skipped by a block that had no room and first in the queue for the next, negative means paid early out of somebody else's skipped share. Sums to zero. Not a balance — nothing is held against it, and deleting the table costs nobody a payment, it only forgets whose turn it was
pplns_pending_fractionsproxy pplns-coinbase only. What a FOUND block did to those fractions, held against its hash until the confirmation pass decides. A found block is a candidate: applying it there would rotate a miner down the queue for a payment an orphan never made. Applied at one confirmation and not reversed by a later reorg — that costs a turn out of order, never a satoshi
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.

The slipstream service keeps its own

data/slipstream.db, written only by the slipstream service, which opens shares.db read-only for pool_meta and blocks_found and never writes to it.

TableWhat it holds
slipstream_submissions every POST exactly as it arrived, accepted or refused, with the reason — the record of what was asked of the pool
slipstream_txs one row per accepted tx: fee and the rate it was held to, where it stands now, when it was first and last in a template, the block that mined it and whether that block was the pool's, and the raw tx, so it can be sent again
slipstream_events every status change, append-only, so a tx's history can be read back rather than inferred

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  x   (or d=65536 to ask for a difficulty floor)

pps-classic

URL       stratum+tcp://pool.example.com:3334
Worker    JPbJrEKEaA69dAADY2qfW7dfyYQ.rig-01
Password  x   (or d=65536 to ask for a difficulty floor)

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.

Which port

A pool may serve more than one, each at a different starting difficulty. The dashboard's identity strip lists them with what each is for, because nothing about the URL says which suits what you are pointing at it:

You are connectingUseWhy
One ASIC, or a few on a home network the default port Low starting difficulty, so shares report regularly and vardiff settles quickly.
A rented order, or a proxy aggregating a fleet a high-difficulty port The whole fleet's hashrate arrives on one connection. At a home-miner difficulty that is hundreds of shares per second down a single socket, which ends in a reject flood and a cancelled order.

Before sending a rented order, check what the pool advertises — a marketplace router has to slice extranonce2 per machine, and blocks the target if the field is too narrow to do it:

(echo '{"id":1,"method":"mining.subscribe","params":[]}'; sleep 1) \
  | nc pool.example.com 3334 | head -1 | jq -r '.result[2]'

The answer must be 7 or higher. simplepool advertises 8, on every port.

A port configured with min_diff holds that difficulty even where the chain's own is lower, which is what makes it measurable by a marketplace — and what makes it cost blocks. See A port above the chain costs blocks before advertising one on a low-difficulty chain.

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.
Shares rejected as wrong extranonce2 size The miner ignored the width from mining.subscribe. Not cosmetic — that width is baked into the coinbase, so a submission of any other size describes a transaction that could never become a block.
Shares rejected as ntime out of range The rig rolled ntime more than 7200 s past — or 600 s behind — the value its job went out with. That header is outside what consensus would accept, so a block built on it would be refused by the node. Usually a badly wrong clock on the rig.
Shares rejected as submitting too fast The connection is past max_submits_per_sec. Almost always a fleet on a port whose difficulty is far too low for it — move it to a high-difficulty port. The pool reports this periodically with a count rather than once per refusal.
Connects, no work The pool has no template: its bitcoind is unreachable or still syncing.

What will not disconnect you

A rejected share is a rejected share. None of the reasons above closes the connection, and several things that look like protocol violations are tolerated on purpose, because a pool that hangs up mid-session — no error, no reason — is indistinguishable from one that drops hashrate at random, and that is what gets a pool blacklisted.

The pool receivesWhat happens
A JSON-RPC response object (no method) Ignored, connection untouched. It is a reply to a request this pool never made — firmware and stratum proxies do emit them, and there is nothing wrong with the client for sending one.
A blank line Ignored. The pool asks a miner nothing between shares, so this is the only keepalive available to it.
A method the pool doesn't implement Answered with an error. Naming an unimplemented method still proves the client speaks stratum.
Malformed JSON Answered with an error. Eight consecutive unusable lines closes the connection — a client that cannot manage one intelligible request is not a miner, but it gets cut on evidence rather than on suspicion, and any valid request resets the count.
Nothing at all, for hours Fine once authorized. Silence between shares is a working miner's resting state, so an authorized connection gets idle_timeout_authorized_sec (2 h) rather than the 10 minutes a socket that never authorized gets. TCP keepalive still reaps a peer that has actually gone.
16 KB with no newline Closed, with the reason logged. Every stratum request is orders of magnitude smaller, so the peer is not framing lines at all and there is no resync point to skip to.

The dashboard

Everything the proxy writes down, readable by anyone. The dashboard reads shares.db and never keeps a second copy of the pool's config: the mode, the fee, the rate and the ports it shows are the ones the proxy wrote into pool_meta, so the page cannot disagree with the process that did the work.

Public pages

PathWhat it shows
/hashrate, the leaderboard (per worker and per address), recent blocks, how to connect with each port's difficulty policy, and a card explaining every number on the page for this pool's mode
/worker/:nameone worker: shares, hashrate, credits and payouts
/blocksevery block the pool found, paginated, with its status and what it paid
/templatesthe work being handed out: height, block value, fees, the sidechain commitments it carries, and the template history
/slipstreamonly when slipstream runs: its fees, how to submit, and each recent submission with where it stands
/healththe hard-failure checks — ledger arithmetic, rate, duplicates — with a 503 while any is failing, so an uptime monitor can watch it

JSON, for monitors and other tools

PathWhat it returns
/api/statuseverything at once: the pool's identity and totals, the node, the health checks, and which commit of each component is running. Always 200 — read health.ok
/api/overview, /api/leaderboard, /api/worker/:name, /api/blocks, /api/templates, /api/nodethe data behind each page
/api/versionsbuild provenance of simplepool, the enforcer, Thunder and bitcoind
/healthzliveness only

Admin

/admin, behind basic auth, is where the operator acts: balances and what is owed, per-worker audits, deposits into the Thunder reserve, payouts and a "pay now" trigger, and a tools page for the stuck cases. Every write action is CSRF-gated, and every broadcast attempt is logged in tx_attempts, successful or not.

Slipstream

An optional service that takes a raw transaction from anyone and gets it into the pool's blocks — including the ones the network will not relay: BIP300/301 deposits, withdrawal bundles and BMM requests, or anything else that is consensus-valid. It borrows the fee rule of Marathon's Slipstream.

How a transaction gets in

The service checks the transaction against the pool's own bitcoind with testmempoolaccept, which reports the fee and size and broadcasts nothing, applies the fee rule, and only then sends it with sendrawtransaction. The enforcer's template mempool mirrors that node's mempool, so the transaction reaches the template the proxy is mining with no enforcer change — and with the enforcer's own BIP300 rules still applied on the way in.

slipstream: a tx from anyone, into the pool's blocks A submitter posts a raw tx. The slipstream service checks it against the pool's bitcoind and the fee rule without broadcasting, then broadcasts it. The enforcer mirrors that node's mempool, so the tx reaches the template the pool mines; the service follows it to a block. Submitterany walletslipstream:8124bitcoind-acceptnonstdtxnenforcertemplate serversimplepool:3334 POST /api/tx <raw hex>testmempoolacceptallowed? fee, vsizefee rule: max(floor, mineable)Checked BEFORE it is sent: nothing can be taken back out of a mempool, so refusing afterwards would be too late.sendrawtransactionmempool mirror (ZMQ)From here it is an ordinary mempool tx: relayed to peers, and minable by any pool whose node took it.getblocktemplatea template carrying the txpoll: in the template?poll: mined? how deep?Every submission is kept, refusals included. Each accepted tx is followed to confirmed, or to dropped with the node's own reason.

The fee rule

A transaction must pay the higher of the minimum submission rate and the current mineable rate. The minimum is SLIPSTREAM_MIN_FEE_RATE, 1 sat/vB by default. The mineable rate is read off the template being mined: while it has room, anything over the minimum gets in, so the two are equal; once it is full, it is the rate of the cheapest transaction it carries. The rule is checked once, at submission; after that a transaction competes by fee rate like any other.

Why the order matters

Nothing can be taken back out of a node's mempool. A transaction that paid too little and was refused after it was sent would be relayed and mined anyway, for less than was asked. So the rule runs between the check and the broadcast, never after.

Following it to a block

StatusMeans
pendingin the node's mempool, not in the latest template
in_templatein the latest template: the pool's miners are working on it now
minedin a block, recorded as the pool's or another pool's from blocks_found
confirmedSLIPSTREAM_CONFIRMATIONS deep, 6 by default
droppedleft the mempool unmined, and the node refused it when it was sent again; the reason is the node's own, e.g. a replacement or a spent input

A transaction whose block is orphaned goes back to pending when the node restores it to its mempool, which it does by itself. Every submission is kept, refusals included, and every status change is logged.

info.json

The service also answers GET /info.json, the listing a pool directory reads. Its facts — mode (the exact pool_mode), fee_bps, coinbase_tag, operator_address, pool_btc_address — come from pool_meta, never from the service's config. Name, chain, logo, contact and the public URLs, slipstream_url among them, come from its environment.

What it needs

  • A node that takes non-standard transactions: acceptnonstdtxn=1. Core refuses that setting on a chain that reports itself as mainnet — drivechain-patched builds included — so a mainnet-fork chain needs a Core patch that lifts the check before it can take them. BIP300 deposits are standard on a drivechain-patched node already and need no setting at all.
  • Acceptance of relay. An accepted transaction is broadcast like any other, so another pool may mine it first. That is the price of needing nothing from the enforcer, and the status table records whose block it was.

Configuration reference

Four processes, two ways of configuring them. The stratum proxy reads one key = value file, proxy.conf; the three Node services — payout, dashboard, slipstream — read only environment variables. Every key each one understands is below, with its default and what happens when it is wrong.

proxy.conf syntax

  • One key = value per line. The value is split at the first =, so values may contain =. Whitespace around the line, the key and the value is trimmed.
  • # starts a comment at the beginning of a line or after a space or tab, and never inside double quotes — so bitcoind_pass = p#ss keeps its #, and fee_bps = 100 # 1% is 100. One pair of surrounding double quotes is stripped; there are no escape sequences.
  • Keys are case-sensitive. A repeated key: the last one wins — except listener, where every line adds a port.
  • An unknown key, or a line with no =, logs a warning and loading continues. A typo is therefore not fatal: read the startup log.
  • Numbers are parsed leniently (atoi/atof), so fee_bps = one reads as 0. Strings longer than their buffer are truncated silently.
  • The file is read once. There is no reload on SIGHUP; restart the proxy to apply a change.

Command line & exit codes

simplepool [config_path]      # default ./proxy.conf
simplepool --version | -V     # version, commit, branch, and "tree: dirty" if built dirty
simplepool --help | -h
ExitMeaning
0clean shutdown (SIGINT / SIGTERM: stop stratum, flush the store, log final stats)
2config error, or an operator_address / pool_btc_address that does not decode — printed as config error: …
3the template backend could not be reached (client init or the startup getblockchaininfo ping)
4the SQLite ledger could not be opened
5the first getblocktemplate failed
6the first job could not be built from that template
7a stratum port could not be bound — if any one fails, none are kept

proxy.conf — every key

Stratum listeners & connections

KeyDefaultRulesMeaning
listen_addr0.0.0.0:: = dual-stack; any IPv6 literal binds v6-onlyAddress every stratum port binds to.
listen_port3334no listener may reuse itThe default port, served on the server-wide vardiff settings. Always bound.
listenernonerepeatable; see sub-keysAn extra port with its own difficulty policy and coinbase budget.
max_conns500≤0 → 500Concurrent connections across all ports. Past it, accept closes the socket immediately, silently. One thread and one fd each.
idle_timeout_sec6000 → default · negative disablesReap a socket that has sent nothing and has not authorized.
idle_timeout_authorized_sec72000 → default · negative disablesReap an authorized miner that has sent nothing. Long on purpose — silence between shares is normal.

Template backend

KeyDefaultRulesMeaning
bitcoind_urlhttp://127.0.0.1:18443—JSON-RPC endpoint for getblocktemplate and submitblock. On a drivechain pool this is the enforcer's GBT server (:8122), not bitcoind: only the enforcer's template carries the BIP300/301 commitments.
bitcoind_user / bitcoind_passemptyboth empty → no auth header and no startup pingBasic auth. Cookie auth is not supported.
bitcoind_poll_interval_ms30000not validated — keep it positiveTemplate refresh when the backend offers no long-poll. With long-poll (the enforcer does BIP22) the proxy wakes on each tip instead. After an error the retry backs off from 1 s doubling to 32 s.

Coinbase & fee

KeyDefaultRulesMeaning
operator_address—required; must decode, else exit 2Receives the fee_bps output. A network mismatch with the node (mainnet vs test) only warns.
fee_bps1000–1000, else refuse to startFee in basis points; 100 = 1%. 0 removes the fee output. A fee below 546 sats is dropped rather than made dust.
coinbase_tag/simplepool/≤63 chars; scriptSig must stay ≤100 bytesText in the coinbase scriptSig after the BIP34 height.

Pool mode

KeyDefaultRulesMeaning
pool_modesolosolo · pps-classic · pplns-thunder · pplns-btc · pplns-coinbase; anything else refuses to startCoinbase shape, what a username must be, when a balance moves. See the matrix below.
pool_btc_address—required by pps-classic, pplns-thunder, pplns-btc; refused by pplns-coinbase; ignored by soloThe pool wallet the coinbase pays. For pplns-btc it must belong to the enforcer's wallet.
block_interval_sec600must be > 0 (all modes)Target seconds per block. Feeds the PPS floor and the issuance ceiling.

PPLNS (pplns-thunder, pplns-btc, pplns-coinbase)

KeyDefaultRulesMeaning
pplns_window_diff_multiple2.0> 0 required; < 1.0 warnsWindow size as a multiple of the current network difficulty.
coinbase_max_bytes1000pplns-coinbase: ≥ 200; 0 → 1000Byte budget for the whole serialized coinbase, commitments included. Overridable per listener.
pplns_payout_floor_sats546pplns-coinbase: ≥ 0; raised to 546A claim below this gets no output in that block; its value is redistributed and the miner is queued first for the next.

pps-classic

KeyDefaultRulesMeaning
pps_sats_per_diff0 (derive)≥ 0Leave unset. 0 derives the rate per template as coinbasevalue / network_difficulty × (1 − fee_bps/10⁴). A value is used verbatim, already net of fee; the proxy warns when it implies a fee more than 25 bps from fee_bps.
pps_min_network_difficulty0 (off)≥ 0Below this network difficulty nothing accrues. Set it to hashrate × block_interval_sec / 2³² on any young chain.
pps_refuse_shares_below_min1—While the floor holds accrual off, refuse authorize and reject submits (error 24) instead of taking uncredited work. A refusal here does not count against the authorize budget.

Difficulty & vardiff (for listen_port; listeners override)

KeyDefaultRulesMeaning
initial_diff1≤0 → 1Starting difficulty on listen_port.
vardiff_enabled10 = offOff pins each connection to its starting difficulty.
vardiff_target_spm12—Target shares per minute per connection.
vardiff_window_sec30—Retarget interval.
vardiff_min / vardiff_max1 / 1e12vardiff_max = 0 = no capClamps. Both still yield to the network-difficulty ceiling.
vardiff_min_samples200 = previous behaviourShares a window must hold before its rate is trusted; below it the window is extended.
vardiff_max_window_mult8≤0 → 8How far a window may be extended waiting for samples.
vardiff_idle_step2≤1 → 2Max step for an under-sampled window (a full one may move 4×).
max_suggested_diff5e7≤0 disables requestsCeiling on a miner-requested floor (d= or mining.suggest_difficulty). Size it against idle_timeout_authorized_sec: at 50M a 25 TH/s rig expects one share per ~8600 s.

Abuse limits

KeyDefaultRulesMeaning
max_submits_per_sec20000≥ 0; 0 disablesPer-connection ceiling, fixed 1 s window. Excess submits are refused before parsing; logged to rejects at most every 10 s.
auth_max_failures3≥ 0; 0 disables the budgetFailed authorizes allowed per connection (then it is closed) and per peer IP within the lockout window.
auth_fail_lockout_sec60> 0 when failures > 0How long an IP that spent its budget is refused. A success clears it.

Storage, broadcast, logging

KeyDefaultRulesMeaning
db_path./data/shares.dbmust open, else exit 4The SQLite ledger (WAL).
commit_window_ms / commit_max_shares100 / 100≤0 → 100Batch commit on whichever comes first.
templates_retention_days30≤0 keeps foreverHistory kept in templates (dashboard only).
redis_urlemptyredis://[user[:pass]@]host[:port][/db] or host:portMirror events onto Redis pub/sub. A failed connect at startup warns and continues without it.
redis_publish_timeout_ms200≤0 → 200Per-publish bound.
redis_reconnect_backoff_ms2000≤0 → 2000Wait between reconnects. The queue holds 4096 events.
log_levelinfodebug · info · warn · error or 0–3; case-insensitivedebug logs every RPC request and raw response. An unknown value warns and is ignored.

listener sub-keys

listener = port=3335 min_diff=65536 label=braiins
listener = port=3336 min_diff=500000 initial_diff=500000 max_coinbase_bytes=900 label=nicehash

Fields are separated by spaces, tabs or commas, in any order. Any error here refuses to start: a field without =, an unknown field, a bad port or label, or initial_diff above max_diff.

FieldRulesMeaning
portrequired, 1–65535, unique, ≠ listen_portPort to bind.
min_diff< 1024 warnsVardiff floor and the promised floor — kept even above the network difficulty (which costs blocks).
initial_diffdefaults to min_diffStarting difficulty.
max_diff—Vardiff ceiling for this port.
max_coinbase_bytes0 = server-wide; else ≥ 200pplns-coinbase byte budget on this port.
label[A-Za-z0-9_-], ≤ 31 charsShown in the log and on the dashboard.

At most 7 listener lines: the server has eight port slots and listen_port takes one. An eighth is refused at startup.

What each mode requires

pool_modeCoinbase paysUsernamepool_btc_addressAlso needs
solothe finder, minus feeBitcoinignored—
pps-classicthe pool, minus feeThunderrequiredpayout worker (PAYOUT_RAIL=thunder), Thunder reserve, pps_min_network_difficulty on young chains
pplns-thunderthe pool, minus feeThunderrequiredpayout worker (PAYOUT_RAIL=thunder)
pplns-btcthe pool, minus feeBitcoinrequired (enforcer wallet)enforcer --enable-wallet; payout worker PAYOUT_RAIL=btc + ENFORCER_RPC_ADDR
pplns-coinbasethe window, directly (solo-shaped while it is empty)Bitcoinrefusednothing — no pool wallet, no payout worker

Removed and renamed keys

You wroteWhat happens
payout_addressrefuses to start: rename it to operator_address
pool_mode = ppsrefuses to start: use pps-classic
pool_mode = pplnsrefuses to start: name the rail — pplns-thunder, pplns-btc or pplns-coinbase
pool_thunder_reserve_address, thunder_sidechain_number, thunder_op_return_hexwarns "obsolete and ignored" and starts. The reserve address belongs to the dashboard and payout worker.

Minimal configs

solo

pool_mode        = solo
bitcoind_url     = http://127.0.0.1:8122
operator_address = bc1q…
fee_bps          = 100
db_path          = ./data/shares.db

pps-classic

pool_mode        = pps-classic
bitcoind_url     = http://127.0.0.1:8122
operator_address = bc1q…
pool_btc_address = bc1q…   # same as operator_address keeps the fee margin
fee_bps          = 100
pps_min_network_difficulty = 5600000  # 40 TH/s

pplns-btc

pool_mode        = pplns-btc
bitcoind_url     = http://127.0.0.1:8122
operator_address = bc1q…
pool_btc_address = bc1q…   # from the enforcer wallet
fee_bps          = 50
pplns_window_diff_multiple = 2.0

pplns-coinbase

pool_mode        = pplns-coinbase
bitcoind_url     = http://127.0.0.1:8122
operator_address = bc1q…
fee_bps          = 50
coinbase_max_bytes      = 3000
pplns_payout_floor_sats = 546
listener = port=3335 min_diff=500000 max_coinbase_bytes=900 label=rental

Payout worker — environment

payout/index.js. Runs for pps-classic and both custodial PPLNS rails. A missing required variable exits with code 2. Numeric values are parsed with parseInt and not otherwise checked.

VariableDefaultMeaning
PAYOUT_DB_PATHrequiredPath to shares.db (opened read-write).
PAYOUT_RAILthunderthunder or btc (L1 through the enforcer wallet, for pplns-btc).
THUNDER_RPC_URLrequired for thunderThunder JSON-RPC.
THUNDER_RPC_USER / THUNDER_RPC_PASS—Basic auth for Thunder.
THUNDER_FROM_ADDRESSrequired for thunderThe reserve address paid from. Must equal the dashboard's POOL_THUNDER_RESERVE_ADDRESS.
ENFORCER_RPC_ADDRrequired for btcEnforcer ConnectRPC host:port; the enforcer must run with --enable-wallet.
ENFORCER_WALLET_PASSPHRASE—Unlocks an encrypted enforcer wallet.
PAYOUT_FEE_RATE_SAT_VB5L1 fee rate passed to the enforcer.
PAYOUT_INTERVAL_MS86400000Batch cadence (24 h).
PAYOUT_SETTLE_INTERVAL_MS30000Re-check cadence while a broadcast batch is unconfirmed.
PAYOUT_RETRY_INTERVAL_MS300000Wait after a failed, reserve-short or mempool-blocked tick.
PAYOUT_MIN_SATS10000Smallest owed balance paid.
PAYOUT_MAX_PER_TICK50Most workers paid in one batch.
PAYOUT_DRY_RUNoff1: log only — no RPC writes, no DB writes.
PAYOUT_NUDGE_MINEon0: never call Thunder mine.
PAYOUT_NUDGE_INTERVAL_MS120000Minimum gap between stall nudges.
PAYOUT_NUDGE_STALL_SEC300How long a batch sits before it is nudged again.
PAYOUT_ADMIN_BIND / PAYOUT_ADMIN_PORT127.0.0.1 / 9080The unauthenticated /tick endpoint. Port 0 disables it. Keep it on loopback.
PAYOUT_DEBUGoff1: debug logging.

Dashboard — environment

VariableDefaultMeaning
PORT8081HTTP port.
DASHBOARD_BIND127.0.0.1Listen address. Loopback is for nginx in front; 0.0.0.0 serves it directly (the installer sets that with --no-nginx, and the Docker image inside its container). /admin is Basic auth, so never directly without TLS.
PROXY_DB_PATH../data/shares.dbThe ledger, relative to dashboard/.
PUBLIC_STRATUM_URLstratum+tcp://<host>:3334Shown on the connect card and in /api/status.
HEALTH_INTERVAL_MS300000Health-monitor period.
ADMIN_CREDENTIALS_FILE—A user:pass file; wins over the two below. Unreadable or malformed is fatal at boot.
ADMIN_USER / ADMIN_PASSWORD—If either is empty, /admin answers 503. A : in either is fatal.
POOL_THUNDER_RESERVE_ADDRESS—Reserve address shown and deposited to from admin.
THUNDER_RPC_URLhttp://127.0.0.1:6009Thunder RPC (balance, mine, remove_from_mempool).
ENFORCER_GRPC_ADDR127.0.0.1:50051Enforcer ConnectRPC (wallet balance, deposits, Ctip).
THUNDER_SIDECHAIN_ID9Sidechain slot deposits go to.
PAYOUT_ADMIN_URL—Payout worker base URL; empty hides "trigger payout".
VERSIONS_TTL_MS / VERSIONS_EXEC_TIMEOUT_MS300000 / 5000/api/versions cache and probe timeout.
VERSIONS_USE_CHECKOUTon0: don't read git checkouts for versions.
{SIMPLEPOOL,ENFORCER,THUNDER,BITCOIN}_REPO_DIR, SIMPLEPOOL_BIN, ENFORCER_BIN, THUNDER_BIN, BITCOIND_BIN, *_BUILD_MANIFESTsibling checkoutsWhere /api/versions looks for each component.
POOL_PPS_SATS_PER_DIFF—Ignored; only warns. The rate comes from pool_meta.

Slipstream — environment

Numeric values must be finite and at least their minimum, or the service refuses to start. An empty value counts as unset.

VariableDefaultMeaning
BITCOIND_RPC_URLrequiredThe pool's bitcoind: testmempoolaccept, sendrawtransaction, tracking. Needs txindex.
BITCOIND_RPC_USER / BITCOIND_RPC_PASS—Basic auth.
BITCOIND_RPC_COOKIE_FILE—Wins over user/pass; re-read on every call.
ENFORCER_GBT_URLrequiredThe enforcer's GBT server (:8122) — the template being mined.
SLIPSTREAM_DB_PATH../data/slipstream.dbIts own database (created if missing).
PROXY_DB_PATH../data/shares.dbThe ledger, opened read-only.
SLIPSTREAM_BIND / SLIPSTREAM_PORT127.0.0.1 / 8124HTTP listener.
SLIPSTREAM_TRUST_PROXYoff1: rate-limit on the last X-Forwarded-For hop. Only behind a proxy that overwrites it.
SLIPSTREAM_MIN_FEE_RATE1Minimum submission rate, sat/vB (≥ 0).
SLIPSTREAM_CONFIRMATIONS6Depth at which a tx becomes confirmed (≥ 1).
SLIPSTREAM_POLL_MS5000Follow-loop period (≥ 100).
SLIPSTREAM_RATE_LIMIT_PER_MIN30Submissions per client per minute (≥ 1).
POOL_NAME, POOL_OPERATOR, POOL_LOGO, POOL_CONTACT, POOL_CHAIN, POOL_PAYOUT_TEXT, PUBLIC_STRATUM_URL, PUBLIC_DASHBOARD_URL, PUBLIC_SLIPSTREAM_URL—Presentation fields for info.json only.
SLIPSTREAM_DEBUGoff1: debug logging.

Stratum protocol reference

Stratum v1 as simplepool speaks it: newline-delimited JSON-RPC over plain TCP, one thread per connection. What a firmware author or a marketplace integrator needs to know, taken from src/stratum.c.

Framing

  • One JSON object per line. Replies are {"id":…,"result":…,"error":…} with no jsonrpc field; notifications carry "id":null. An error is [code, "message", null].
  • A line may be at most 16 KB. Longer, or a full buffer with no newline, closes the connection with the reason logged.
  • Blank lines are ignored — the only keepalive a miner has. A JSON response object (no method) is swallowed silently.
  • Malformed JSON or a missing method is answered with error 20 and the connection stays open; eight such lines in a row close it.

Methods the pool accepts

MethodParamsResult
mining.configure [["version-rolling"], {"version-rolling.mask":"…"}] {"version-rolling":true,"version-rolling.mask":"%08x"} — your mask ANDed with 0x1fffe000 (BIP320). Other extensions are ignored; without version-rolling the result is {}.
mining.subscribe ignored (no session resume) [[["mining.set_difficulty","sd"],["mining.notify","sn"]], "<extranonce1, 8 hex>", 8]
mining.authorize [username, password] true, then mining.set_difficulty, then mining.notify with clean_jobs = true. Subscribe before authorizing — see below.
mining.suggest_difficulty [number > 0] true. Applied as a floor, like d=; re-announced if already authorized.
mining.submit [worker, job_id, extranonce2, ntime, nonce, (version)] true, or an error below. The worker field is ignored — the connection's authorized identity is used.

Anything else — mining.extranonce.subscribe, mining.get_transactions, mining.multi_version, client.* — is answered with error 20 unknown method, and the connection stays up.

What the pool sends

NotificationParams
mining.set_difficulty[difficulty] — takes effect on the next notify.
mining.notify [job_id, prevhash, coinb1, coinb2, [merkle_branch…], version, nbits, ntime, clean_jobs]. job_id is the build time in ms, lowercase hex. prevhash is word-swapped (each 4-byte word byte-reversed). version/nbits/ntime are %08x. coinb1/coinb2 are rendered per connection.

clean_jobs is true on a new tip (height or previous hash changed) and on the first job after startup; false on the periodic refresh, which rebuilds the job every 30 s so new transactions are picked up. The pool never sends client.reconnect, mining.set_extranonce or mining.set_version_mask.

Usernames and passwords

RailModesAccepted address
Bitcoinsolo, pplns-btc, pplns-coinbase bech32 bc1/tb1/bcrt1 v0 (P2WPKH, P2WSH), bech32m v1 (P2TR); base58 P2PKH (0x00 / 0x6f) and P2SH (0x05 / 0xc4). Witness v2–v16 is refused — anyone-can-spend today.
Thunderpps-classic, pplns-thunder bare base58 of a 20-byte hash. The s<n>_<b58>_<hex6> deposit wrapper is refused.

The username is <address>[.<rig_label>], split at the first dot; the address part is 1–127 characters. The stored worker name is the full username with anything outside [A-Za-z0-9._-] replaced by _, up to 128 characters.

The password carries no secret. The one thing read from it is d=<n> — at the start or after ,, ; or a space — which asks for a difficulty floor, not a pinned value. It is clamped in this order: capped at max_suggested_diff, then at the network difficulty, then the port's promised min_diff wins over that cap, then the port's max_diff. At authorize, a password d= overrides an earlier mining.suggest_difficulty.

Authorize limits

  • At most 60 mining.authorize calls per 10 s per connection; past that each counts as a failure.
  • After auth_max_failures failures the error is sent and the connection closed.
  • A peer IP that has failed auth_max_failures times within auth_fail_lockout_sec is refused before its request is parsed. A success clears it. IPv4-mapped IPv6 addresses count as their IPv4 form.

The work split

FieldSizeRule
extranonce14 BNext value of a clock-seeded server counter per subscribe. Unique until 232 subscribes.
extranonce28 BExactly 8 bytes or wrong extranonce2 size. Enough for a marketplace router to slice per machine (it needs ≥ 7).
versionmask 0x1fffe000Submitted version is (job & ~mask) | (rolled & mask) using the negotiated mask, or the default if none.
ntime−600 s … +7200 sMeasured from the job's own ntime.
Subscribe first

A connection that authorizes without ever subscribing gets its first job but keeps extranonce1 00000000 — shared by every such connection, so they mine identical work — and it is left out of later job broadcasts. The server-wide hash dedupe stops that turning into double credit; the wasted hashrate is the client's.

How a submit is checked

  1. authorized? → 24 unauthorized
  2. under max_submits_per_sec? → 20 submitting too fast
  3. at least five params? → 20 bad params
  4. pps-classic gate open? → 24 (the "not crediting right now" message)
  5. job known and retained? → 21 stale or unknown job
  6. version hex valid
  7. not seen on this connection? → 22 duplicate share
  8. ntime and nonce hex, ntime in window → 20 ntime out of range
  9. extranonce2 hex, exactly 8 bytes → 20 wrong extranonce2 size
  10. render the coinbase → 25 on failure
  11. hash the header; not seen server-wide? → 22 duplicate share
  12. hash ≤ network target → submit the block, whatever the share target says
  13. hash ≤ share target → accept; else 23 low difficulty

Step 7 records the share key before the later checks run, so resubmitting a share that failed validation answers duplicate share. The per-connection ring keys on job_id|extranonce2|ntime|nonce|version (1024 entries); the server-wide ring keys on the header hash itself (16384 entries), so it holds across connections and reconnects.

A share is judged at the difficulty its job was notified under (34 remembered per connection). One that misses that but meets the connection's current, lower difficulty is credited at the current one. Jobs are kept for the current plus 16 retired, up to 5 minutes, pruned when a new job is pushed.

Error codes

CodeMessages
20bad params · unknown method · malformed JSON · missing method · bad version hex · bad ntime/nonce hex · ntime out of range · bad extranonce2 hex · wrong extranonce2 size · submitting too fast
21stale or unknown job
22duplicate share
23low difficulty
24unauthorized · missing worker name · bad username / invalid address · too many authorize calls · too many failed authorizations from this address · PPS accrual suspended
25coinbase render failed

Rejected submits and failed authorizes are written to rejects with their reason. Malformed hex and bad params are not; a flood past the submit ceiling is summarised at most once every 10 s.

Vardiff, precisely

  • Runs only when a share is accepted — a silent connection is never retargeted. The window is armed at authorize.
  • After vardiff_window_sec: if fewer than vardiff_min_samples shares arrived, the window stretches up to vardiff_max_window_mult× before acting.
  • ratio = observed_spm / target_spm. Inside [0.5, 2.0] nothing changes; outside, new = old × ratio, capped at 4× per step — or vardiff_idle_step for an under-sampled window.
  • Miner-floor detection: with ≥ 5 shares whose lowest achieved difficulty is over 4× what was assigned, the miner is enforcing its own floor; difficulty jumps to 95% of that minimum with no step cap.
  • Clamp order: requested floor → port vardiff_min → port vardiff_max → network difficulty → port min_diff (wins) → never below 1.
  • A change sends one mining.set_difficulty (no re-notify) and logs it.

Sockets

TCP_NODELAY; keepalive after 120 s idle, 3 probes 30 s apart (Linux); a 10 s send timeout drops a peer that stops reading; listen backlog 64.

Coinbase layout

Byte by byte, what the pool puts in the one transaction it writes.

Input

prevout32 × 00 · ffffffff
BIP34 heightminimal push
tagcoinbase_tag, ≤ 75 B
extranonce14 B
extranonce28 B
sequenceffffffff

coinb1 ends just before extranonce1; coinb2 starts at the sequence. The whole scriptSig must be 2–100 bytes. When the template supplies its own coinbase (coinbasetxn, as the enforcer does), its scriptSig, version and locktime are kept and the tag and extranonces are appended to it.

Outputs, by mode

ModeFrom the enforcer's coinbasetxnBuilt from scratch
solo template outputs in order; its single spendable output becomes miner + [fee]. Every OP_RETURN — BIP300/301 commitments, witness commitment — kept byte for byte. miner · [fee] · [witness commitment]
pps-classic, pplns-thunder, pplns-btc as solo, with pool_btc_address in the miner slot pool · [fee] · [witness commitment]
pplns-coinbase the reward output becomes one output per paid miner + [fee]; solo-shaped while the window is empty payees… · [fee] · [witness commitment]

A template must have exactly one spendable (non-OP_RETURN) output to be used. The fee is floor(value × fee_bps / 10000); below 546 sats, or with no operator address, it is 0 and the output is omitted.

pplns-coinbase: who fits

  1. Split: sats_i = floor(payable × diff_i / Σdiff), remainder to the first. value is the template's actual spendable output.
  2. Order: a quarter of the expected slots go first to the largest positive owed_fraction; then everyone else by difficulty, largest first.
  3. Fit: walk that order; skip claims under the floor; charge each output its real size (31 B for P2WPKH, 43 B for P2TR); skip one that would exceed the budget and keep going, since a smaller script later may still fit.
  4. Redistribute: what skipped miners were owed goes to the paid ones pro rata; the rounding remainder to the largest output. The operator gets its fee and nothing else.
  5. Queue: each miner's entitled − received share of the block is staged against the block hash, applied at one confirmation, discarded if orphaned.

The byte budget is max_coinbase_bytes minus the fixed envelope — version, input, scriptSig, locktime, the witness commitment, every template OP_RETURN, and a reserved operator output. On a drivechain those commitments are the largest fixed cost.

Submitting a block

Header, transaction count, coinbase, then the template's transactions. When the template's coinbase was segwit-serialized, the coinbase is re-serialized with the marker, flag and a 32-byte zero witness reserved value, matching the commitment the backend computed. submitblock returning null is accepted (row pending); a reason string is recorded as rejected in submit_error. From a plain bitcoind template the coinbase goes out in legacy form, and Core's submitblock adds the witness reserved value itself before validating; it is not part of the block hash.

Ports, APIs & events

Everything that listens, and everything it says.

Ports

PortProcessBindExpose?
3334 + listenerssimplepool (stratum)listen_addryes — raw TCP, open in the firewall
8081dashboardDASHBOARD_BIND, 127.0.0.1through nginx
9080payout admin127.0.0.1never — unauthenticated
8124slipstream127.0.0.1through nginx
8122enforcer GBT (upstream)—no
50051enforcer ConnectRPC (upstream)—no
6009Thunder RPC (upstream)—no

Dashboard HTTP

Every response carries X-Content-Type-Options: nosniff, X-Frame-Options: DENY and Referrer-Policy: same-origin. Rate limiting is nginx's job (30 r/s, burst 20, in the shipped config).

Method · pathReturns
GET /, /worker/:name, /blocks?before=, /templates?limit=, /slipstreamHTML pages
GET /worker-lookup?name=302 to the worker page
GET /api/overviewpool totals
GET /api/nodebackend tip, or {}
GET /api/leaderboard, /api/leaderboard/by-addressper worker / per address
GET /api/worker/:nameone worker; 404 {"error":"unknown worker"}
GET /api/blocks?before=&limit={rows, next_before}
GET /api/templates?limit={current, history, total}
GET /api/versions[?force=1]build provenance of every component
GET /api/status[?force=1]{generated_at, pool, node, health, versions} — always 200
GET /health{ok, checks, failing, unavailable, checked_at, took_ms}; 503 while failing or before the first pass
GET /healthz{ok:true, db_ready} — liveness, always 200

The health monitor runs every HEALTH_INTERVAL_MS and checks: events lost, duplicate shares, ledger arithmetic, margin, PPS difficulty, listener difficulty, block value, orphan rate, ambiguous payouts, stalled payouts and template commitments.

Admin (/admin, HTTP Basic, realm "simplepool admin")

Method · pathDoes
GET /admin, /admin/workers, /admin/worker/:id, /admin/deposits, /admin/payouts, /admin/toolsHTML pages
GET /admin/api/summary, /admin/api/worker/:idJSON: reserve, enforcer balance, totals, workers, in-flight, payouts, deposits, blocks, tx attempts
POST /admin/action/depositenforcer CreateDepositTransaction (address, value_sats, fee_sats, sidechain_id) and a deposits row
POST /admin/action/check-depositsprobe deposit status
POST /admin/action/trigger-payoutPOST PAYOUT_ADMIN_URL/tick
POST /admin/action/nudge-mineThunder mine
POST /admin/action/remove-from-mempoolThunder remove_from_mempool (txid)
GET /admin/logout401, clearing the browser's credentials

Every POST needs a single-use CSRF token (1 h) and replies 302 back to the page with a flash message. Missing credentials make /admin answer 503 rather than run open.

Payout worker HTTP

Method · pathReturns
POST /tickruns one payout pass now: {ok, result:{attempted, paid, failed, settled, txid?, waiting_on?, reserve_short?, mempool_blocked?, reason?}}; 500 on error
GET /healthz{ok:true}

No authentication — the loopback bind is the boundary. Its loop picks the next wait from the last result: settle (30 s) while a batch is broadcast and unconfirmed, retry (5 min) after a failure, a short reserve or a blocked mempool, otherwise the interval (24 h).

Slipstream HTTP

CORS * on every response; no auth; trailing slashes ignored.

Method · pathReturns
POST /api/tx (or /tx) Body: raw hex as text/plain, or JSON "hex", {"hex":…}, {"tx":…}; ≤ 2,000,000 hex chars. 200 {accepted:true, …} or {accepted:false, reject_reason, fee_rate?, required_fee_rate?}; 400 invalid body/hex/decode; 413 too large; 429 rate-limited; 502 node unavailable.
GET /api/tx/:txid{tx, events}; 400 bad txid; 404 unknown
GET /api/txs?status=&limit={txs}; status one of pending, in_template, mined, confirmed, dropped; limit 1–500 (50)
GET /api/fees{min_submission_rate, mineable_rate, required_rate, template_height, template_weight, updated_at}
GET /info.json (or /api/info)pool listing: name, operator, chain, mode, fee_bps, coinbase_tag, stratum_url, dashboard_url, status_url, slipstream_url, operator_address, pool_btc_address, payout, software, version, logo, contact; cached 60 s
GET /healthz{ok, template_age_s, enforcer_error}; 503 without a template

Redis channels

Only with redis_url set. Fire-and-forget: SQLite is the record, and a dropped message is counted, not retried.

ChannelJSON fields
pool:sharesworker, payout_address, ts_ms, difficulty, is_block, share_hash
pool:rejectsworker, ts_ms, reason
pool:blocksworker, finder_address, ts_ms, height, hash, reward_sats, fee_sats — only blocks the node accepted
pool:tipheight, hash, observed_at_s
pool:creditsworker, ts_ms, delta_sats, accrued_total_sats (the total is always 0 — read the ledger)

Upstream calls

CallerCalls
simplepoolgetblocktemplate (rules:["segwit"], capabilities:["coinbasetxn","longpoll"]), submitblock, getblockhash, getblockchaininfo
payout (thunder)Thunder balance, create_transfer, sign_transaction, submit_transaction, get_transaction, get_wallet_addresses, get_wallet_utxos, get_block_template, mine
payout (btc)enforcer WalletService/{UnlockWallet, GetBalance, SendTransaction, ListTransactions, ListUnspentOutputs}
dashboardThunder balance, mine, remove_from_mempool; enforcer WalletService/{GetBalance, CreateDepositTransaction, ListSidechainDepositTransactions}, ValidatorService/{GetChainTip, GetCtip}
slipstreambitcoind testmempoolaccept, sendrawtransaction, getmempoolentry, getrawtransaction, getblockheader; enforcer getblocktemplate

Database schema

Every column of shares.db. The proxy applies schema.sql at startup and adds newer columns with ALTER TABLE, so an old database upgrades in place.

Connection settings

ProcessModePragmas
simplepoolread-write, sole writer of the share ledgerWAL, synchronous=NORMAL, foreign_keys=ON, busy_timeout=5000
payoutread-write: payouts*, pps_credits.paid_sats, tx_attemptsWAL, NORMAL, 5000
dashboardread-only; a separate handle writes deposits and tx_attempts for admin actionsWAL, 2000
slipstreamread-only on shares.db; owns slipstream.dbWAL, NORMAL, 5000

The proxy's writes go through one 65,536-event ring and one writer thread, committed as a BEGIN IMMEDIATE batch every commit_window_ms or commit_max_shares. A batch that fails three times is counted in pool_meta.events_lost — which the dashboard health check watches. Timestamps are unix seconds.

All tables and columns
TableColumns
workersid PK · name UNIQUE · first_seen · last_seen · payout_address (set once, never changed)
sharesid PK · worker_id → workers · ts · difficulty REAL · is_block · block_hash (the share's own hash, on every row) · credited_sats · rate_used REAL
rejectsid · worker_name · ts · reason
blocks_foundid · ts · height · hash (UNIQUE, index added at startup) · finder_id · finder_address · reward_sats · fee_sats · status (pending · confirmed · orphaned · rejected) · confirmations · pplns_window_diff · pplns_distributed · submit_error · checked_via (node · tips)
node_statussingle row: tip_height · tip_hash · tip_observed_at · updated_at
pool_metasingle row: network · network_source (node · inferred) · pool_mode · coinbase_tag · operator_address · pool_btc_address · fee_bps · pplns_payout_floor_sats · listeners (JSON [{port,label,min_diff,initial_diff}]) · rate_source (derived · override) · rate_sats_per_diff · gross_sats_per_diff · effective_fee_bps · network_difficulty · block_value_sats · credited_from · events_lost · updated_at
rate_historyts · rate_sats_per_diff · gross_sats_per_diff · fee_bps · network_difficulty · block_value_sats · rate_source — appended when any of them changes, pps-classic only
templatests · height · prev_hash · bits · network_difficulty · coinbase_value_sats · tx_count · tx_fees_sats · source (enforcer · bitcoind) · cb_spendable · cb_op_returns · longpoll · rate_sats_per_diff · last_seen · polls. Repeat polls fold into one row on (height, prev_hash, bits, source, cb_spendable, cb_op_returns, longpoll).
pps_creditsworker_id PK · accrued_sats (proxy) · paid_sats (payout) · last_updated
depositsts · btc_txid · sats_deposited · fee_sats · thunder_recipient · ctip_seq_before · ctip_seq_after · notes
payoutsworker_id · sats · fee_sats · txid · paid_at · note
payouts_in_flightworker_id · sats · txid ('' until broadcast) · started_at
tx_attemptsts · kind (deposit · payout) · status (broadcast · failed) · stage · txid · raw_tx · amount_sats · fee_sats · destination · worker_id · error · detail (JSON)
pplns_fractionsworker_id PK · owed_fraction REAL · updated_at
pplns_pending_fractions(block_hash, worker_id) PK · delta REAL

How a block's status moves

The confirmation pass runs on every tip change, up to 16 rows at a time. With getblockhash available it asks the node directly (checked_via = node); against a backend that serves only templates it compares each row's hash with the prev_hash of the newest template one height above (checked_via = tips).

pending   → confirmed | orphaned
confirmed → orphaned            (reorg)
orphaned  → confirmed           (tips path only)
rejected                        (terminal: submitblock refused it)

pplns-thunder and pplns-btc distribute a block once it is confirmed at ≥ 100 confirmations, in one transaction that also sets pplns_distributed = 1. The window is shares up to the one that found the block, newest first, until the running difficulty reaches pplns_window_diff (the crossing share counts whole); payable = gross − floor(gross × fee_bps / 10⁴) — with the coinbase's dust rule, so a fee under 546 sats that was never paid out is not deducted either — split by difficulty and truncated.

Constants & hard limits

Compiled in; not configurable.

WhatValue
Extra stratum ports7 (8 slots including listen_port)
Stratum line length16 KB
Consecutive unusable lines before close8
Retained jobs · job lifetimecurrent + 16 · 5 min
Per-connection job-difficulty memory34 jobs
Duplicate rings1024 per connection · 16384 server-wide
Authorize call budget60 per 10 s per connection; 1024-slot per-IP table
Periodic job refresh30 s
Template backend timeouts10 s per RPC, 90 s long-poll; retry 1 s → 32 s
RPC response cap32 MiB
Hashrate window (issuance ceiling)60 s
Dust limit546 sats
Coinbase budgetdefault 1000 B, minimum 200 B; ≤ 200 payout outputs
pplns-coinbase reserved slots¼ of the expected slots
Maturity for pplns-thunder / pplns-btc100 confirmations
Blocks checked per confirmation pass16
Store ring · commit retries65,536 events · 3
Redis queue · payload4096 messages · 1024 B
PPS override drift warning25 bps
Withholding audit thresholdexpected ≥ 5 blocks and z ≥ 3

Build, release & tests

Requirements

  • simplepool: a C11 compiler, libsqlite3, libcurl, libhiredis, pthreads. cJSON is vendored.
  • Node services: Node.js ≥ 20. The dashboard needs express, ejs and better-sqlite3; payout and slipstream only better-sqlite3.
  • Upstream: a template backend — on a drivechain, the bip300301_enforcer (GBT on :8122) in front of a drivechain-patched bitcoind; Thunder for the Thunder rails.
  • Platforms: release binaries for Linux amd64 and arm64, built on Ubuntu 22.04 (glibc 2.35), so they run on 22.04, 24.04 and Debian 12. macOS builds for development.

Make targets

make              # build/simplepool
make test         # all 11 C unit suites
make asan         # the core suites under ASan + UBSan
make coverage     # llvm-cov over the unit suites
make format       # clang-format
make install      # PREFIX=/usr/local

Flags: -std=c11 -O2 -Wall -Wextra -Werror -Wpedantic -Wshadow -fstack-protector-strong -D_FORTIFY_SOURCE=2, and never -ffast-math — the audit's exact-equality check depends on IEEE-754 arithmetic. The commit, branch and dirty flag are compiled in.

Releases

A tag must equal the Makefile VERSION (currently 0.4.0). CI builds simplepool-<version>-linux-<arch>.tar.gz — the source tree, the binary, its .build.json manifest and a RELEASE file — smoke-tests it, publishes SHA256SUMS, and takes the notes from CHANGELOG.md. Four Docker images go to ghcr.io/layertwo-labs/: simplepool, simplepool-dashboard, simplepool-payout, simplepool-slipstream.

Tests

SuiteCovers
test_sharetargets, difficulty, PPS rate formulas, merkle, header
test_stratumsubscribe / authorize / submit, vardiff, window render
test_store, test_store_walkSQLite store, migrations, reconcile SQL, PPLNS distribution and window walk (with fault injection)
test_coinbasebuilders, address decoding, template replacement, witness
test_bitcoindJSON-RPC client against a stub server
test_config, test_reconcile, test_pplns, test_broadcast, test_thunderconfig parsing; confirmation pass; split and ordering; Redis without Redis; Thunder addresses
tests/test_*_regtest.shend to end on regtest, per mode — solo, pps-classic, pplns, pplns-coinbase, slipstream, Thunder payout, L1 payout
npm test in each servicenode --test suites for the dashboard, payout and slipstream

CI also runs python3 docs/sequence-diagrams.py --check: the diagrams on this page are generated, and the build fails if they drift.

Source layout

PathResponsibility
src/main.cstartup, tip watcher, job building, PPS rate, share and block callbacks
src/stratum.cthe stratum server: sessions, extranonce, vardiff, per-mode coinbase render, submit validation, block assembly
src/store.cSQLite writer thread, schema and migrations, block status, PPLNS distribution and window, fraction ledger
src/coinbase.caddress → script, BIP34, coinbase builders, byte budget
src/bitcoind.clibcurl JSON-RPC: GBT with long-poll, submitblock, getblockhash
src/config.cproxy.conf parsing and validation
src/pplns.c, src/reconcile.cwindow split and ordering; confirmation pass
src/share.c, src/sha256.cshare math and PPS formulas; SHA-256
src/broadcast.c, src/thunder.c, src/log.c, src/version.cRedis, Thunder addresses, logging, build provenance
dashboard/, payout/, slipstream/the three Node services
deploy/systemd units, nginx vhosts, docker-compose
scripts/install.sh, simplepoolctl, release and regtest tooling
schema.sql, proxy.conf.examplethe ledger schema; the annotated config

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.serviceallthe stratum proxy — the only thing that is strictly required
simplepool-dashboard.serviceallread-only public stats on 127.0.0.1:8081 (published by nginx), plus /admin behind basic auth
simplepool-payout.servicepps-classic, pplns-thunder, pplns-btcthe daily payout batch, over Thunder or on L1 through the enforcer's wallet
simplepool-slipstream.serviceany, optionalthe slipstream service on :8124, published through nginx

Slipstream is not set up by the installer yet. Its unit and nginx vhost are templates in the repository — deploy/systemd/simplepool-slipstream.service and deploy/nginx/slipstream.conf — and slipstream/README.md walks through installing them. The docker-compose stack runs it as a fourth container.

Installer options

Everything the installer asks can be passed up front, which is how simplepoolctl upgrade re-runs it unattended.

FlagsMeaning
--from-release [tag] · --from-source [--repo URL] [--branch B]install a published build (default: latest), or clone and compile
--root DIR · --user NAMEinstall location (/home/simplepool) and service user (simplepool)
--mode · --stratum-port · --bitcoind-url / -user / -passthe core of proxy.conf
--operator-address · --fee-bps · --coinbase-tag · --pool-btc-addresscoinbase and fee
--thunder-address · --thunder-rpc-url · --payout-interval-hoursthe Thunder reserve and payout cadence
--hostname FQDN · --dashboard-port · --admin-user · --admin-password · --tls --email ADDRthe dashboard, nginx and a certbot certificate
--no-dashboard · --no-payout · --no-nginx · --no-firewall · --enable-firewall · --no-deps · --run-testswhat to skip or add
--non-interactive · --yesaccept defaults, ask nothing

The installer offers solo and pps-classic; for the PPLNS modes, set pool_mode in proxy.conf afterwards. Don't pass --pps-sats-per-diff: leave the rate derived.

Where things live

PathWhat
<root>/proxy.confthe proxy config
<root>/build/simplepoolthe binary
<root>/data/shares.dbthe ledger (mode 0640) — the only directory the units may write
<root>/data/slipstream.dbslipstream's own database
/etc/simplepool/install.envthe installer's saved answers (0600)
/etc/simplepool/admin.creddashboard admin user:pass
/etc/systemd/system/simplepool*.service + .d/local.confunits; the drop-ins carry the environment variables
/etc/nginx/sites-available/<fqdn>, /etc/nginx/conf.d/pool-ratelimit.confthe dashboard vhost and its rate limit

Every unit runs as the service user with NoNewPrivileges, PrivateTmp, ProtectSystem=full and ProtectHome=read-only, and restarts on failure.

simplepoolctl

CommandDoes
statusunits, ports, versions, ledger totals (the default)
doctorbinary, config, addresses, payout source, DB and schema, disk, bitcoind reachable, units up, ports open, firewall
logs [svc] [-f] [-n N]journal for proxy, dashboard, payout or all
start · stop · restart [svc]systemd lifecycle
configwhere every config file is, and what it says
versioninstalled release and the latest one
upgrade [tag]re-run the installer non-interactively onto a release
uninstall [--purge]remove units and the CLI; --purge also deletes the root and /etc/simplepool

Neither the installer nor simplepoolctl manages slipstream yet.

Docker

deploy/docker/docker-compose.yml runs the four images against an existing node stack on the host. The proxy uses host networking; the dashboard publishes 8081; payout and slipstream publish on 127.0.0.1 only. All four share data/ as /data and reach host services through host.docker.internal.

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.
  • Serving a difficulty its chain cannot back costs blocks. Share difficulty is clamped to the network difficulty by default, because a miner filters locally and a harder share target makes it discard valid blocks. A rental port can override that clamp with min_diff — it has to, since a marketplace measures the wire — but it buys reachability with the blocks those miners then throw away. Until the chain catches up, serving a marketplace whose floor sits above it is a choice with a price, not a free setting. The pool warns at startup and the dashboard says so plainly, rather than letting either half be found out when an order is cancelled.
  • Slipstream does not keep transactions private. It broadcasts through the pool's bitcoind, so another pool can mine a submitted transaction first, and it cannot withdraw one once sent. On a chain that reports itself as mainnet the node cannot run acceptnonstdtxn without a Core patch, so there it takes only standard transactions — which includes BIP300 deposits.
  • On a drivechain, the template must come from the enforcer. Pointed at a plain bitcoind, the pool mines valid blocks, but they carry no BIP300/301 commitments, so the sidechains starve. Only the enforcer's template (coinbasetxn) includes them.
  • The submit ceiling bounds a flood, it does not end one. A mismatched connection is refused cheaply instead of validated, but it is still connected and still sending. What ends it is vardiff climbing to a difficulty that fits, which takes minutes.