What it is
A stratum server, a share ledger, and a read-only dashboard. That is the whole system.
Miners open a TCP connection to port 3334 and speak stratum v1.
simplepool builds block templates from bitcoind's
getblocktemplate, hands each connection its own job, re-hashes
every submission it receives, and writes each accepted one into
data/shares.db. If a submission also clears the network target,
it goes straight back out via submitblock.
There is no account system. There is no password — the stratum password field is read and discarded. Your identity on the pool is the payout address you authorize with, which means there is nothing to register, nothing to log into, and nothing the operator can quietly change about who you are.
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.
Auditing your own contribution to a mining pool is normally somewhere between hard and impossible — you are handed a number and asked to trust it. simplepool writes down enough per share that the number can be re-derived from scratch by anyone holding a copy of the database, without trusting the dashboard that reports it. Section 11 is that argument in SQL.
The two modes
One config key — pool_mode — decides the shape of the coinbase,
what a stratum username must be, and whether any off-chain accounting
happens at all.
pool_mode = solo the default
Every block is paid, on-chain, in its own coinbase, to the miner who found it. Nothing is pooled. If your rig finds the block you get essentially the whole subsidy plus fees; if it doesn't, nobody on this pool earns anything at that height.
- Stratum username
- your Bitcoin address,
bc1q…or base58 - Who gets paid
- the finder, in the block's coinbase
- When
- immediately, with the block — no payout worker exists
- Variance
- all yours
- Shares are
- a record, not a balance
- Needs
- a
bitcoind. Nothing else.
pool_mode = pps-classic
Every block's coinbase pays a pool-owned BTC wallet. Every accepted share credits your balance at a rate derived from the live block template, whether or not the pool found anything. The operator moves accumulated BTC into a Thunder reserve, and a payout worker drains that reserve to miners.
- Stratum username
- a bare base58 Thunder address
- Who gets paid
- every miner, per share
- When
- daily batch, once your balance clears the minimum
- Variance
- the pool's
- Shares are
- the unit of account
- Needs
bitcoind, the enforcer, a Thunder node
| solo | pps-classic | |
|---|---|---|
| Coinbase outputs | miner's address + operator fee | pool_btc_address + operator fee |
| Per-connection coinbase | yes — each miner's cb1/cb2 pay that miner |
no — every miner's coinbase pays the pool |
| Off-chain accounting | none | pps_credits |
| Pool custodies BTC | never | yes, between mining and deposit |
| Payout asset | BTC, on the mainchain | BTC on Thunder, a BIP300 sidechain |
| Payout worker | not installed | simplepool-payout.service |
| Miner's income | lumpy and rare, but complete | smooth and proportional |
| Who eats bad luck | the miner | the pool operator |
pool_mode = pps put a BIP300 drivechain deposit directly in
each coinbase, so the pool would never custody BTC at all. It does not
work. Regtest and a live forknet both showed the enforcer does not
credit coinbase outputs as deposits: the block confirms, and the
sidechain Ctip never moves — the reward is simply stranded. A canonical
deposit transaction has to spend real, mature, spendable UTXOs, and a
coinbase does not qualify. That is a consensus rule, not a bug, so the
mode was deleted rather than patched. pps-classic is what
every working drivechain pool converges on instead.
The stack
In solo mode everything to the right of bitcoind is optional.
In pps-classic the enforcer and a Thunder node join the picture, because
that is where miners actually get paid.
Optionally, setting redis_url mirrors accepted shares, rejects,
blocks, tip changes and PPS credits onto Redis pub/sub channels
(pool:shares, pool:rejects, pool:blocks,
pool:tip, pool:credits). SQLite stays authoritative;
the publish is fire-and-forget and a Redis outage cannot cost you a share.
Life of a share
From plugging in an ASIC to a row in the ledger. Identical in both modes except where noted.
-
miner → pool
mining.subscribeThe pool allocates this connection a 4-byte extranonce1 and replies with it. The value comes from an atomic counter XORed with the current millisecond, so two rigs subscribing in the same nanosecond cannot collide, and a rig reconnecting days later after the counter has wrapped still gets something fresh.
-
miner → pool
mining.authorize "<address>[.<rig>]"The username is parsed as an address and validated on the spot — bech32 or base58check in solo mode, bare base58 Thunder in pps-classic. An invalid address is rejected with a clear error and written to the
rejectstable rather than silently accepted. The password is discarded. -
pool → miner
mining.set_difficulty+mining.notifyThe connection gets a starting difficulty and the current job. In solo mode the job's
cb1/cb2are 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. -
pool ↔ bitcoind
Tip watcher
A background thread re-fetches
getblocktemplateeverybitcoind_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 carriesclean_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 sendsclean_jobs = false, and the pool goes on accepting submits against the older job out of its retention ring. -
miner → pool
mining.submitCarries
job_id, the miner'sextranonce2,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 cachedcb1/cb2and 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
extranonce2is the width the pool advertised, thentimeis 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. -
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 difficultyand logged inrejects. Below it, a row lands inshares. Below the network target as well, it is also a block. -
pool → bitcoind
Block submission
A block-shaped share is serialised in full and pushed via
submitblock, then recorded inblocks_foundwith the height, hash, finder, reward and fee. What is recorded is a block candidate: if the node refuses the submission the row is writtenrejectedwith its reason, and if it is accepted the row ispendinguntil the block is verified to be in the chain — a later reorg moves it toorphaned. Onlyconfirmedis 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. -
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 everycommit_window_ms(100 ms) or everycommit_max_shares(100), whichever comes first.
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
Where the extranonce lives
The coinbase scriptSig is assembled at share-check time and
carries both halves of the standard stratum split:
/simplepool/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.
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.
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:
| Key | Default | What it does |
|---|---|---|
vardiff_enabled | 1 | 0 pins every connection to initial_diff |
vardiff_target_spm | 12 | target shares per minute per connection |
vardiff_window_sec | 30 | how often to retarget |
vardiff_min / vardiff_max | 1 / 1e12 | clamps for listen_port; a listener overrides them per port |
initial_diff | 1 | what a connection on listen_port starts at |
listener … min_diff | unset | the 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_sec | 600 | reap a socket that has not authorized |
idle_timeout_authorized_sec | 7200 | reap an authorized miner. Much longer on purpose: silence between shares is a working miner's normal state, not an idle connection. |
max_conns | 500 | concurrent stratum connections across every listener; past it, accept closes immediately. Each one costs a thread and an fd. |
pps_min_network_difficulty | 0 | pps-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_min | 1 | while 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_sec | 600 | target 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_ms | 200 / 2000 | bounds 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:
fee_bpsoperator_addressfee_bps of the reward · default 1%
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
-
on-chain
The coinbase pays the pool
Ordinary output to
pool_btc_addressfor 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. -
per share, automatic
Each accepted share credits
pps_creditsaccrued_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. -
operator, manual
BTC is deposited into the Thunder reserve
From the admin dashboard: a real
CreateDepositTransactionthrough the enforcer's wallet, spending accumulated pool UTXOs intoOP_DRIVECHAIN+OP_RETURN. This does move the Ctip. Each one is recorded in thedepositstable with the txid and the Ctip sequence before and after. -
payout worker, daily
The reserve is drained to miners
Everyone whose
accrued − paidclearsPAYOUT_MIN_SATSis 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.
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:
| Guard | What it is | Blind 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.
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. Solo has no accrual to suspend — it pays each miner from their own coinbase — so the gate never touches it, whatever the chain's difficulty is doing.
Username
<thunder_base58_address>[.<rig_label>]
JPbJrEKEaA69dAADY2qfW7dfyYQ
JPbJrEKEaA69dAADY2qfW7dfyYQ.shed-01
The deposit-format wrapper s9_<base58>_<hex6> —
what format-deposit-address hands you — is
rejected at authorize time. Thunder's own OP_RETURN parser
does not recognise it at the byte level, so a miner who accrued a balance
against it would have accrued something unpayable. Failing at connect is
the kind alternative.
Where the fee lands
fee_bps is one number applied in up to two places, and whether
that is one deduction or two depends entirely on your addresses.
In solo
One place only: the coinbase splits fee_bps to
operator_address and the rest to the finder. 100 bps = 1%,
capped at 1000 bps = 10%.
In pps-classic
Two places: the coinbase splits fee_bps between
operator_address and pool_btc_address,
and the PPS rate is reduced by fee_bps before
anyone is credited.
Which makes the choice of addresses a real economic decision:
| Arrangement | Effect | Consequence |
|---|---|---|
operator_address == pool_btc_address |
the coinbase split is a no-op — the pool receives the whole block — and the fee is collected once, via the rate | the pool runs with a fee_bps margin over its expected payout. That margin is the buffer that absorbs bad luck. |
| they differ | the operator takes the cut on-chain, per block, before the pool entity sees it | the pool entity runs at break-even in expectation with no buffer, while still carrying full PPS variance. A bad run becomes a shortfall. |
Both are coherent; neither is a bug. Pick deliberately, and if you pick the second one, know that you have separated who collects the fee from who carries the risk.
Payouts over Thunder
pps-classic only. The design goal is narrow and unglamorous: never pay twice, and never claim to have paid when you haven't.
Once a day, in one transaction
Payouts run as a daily batch. Once every 24 hours, everyone
whose accrued − paid clears PAYOUT_MIN_SATS
(10 000 sats by default) goes out together in a single Thunder
transaction.
Batching is not an optimisation, it is a requirement. Thunder only advances when a mainchain block commits to it, and its wallet cannot spend the change of an unconfirmed transaction — so paying N miners individually would cost N sidechain blocks, and past a handful of miners the queue would drain slower than it fills. The cost of batching is failure isolation: one bad address fails the whole batch. That is an acceptable trade here, because every recipient is an address the proxy already validated at authorize time, and a failed batch credits nobody and strands nobody — the next run simply retries.
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 tick | Why |
|---|---|---|
| did nothing, or settled cleanly | PAYOUT_INTERVAL_MS — 24 h |
the ordinary cadence |
| broadcast a batch, or is still waiting on one | PAYOUT_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 short | PAYOUT_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 settlement | 5 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
-
write-ahead
INSERT INTO payouts_in_flightOne row per worker in the batch,
txid = ''. From this momentlistDue()skips those workers, so nothing can queue them twice. -
network
Broadcast the batch
One Thunder transaction for everyone. On failure the rows are removed,
paid_satsis untouched, and the next run tries again. -
local
Stamp the txid — and stop
The rows stay in flight. Nobody is credited here. A broadcast is not a settlement.
-
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.
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.
| Table | Written by | What it holds |
|---|---|---|
workers | proxy | one row per address[.rig] seen, with the payout address kept separately so the dashboard can roll up across rigs |
shares | proxy | one row per accepted share: worker, timestamp, difficulty, hash, is_block, and in pps-classic credited_sats + rate_used |
rejects | proxy | 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_found | proxy | height, hash, finder, finder address, reward_sats, fee_sats |
rate_history | proxy | every PPS rate published, with the template inputs it was derived from — the basis of audit check 2 |
pool_meta | proxy | 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 |
templates | proxy | one row per materially distinct block template, pruned by templates_retention_days |
node_status | proxy | backend height and tip, for the dashboard's node card |
pps_credits | proxy and payout worker | accrued_sats (proxy only, monotonic) and paid_sats (payout worker only, monotonic). Owed = the difference |
payouts_in_flight | payout worker | the write-ahead log that makes payouts at-most-once |
payouts, tx_attempts | payout worker | settled payouts, and every transaction attempt with its stage and raw bytes for forensics |
deposits | dashboard | one row per operator-triggered BTC → Thunder deposit, with Ctip sequence before and after |
accrued_sats and paid_sats must both only ever
increase, and each has exactly one writer. A decrease in either means
somebody edited the database by hand — which is worth knowing, and is why
it is stated here rather than enforced by a trigger that would hide it.
Connect a miner
There is nothing to sign up for. Point the ASIC at the host and put your address in the username field.
Solo
URL stratum+tcp://pool.example.com:3334
Worker bc1qw508d6…kv8f3t4.rig-01
Password (anything — it is discarded)
pps-classic
URL stratum+tcp://pool.example.com:3334
Worker JPbJrEKEaA69dAADY2qfW7dfyYQ.rig-01
Password (anything — it is discarded)
Stratum is raw TCP, not HTTP, so it does not pass through the pool's nginx.
Miners connect straight to host:3334; only the dashboard is
behind the reverse proxy. If you need TLS on stratum itself, that is an
nginx stream {} block or stunnel, not something the
pool does for you.
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 connecting | Use | Why |
|---|---|---|
| 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
| Symptom | Cause |
|---|---|
| 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 receives | What 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. |
Configuration
One key = value file, proxy.conf. The keys that
change behaviour rather than tuning it:
| Key | Default | Meaning |
|---|---|---|
pool_mode | solo |
solo or pps-classic. Decides the coinbase shape and what a username must be. |
operator_address | — | Required. Receives the fee_bps cut. The proxy refuses to start without it. |
fee_bps | 100 |
Fee in basis points; 100 = 1%, hard cap 1000 = 10%. 0 drops the fee output entirely. |
pool_btc_address | — | pps-classic only, and required there: the coinbase pays here. |
pps_sats_per_diff | unset | Leave it unset. See the warning in section 8. |
listen_addr / listen_port | 0.0.0.0 / 3334 |
Where stratum listens. |
listener | none | An extra port with its own difficulty policy — port=, min_diff=, initial_diff=, max_diff=, label=. Repeatable, unlike every other key here: each line adds a port. See section 6. Remember to open it in the firewall. |
max_submits_per_sec | 20000 |
Per-connection ceiling on mining.submit; 0 disables it. Bounds what one badly matched connection can cost. Raise it only if you pin a low difficulty for a very large miner. |
bitcoind_url | — | JSON-RPC endpoint for getblocktemplate / submitblock. |
bitcoind_user / bitcoind_pass | — | Optional — omit both for an unauthenticated backend and the call goes out with no auth header. Cookie auth is not supported. |
bitcoind_poll_interval_ms | 30000 |
Template refresh. On a drivechain pool this is also the worst-case delay before a sidechain's BMM request can reach a job — lower it to 5000–10000 if sidechains need to merge-mine reliably. |
coinbase_tag | /simplepool/ |
Short string baked into the coinbase scriptSig. |
db_path | ./data/shares.db |
The ledger. |
commit_window_ms / commit_max_shares | 100 / 100 |
Write batching — commit on whichever comes first. |
templates_retention_days | 30 |
How much template history the dashboard keeps. 0 keeps everything. |
redis_url | empty | Set to mirror events onto Redis pub/sub. Empty disables it. |
log_level | info |
debug logs every RPC request and raw response. |
The payout worker is configured entirely by environment variables, not by
this file — PAYOUT_INTERVAL_MS,
PAYOUT_SETTLE_INTERVAL_MS, PAYOUT_MIN_SATS,
THUNDER_FROM_ADDRESS and friends. The Thunder reserve address is
deliberately not a proxy key: the coinbase never touches Thunder, so
only the dashboard and the payout worker have any business knowing it.
Running one
One line on a fresh Ubuntu or Debian server, then a single command for everything after.
curl -fsSL https://raw.githubusercontent.com/LayerTwo-Labs/simplepool/main/scripts/install.sh | sudo bash
The installer downloads the published build for the machine's architecture,
verifies it against the release SHA256SUMS, then asks for the
pool mode, your bitcoind RPC, your addresses, a dashboard domain and admin
password, and whether to set up nginx, TLS and the firewall. It writes
proxy.conf, loads the schema, installs three systemd units, and
tells you what miners should connect to. Every answer is saved, so re-running
it is how you change your mind about any of them. Pass
--from-source to clone and compile instead.
simplepoolctl status # services, ports, versions, ledger totals
simplepoolctl doctor # binary runs? bitcoind answers? DB writable?
simplepoolctl logs payout -f # one service, or all of them
sudo simplepoolctl restart proxy
sudo simplepoolctl upgrade # next release, then restart
sudo simplepoolctl uninstall # --purge also deletes the ledger
What runs
| Unit | Mode | Job |
|---|---|---|
simplepool.service | both | the stratum proxy — the only thing that is strictly required |
simplepool-dashboard.service | both | read-only public stats on :8081, plus /admin behind basic auth |
simplepool-payout.service | pps-classic | the daily Thunder payout batch |
Which commit is running
The build commit is compiled into the binary, so simplepool
--version reports what is actually executing rather than what
the source tree next to it currently says. A build from a tree with
uncommitted changes says so on its own line, because in that case the commit
printed above it does not describe the binary. The dashboard's
/api/versions answers the same question for the whole stack —
simplepool, the enforcer, Thunder, bitcoind — over plain HTTP with no auth.
What it can't do
Stated plainly, because a pool that only advertises its guarantees is telling you half the story.
- It cannot prove a miner hashed anything. It can only check the work it was shown. A rig that finds a block and drops it on the floor looks identical to an unlucky one on any individual sample — which is why the withholding audit is statistical, and why it needs an expectation of at least five blocks before it will say anything.
- In pps-classic the pool custodies BTC. Between mining a block and depositing into Thunder, the reward sits in a pool-controlled wallet. The design that avoided this — a drivechain deposit in the coinbase — does not work at the consensus level. This is a real trust assumption and it is not engineered away.
- Deposits are manual. An operator presses a button per deposit. If nobody does, the reserve runs dry and payouts skip with a logged warning until someone notices.
- An ambiguous crash needs a human. A crash between writing the in-flight rows and broadcasting leaves a state where "it was sent" and "it wasn't" are indistinguishable. The worker refuses to guess, halts, and says so.
- The Thunder payout fee is a flat 100 sats per batch for now, pending observable fee dynamics on that chain.
- Solo mode is solo. If your rig doesn't find the block, you earn nothing for that height. That is the design, not a shortfall.
-
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. - 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.