WIP: pplns pool_mode — pooled mining without operator variance - #61
Draft
rsantacroce wants to merge 3 commits into
Draft
WIP: pplns pool_mode — pooled mining without operator variance#61rsantacroce wants to merge 3 commits into
rsantacroce wants to merge 3 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft — not for merge. Targeting a release ~3 weeks out. Closes #48 when it lands.
Forks from
a2ef6f5, withv0.3.0as an ancestor: nothing here depends on unreleased or in-flight work, and the branch can sit for weeks without rotting against something unmerged.Why
solopays each miner from their own coinbase and pools nothing.pps-classicpools 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
071bf7fpool_modewas overloading; add the modes and the window knob02e19338fc6ce1The flag split
pps_enabledwas 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:solopps-classicpplns-thunderpplns-btcpplns-btcis the combination that breaks a single flag. A third came out of the same split: the accrual gate now keys onpps_accruesrather than the gate pointer —main.cinstalls 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_gatedcaught 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 offee_bps.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_enforceralready ships a wallet, andWalletService/SendTransactiontakes 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
pool_moderather than a mode plus a rail knob, so the inconsistent configuration is unrepresentable rather than merely rejectedpplns-btcrequires the enforcer running with--enable-wallet; the proxy says so at startupStill to do before this is mergeable
pplns-thunderexercised end to end (needs no new code — the existing worker already drainspps_credits)pps_creditsso balances appear, but "rate" and solvency framing are meaningless under PPLNS and the identity strip doesn't know the mode existsdocs/simplepool.htmlandREADME.mdstill describe two modesVerification 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