Skip to content

WIP: pplns pool_mode — pooled mining without operator variance - #61

Draft
rsantacroce wants to merge 3 commits into
mainfrom
2026-08-25-pplns-mode
Draft

WIP: pplns pool_mode — pooled mining without operator variance#61
rsantacroce wants to merge 3 commits into
mainfrom
2026-08-25-pplns-mode

Conversation

@rsantacroce

Copy link
Copy Markdown
Collaborator

Draft — not for merge. Targeting a release ~3 weeks out. Closes #48 when it lands.

Forks from a2ef6f5, with v0.3.0 as an ancestor: nothing here depends on unreleased or in-flight work, and the branch can sit for weeks without rotting against something unmerged.


Why

solo pays each miner from their own coinbase and pools nothing. pps-classic pools properly but moves all variance onto the operator, who needs a reserve measured in block rewards to absorb it. There was no pooled mode where the miners carry the variance — which is the mode most small pools actually want, and the only alternative to a PPS pool that cannot fund its reserve (see #47).

Under PPLNS the pool never owes more than it has just been paid. There is no reserve to size and operator ruin is not a failure mode.

What's here

commit
071bf7f Split the flag pool_mode was overloading; add the modes and the window knob
02e1933 Distribution — split a matured block across the window that produced it
8fc6ce1 The L1 rail, via the enforcer's wallet

The flag split

pps_enabled was doing two unrelated jobs — whether the coinbase pays the pool or the miner, and whether a username is a Thunder or Bitcoin address. They moved together across the only two modes that existed:

mode coinbase pays username
solo the miner bitcoin
pps-classic the pool thunder
pplns-thunder the pool thunder
pplns-btc the pool bitcoin

pplns-btc is the combination that breaks a single flag. A third came out of the same split: the accrual gate now keys on pps_accrues rather than the gate pointer — main.c installs that pointer for every mode, so keying on it would have suspended solo and both PPLNS modes, refusing miners from modes that never accrued anything. test_solo_is_never_gated caught it.

Distribution

On each confirmation pass: confirmed, 100 deep, not yet distributed → walk back from the block's own share accumulating difficulty until the window fills → credit each worker its proportion of (reward + fees) net of fee_bps.

  • Maturity, not confirmation. A coinbase output is unspendable until 100 deep; crediting earlier creates a balance the pool cannot fund — the reserve requirement PPLNS exists to remove, reintroduced by accident. It also deletes the orphan question rather than answering it: crediting is additive and there is no negative share, so a credit from a block that turns out not to be ours cannot be clawed back. At 100 deep that stops being a risk.
  • The window is snapshotted at find time, not recomputed at payout. Those moments are ~100 blocks apart and the chain can retarget between them.
  • Transaction fees are included, unlike pure PPS.

The L1 rail

An earlier design had the pool tracking its own coinbase outpoints and serialising a BIP174 PSBT for offline signing. It doesn't need to exist: bip300301_enforcer already ships a wallet, and WalletService/SendTransaction takes a destinations map and a fee rate and does the selection, signing and broadcasting itself.

The client mirrors ThunderClient's interface, so the payout loop never branches on which rail it drives — the write-ahead row, one transaction per batch, and credit-on-confirmation are written once and shared.

Design decisions already settled

  • one rail per pool, encoded in pool_mode rather than a mode plus a rail knob, so the inconsistent configuration is unrepresentable rather than merely rejected
  • window as a multiple of network difficulty, so it self-scales across retargets
  • standard partial-window handling: a young pool pays the full reward across whatever work exists
  • pplns-btc requires the enforcer running with --enable-wallet; the proxy says so at startup

Still to do before this is mergeable

  • pplns-thunder exercised end to end (needs no new code — the existing worker already drains pps_credits)
  • regtest e2e for both rails, against a real enforcer wallet rather than a stub
  • dashboard is PPS-shaped: it reads pps_credits so balances appear, but "rate" and solvency framing are meaningless under PPLNS and the identity strip doesn't know the mode exists
  • docs — docs/simplepool.html and README.md still describe two modes

Verification so far

608 C assertions, payout 77, dashboard 135, clean under ASan/UBSan. The window and maturity gates are mutation-tested; so is the address merge in the L1 client.

🤖 Generated with Claude Code

rsantacroce and others added 3 commits August 25, 2026 16:52
Groundwork for #48. No PPLNS accounting yet — this is the mode plumbing
it needs, and one refactor that has to happen first.

`pps_enabled` was doing two unrelated jobs. It decided whether the
coinbase pays the pool or the miner, and it decided whether a stratum
username is a Thunder address or a Bitcoin one. Those happened to move
together across the only two modes that existed, so one flag was enough:

    mode           coinbase pays   username
    solo           the miner       bitcoin
    pps-classic    the pool        thunder

pplns-btc is the combination that breaks it. It pools the reward like
PPS, so the coinbase pays the pool, and it pays out on L1 like solo, so
the username is a Bitcoin address. No single flag expresses that, so it
is now two: coinbase_pays_pool and username_is_thunder.

A third came out of the same split. The accrual gate suspends crediting
when network difficulty is below what PPS can safely price a share at,
and it was reading pps_enabled. Rewriting it to read the gate pointer
instead looked equivalent — main.c installs that pointer unconditionally,
so it would have gated solo and both PPLNS modes, refusing miners from
modes that never accrued anything to suspend. test_solo_is_never_gated
caught it, which is exactly the defensive property it was written for. So
the gate keys on pps_accrues, true only for pps-classic: it is the only
mode that prices a share when it arrives and can therefore misprice one.
PPLNS values a share in hindsight, out of a block actually found, so
there is nothing to gate.

The two pplns values are one knob rather than a mode plus a rail knob,
per the decision on the issue: an operator runs Thunder or L1, never
both, because the rail decides what a username is. One value makes the
inconsistent configuration unrepresentable instead of merely rejected.
`pool_mode = pplns` on its own is refused with a message naming the two
real values, checked before the generic catch-all since it is the likely
typo.

Window size is pplns_window_diff_multiple, a multiple of current network
difficulty rather than an absolute share count, so it self-scales across
retargets — an absolute window silently changes meaning ~4x at each of
the forknet retargets. Required > 0, and warned below 1.0, where a block
pays out across less work than it took to find and rewards hopping.

Still to come: the distribution step itself (read the window on a block
maturing, credit pps_credits pro rata) and the L1 payout rail in the
payout worker, which mirrors ThunderClient's small surface — balance,
batch send, confirmation — against bitcoind's already-generic rpc_call.

379 stratum assertions (was 374), clean under ASan/UBSan, and
proxy.conf.example still loads with no unknown key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The distribution step from #48, and the last piece that is rail-agnostic
— pplns-thunder and pplns-btc both land here and differ only in what
pays out afterwards.

On every confirmation pass, any block that is confirmed, 100 deep, and
not yet distributed is split across the shares that produced it: walk
backwards from the block's own share accumulating difficulty until the
window is full, then credit each worker its proportion of
(reward + fees) net of fee_bps.

Three decisions worth stating, because each has a wrong answer that
looks fine.

Maturity is 100 confirmations, not "confirmed". A coinbase output is
unspendable until then, so crediting at confirmation creates a balance
the pool genuinely cannot fund — the reserve requirement PPLNS exists to
remove, reintroduced by accident. Waiting also deletes the orphan
question rather than answering it: crediting is additive and there is no
negative share, so a credit from a block that later turns out not to be
ours cannot be taken back, and the reversal path that would otherwise
have to exist simply never does.

The window is snapshotted onto the block row when it is found, not
recomputed when it is paid. Those moments are ~100 blocks apart and the
chain can retarget in between; recomputing would pay a block out across
a window its own miners never worked under, and would make the same
block distribute differently depending on when the pass happened to run.
Stored, the split is reproducible from the row alone.

Transaction fees are included. Unlike pure PPS, PPLNS shares what the
block actually earned — blocks_found already had reward_sats and
fee_sats as separate columns, so this is summing two numbers that were
already there.

Two smaller ones. The share that crosses the window boundary is counted
whole rather than split: the window is a rule for choosing which work
gets paid, not a claim that exactly N difficulty was performed. And a
pool younger than its own window pays the full reward across whatever
work exists rather than scaling down — scaling down is arithmetically
tidier but leaves a remainder with nowhere honest to go, since it is the
miners' block and no third party has a claim on the difference.

pplns_distributed is an exactly-once latch, and one transaction per
block. Crediting being additive means a partial or repeated
distribution is the one failure that cannot be fixed by running again,
and that leaves no trace in the amounts themselves.

Tested against a window with older work deliberately sitting behind it:
carol mines 1000 difficulty outside the window and is paid nothing,
which is the behaviour a naive "sum all shares" query gets wrong.
Verified by mutation — removing the window bound and removing the
maturity gate each fail the suite.

Next: the payout rails. pplns-thunder needs none, the existing worker
drains pps_credits already. pplns-btc needs an L1 client mirroring
ThunderClient's surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last rail for #48, and much smaller than it was going to be.

The plan before this was for the pool to track its own coinbase outpoints,
serialise a BIP174 PSBT, and hand it to the operator to sign offline —
several hundred lines of transaction construction whose bugs would be
silent and expensive, in a binary that had never held a key or built a
transaction.

None of it needs to exist. bip300301_enforcer already ships a wallet, and
its WalletService has SendTransaction: a destinations map, a fee rate,
and it selects the inputs, signs and broadcasts itself. So pplns-btc is a
client class of about a hundred lines and no new dependency. INSTALL.md
had already been recommending an enforcer-owned pool_btc_address, which
is exactly the arrangement this needs.

The client mirrors ThunderClient's interface rather than inventing one —
balance, transferBatchDetailed, getTransaction, walletUtxos, and mine()
as a no-op because Bitcoin blocks arrive without being asked, where
Thunder only advances when a mainchain block commits to it. The payout
loop therefore never branches on which rail it is driving, and everything
that makes a payout safe is written once and shared: the write-ahead
payouts_in_flight row, one transaction per batch, and crediting paid_sats
only on confirmation.

Three things in it are load-bearing rather than defensive.

destinations is keyed by address, and two rigs can authorize with the
same payout address. Sending the list unmerged lets one entry overwrite
the other, paying that miner once for two debts while the ledger marks
both settled — a shortfall that balances perfectly on the pool's side and
is visible only to the miner. The client sums by address first. Verified
by mutation: replacing the sum with an assignment fails the suite.

An unreachable enforcer reports unknown, never confirmed and never
evicted. payout.js turns unknown into "block and ask a human", because
"the node forgot it" and "it confirmed a while ago" look identical from
here and guessing either way pays twice.

The fee is a rate, not an amount. The enforcer selects the inputs, so it
is the only party that knows the size of the transaction the fee applies
to — there is no local estimator to drift out of date.

PAYOUT_RAIL selects the rail and decides which of the two disjoint sets
of environment variables is required, so a correctly configured L1 pool
is not refused for lacking THUNDER_RPC_URL. The proxy logs what
pplns-btc needs at startup — enforcer with --enable-wallet,
pool_btc_address from that wallet, worker with PAYOUT_RAIL=btc — because
otherwise the first sign of a misconfiguration is a payout failing 100
blocks after the block was found.

77 payout assertions (was 67), dashboard 135, C suites unchanged and
clean under ASan/UBSan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: add a pplns pool_mode (pooled mining without operator variance)

1 participant