coinbase-direct PPLNS — pay the window from the block's own coinbase - #76
coinbase-direct PPLNS — pay the window from the block's own coinbase#76rsantacroce wants to merge 24 commits into
Conversation
First piece of the third rail proposed on #61 — pay the PPLNS window straight out of the coinbase of the block that produced it, so the pool never receives the reward at all. No wallet, no payout worker, no write-ahead row, no credit-on-confirmation. The premise holds. CLASSIC_PAYOUTS.md rules out coinbase payment because the enforcer will not credit a coinbase output as a drivechain deposit, but that is a constraint about paying INTO a sidechain. It says nothing about L1 destinations, and solo mode already pays miners straight from the coinbase. It also removes the maturity gate rather than merely the wallet. That gate exists because crediting is additive with no negative share, so a credit from a block that turns out not to be ours cannot be clawed back. Here there is nothing to claw back: a reorged block simply never paid. This commit is the primitive only — the builder plus its tests. Nothing calls it yet; the window is still computed at maturity, and wiring it to job-build time is the next step. The design question the proposal raises and does not settle is where dropped value goes, so that is what the code answers: A coinbase that pays out less than it is allowed does not leave the remainder anywhere. It destroys it. So a payee below the dust limit, or past the output cap, cannot just be skipped. Its value rides on the operator output and is reported as carry_sats, separately from fee_sats, so a ledger can tell the operator's income from its liability. That is the honest form of the cost the comment names: this is not zero custody, it is custody proportional to dust, and the number is in the result struct rather than implied. Where there is no operator address to carry to, the build is refused rather than burning it. Other decisions worth stating: - payees are paid largest first, so the cap and the dust limit fall on the smallest claims: the ones for whom waiting a block costs least and whose carried balance is smallest. - the caller's split must sum to exactly (value - fee). A shortfall would be forfeited to nobody, so it is refused as a caller bug rather than papered over. - a window where nobody clears dust is refused. Paying the operator the entire block and calling it a fee is the worst available outcome. - the output cap defaults to 200 and is not a consensus limit. Consensus bounds the coinbase by weight, and even a thousand P2WPKH outputs is a few percent of the budget; the real constraint is that some marketplaces verify a coinbase and reject one they consider oversized. - ties break by input order, so the same window builds the same coinbase twice. A miner checking the block it was paid from has to get the same answer the pool did. Nine tests, and the ones that matter read the assembled transaction rather than the builder's own report: outputs are counted and summed out of cb2, and every case asserts the whole block leaves in outputs. Mutation-verified — making the carry vanish instead of riding the operator output fails the dust test. Still to do: the window at template time, the mode itself, and the per-connection coinbase question — a shared N-output coinbase is identical for every connection and much larger, which touches the extranonce layout and the job rebuild path in stratum.c, not just this file.
Second piece. store_pplns_distribute() reads the window of a block that has already matured, anchored on that block's own share. A coinbase-direct pool needs the window a block found RIGHT NOW would pay, anchored on the newest share there is — roughly 100 blocks before the other one would run. Same walk, different anchor, and the boundary rule is copied deliberately rather than reinvented: `running - difficulty < window` compares against the total EXCLUDING the current row, which is what counts the share crossing the boundary whole instead of splitting it. If these two ever disagree, the pool pays out something other than what its template promised. Mutation-verified — changing the comparison to `running < window` fails the tests. Two decisions this raised that the distributor never had to make, because it credits a ledger where this pays an output: A worker with no payout address is left out of the split entirely, and out of the DENOMINATOR too. It cannot be given a coinbase output, and leaving it in the total would shrink everyone else's share to fund an output that is never created — value destroyed rather than merely unpaid. The distributor never meets this because pps_credits is keyed on worker_id and an address is only needed later, at payout. Truncation is reported rather than absorbed. Past `cap`, the tail is absent from both the entries and the total, so its claim is redistributed to the others rather than carried as a debt — the opposite of what the builder does with an output cap. The caller has to decide whether that is acceptable, so out_truncated says it happened rather than leaving it to be discovered in the amounts. An empty window returns 0 rather than an error: a pool that has just started has no shares, and that is the caller's cue not to build a coinbase-direct template at all. A non-positive window_diff IS an error — that is a config bug, not an empty pool. Four tests, including the one that matters: carol mines 1000 difficulty outside a 100-difficulty window and appears in neither the entries nor the total. Still to do: the mode, the wiring at job-build time (including what to do with the rounding remainder, since the builder requires an exact sum), the per-connection coinbase question, and docs.
I called the per-connection coinbase "probably the real work" when opening this. Having read stratum.c, that was wrong, and the correction matters more than the guess did. A Stratum coinbase is cb1 + extranonce1 + extranonce2 + cb2. The extranonce lives in the scriptSig; the OUTPUTS live in cb2. So an N-output coinbase is structurally identical to what pps-classic and pplns already do — every connection gets the same cb2, and only the extranonce differs. There is no conflict with the per-connection extranonce layout, and conn_render_coinbase already renders an identical coinbase for every miner in the pooled modes. cb2 just gets bigger. The real gap was somewhere else, and it would have made the rail unusable in production. When the enforcer serves the template, the coinbase comes from the SERVER, carrying the BIP300/301 commitment OP_RETURNs and the witness commitment, and coinbase_build_from_template replaces its single spendable output. That function pays one miner and an optional fee. Every simplepool deployment mines on an enforcer template, so a window builder that only works from scratch is a rail that does not work where the pool actually runs. So the template builder now takes a resolver callback instead of a miner address. It parses, finds the reward, and hands it to the caller to turn into concrete outputs — one miner and a fee, or a whole window. A callback rather than an argument because the split depends on the reward and the reward is only known after parsing, so the caller cannot compute it up front and the parser should not have to know which kind of pool it is serving. The public coinbase_build_from_template keeps its exact signature and behaviour; it is now a thin wrapper over the same impl, which is what kept its existing tests as the safety net through the refactor. Both window builders share ONE resolver. That is the point of the change rather than a tidiness argument: a pool mining a drivechain template and a pool mining plain bitcoind must divide the same window into the same amounts, and two copies of dust, cap, carry and ranking would eventually disagree. A test asserts they produce identical results — same paid_count, paid_sats, fee_sats, carry_sats and dropped_dust — over a window containing a dust payee. Both new tests read the assembled transaction rather than the builder's report: one spendable output becomes two, every OP_RETURN the enforcer put there survives, and the outputs still sum to the whole reward. Mutation-verified — leaving the output count at the template's own vout fails. coinbase.c is at 87% line coverage; whole-project 79.9%. Still to do: the mode, the wiring at job-build time, carry-forward in the ledger, and docs.
pool_mode = pplns-coinbase. The window is snapshotted onto the job when the template is built, and the coinbase of the block that window produced pays every one of them directly. The pool never receives the reward, so there is no wallet, no payout worker, no write-ahead row and no maturity gate. Config refuses pool_btc_address in this mode rather than ignoring it. The whole claim here is that the pool never holds the reward, and a configured pool wallet is the shape of a pool that does — most likely a mode switched in place without the rest of the config following. Running a custodial-looking pool that quietly is not one is worse than refusing to start. coinbase_pays_window is a third state, not a variation on the other two: solo pays the finder, the pooled modes pay the pool, and this pays the window. main.c sets them so the reward can go to the miners or to the pool, never both. The window rides on the JOB rather than the connection, which follows from what a Stratum coinbase is: cb1 + extranonce1 + extranonce2 + cb2, outputs in cb2, extranonce in the scriptSig. Every connection therefore renders the same coinbase, exactly as the pooled modes already do. A test asserts that two connections get identical output counts, because "the window belongs to the job" is the property the whole design rests on. Three decisions that could each have been made silently: A job with no window is never published, and if one reaches the renderer it refuses. A coinbase paying nobody does not pay less — it forfeits the whole block. Better to render nothing and let the miner wait for the next template, which is at most one poll interval away. Truncating division leaves a few satoshis over, and they cannot be dropped: the builder requires the split to spend the payable amount exactly, and a coinbase that pays out less forfeits the difference. They go to the largest claim, which is the miner with the strongest claim on them. The first job of the process deliberately carries no window. Network difficulty has not been read yet, and a process that just started has no shares to pay anyway — so it would be empty even if it could be sized. Said in a comment rather than left as a mystery for whoever sees the first "refusing to render" line in a log. Tests: config accepts the mode without a pool wallet, refuses it with one, and still validates the window knob; a job with a window renders exactly one output per miner and nothing else, identically for two connections; a windowless job renders nothing at all. Mutation-verified — making the mode fall through to solo fails two of them. Both config paths were also checked against the real binary rather than only in tests. Still to do: the e2e, carry-forward in the ledger, and docs.
The e2e found that a brand-new pplns-coinbase pool could never start.
WARN stratum: no PPLNS window on job … — refusing to render a coinbase
that would pay nobody
INFO pplns-coinbase: no shares in the window yet — holding this template
back rather than mining a block that pays nobody
INFO pplns-coinbase: no shares in the window yet — holding this template
back rather than mining a block that pays nobody
No shares means no window, no window means no coinbase, no coinbase means no
miner can work, and no work means no shares. Forever. Every unit test passed
throughout: each half of that loop is correct on its own, and only running it
shows they close.
The fix is not a special case so much as noticing what PPLNS over an empty
window degenerates to. With no prior work, the only party with a claim on the
block is whoever finds it — which is solo. So a windowless job renders the
solo shape, per connection, and the first accepted share ends it permanently.
Both the empty-window template path and the first job of a process now say so
in the log. An operator watching the first block of a new pool go entirely to
its finder deserves to know that was deliberate rather than the window
silently failing.
The e2e proves what only the chain can:
- the pool starts and mines with NO pool_btc_address configured
- the coinbase pays the miner 4950000000 sats and the operator 50000000,
read out of the block rather than out of anything simplepool wrote
- no output pays a third address. There is no pool wallet in this mode, so
any other address would be the pool holding the reward — the one claim a
bookkeeping bug cannot fake
- pps_credits stays EMPTY. A balance there would mean the pool believes it
owes money it has already paid on chain
- and it mines a SECOND block to prove the mode rather than only the
bootstrap, asserting the log shows a template built from a real window
That last stage exists because of a mistake worth recording: the first
version asserted the tip watcher had logged an empty window, which passed
once and then failed. On a regtest chain the first block is found about a
second after startup, before the first tip change — so whether the watcher
ever SEES an empty window is a race, and the bootstrap actually happened via
the initial job, which logged nothing at all. The assertion now keys on the
initial job's own message, which is deterministic, and the second block is
what proves the window path.
CI runs it after the other two pplns suites.
Wired4ncer, who has run a coinbase-direct PPLNS pool on ECX alpha since 2026-08-19, gave numbers on #61 that show the cap I built measures the wrong thing: - up to 16 miners paid per block - whole coinbases of 721-817 bytes - and the binding term is NOT the payouts. The same 16 payouts cost 817 bytes against four drivechain OP_RETURNs and 769 against three. A cap counted in outputs cannot express that, because the commitments are not outputs it counts. Worse, the number was badly wrong in the same direction: 200 outputs at ~31 bytes each is over 6000 bytes of payouts alone, roughly eight times what a marketplace is observed to accept. A pool trusting that default would have had jobs refused. So the limit is now the whole serialized coinbase in bytes. Everything that is not a payout is charged first — the transaction envelope, the scriptSig, the operator output, and the commitment OP_RETURNs the template already carries — and the payouts get what is left. The commitments are simply part of what has been spent, which is exactly the relationship the count could not see. A test pins it as a relationship rather than against someone else's absolute numbers, which would be asserting on another implementation: the same window and the same budget, built from an enforcer template versus from scratch, pays strictly fewer miners from the template. Measured at a 300-byte budget: 5 against 6. A second test builds across a range of budgets and asserts the bytes on the wire never exceed the number, because a budget is only worth having if it is true of the transaction and not just of the accounting. Two details the byte accounting forced, both of which a per-output figure would have got wrong: Cost is computed per address, after resolving it. A P2TR payout is 43 bytes against a P2WPKH one's 31, so budgeting at a flat rate lets more through than fits. A payee that does not fit does not stop the loop. A later, cheaper address may still fit, and stopping early would carry money that could have been paid. coinbase_max_bytes is configurable, as he asked, because the number belongs to whichever marketplace an operator sells to rather than to us. Default 1000, documented in proxy.conf.example with where the figure comes from. A value too small to hold a coinbase and one payout is refused: that is a pool that cannot run, not one that runs badly. The remaining item from his scope is the carry-forward ledger. carry_sats is computed and reported but still nothing consumes it — today the dust a miner is owed rides on the operator output and is not recorded as owed to anyone. He reports 62 of 100 addresses sitting below the floor, so it is not a corner.
CI caught a race my local runs hid. The second miner connected while the pool
was still serving the job for the height just mined — the tip watcher polls
every 500ms — so it mined a SIBLING of that block rather than a successor:
submitblock -> null (first block, accepted)
submitblock -> "inconclusive" (sibling, 60ms later, rejected)
height: 11 -> 11
"inconclusive" is bitcoind saying the block neither extends nor replaces the
tip, which is exactly what a sibling is. Nothing to do with the mode, the
window or the coinbase; the stage was simply mining against a stale job.
It now waits for the pool to publish a job at the next height before starting
the second miner, and says which height it is serving. A timing assumption
that happens to hold on one machine is not a test.
Until now carry_sats was computed, reported, and then forgotten. A claim too small for a coinbase output rode on the operator output — so the operator was holding it — and nothing anywhere said whose it was. That is not a small custodial balance, it is money kept quietly. Wired4ncer names this as the mode's first cost on #61 and reports 62 of 100 addresses currently sitting below the floor, so it is the common case rather than an edge. The ledger is now written per worker, into the same pps_credits table the other rails use, so a carried claim shows up wherever a miner's balance already shows up. Three decisions in that: Only the UNPAID part is recorded. A claim the coinbase paid is settled on chain and has no business in a ledger of what is owed; a partly paid claim carries only its remainder. Recording the full claim would have the pool owing money it had already paid, and a zero row would put every settled miner into a ledger of debts the pool does not have. It is written when a block is FOUND, which is the only moment the information exists. The coinbase is decided when the template is built, but almost no template becomes a block, so nothing can be written earlier without recording debts for blocks that were never mined. The outcome is recomputed at that moment rather than remembered. The split is deterministic from the job and the config, so running the same builder again reproduces exactly what was rendered, and a found block is rare enough that the cost is irrelevant. The alternative — carrying the result along the submit path — would mean the ledger and the coinbase could disagree. Worker ids now ride on the job beside the payees, because an address is not enough: two rigs can share one and the ledger is per worker. Four store tests, mutation-verified: recording the full claim instead of the remainder fails them. Two things worth stating plainly. The e2e does NOT cover carry with anything in it. Carry needs a window where some claims fit the coinbase and others do not, which needs several miners of very different sizes, and the harness drives one cpuminer. Squeezing the byte budget instead does not produce it either: when nothing fits, the builder refuses, no coinbase is rendered, and no block is found. I wrote that stage, watched it print "carried: 0 sats" and pass regardless, and removed it — a stage that cannot fail is worse than an absent one. The gap is now stated in the file rather than papered over. And carry is recorded but not yet CLEARED: a carried claim does not currently raise the miner's share of a later block. Clearing it has to come out of the operator's fee, since the money is already on chain in the operator's output, and that is a design question rather than an oversight — it is the next piece.
…ing them The carry ledger is gone. A claim the coinbase cannot pay -- below the payout floor, or with no room left in the byte budget -- now rides on the operator output permanently. It is income, not a debt: nothing records it and nothing settles it later. This is a policy decision, not an accounting convenience. The alternative was the carried balance this rail shipped with, which reintroduced exactly the custodial ledger the mode exists to delete: a debt, an off-chain record of it, and a settlement that can fail. Forfeiting keeps the property that the block IS the payment, at the price of a hard floor under who this pool is worth mining at. A miner too small to clear it earns nothing here however long it mines, and is better off solo mining, where it at least holds a lottery ticket. That is only a rule rather than a trap if the miner can see it, so the floor is now disclosed three ways: stated at startup, reported per template as the number of miners about to be excluded, and reported per block as the claims actually forfeited and for how much. The e2e asserts all three, and a mutation that reworded the startup line was caught by it. Also fixes a latent stall this made visible. attach_pplns_window() divided the template's coinbasevalue, which is the SUM of every coinbase output, while the builders check the payees against the single spendable output they replace. They agree whenever the commitments carry no value -- the only shape seen in practice -- but on a node where they did not, every render on every connection would be refused and the pool would stop publishing work with nothing but a repeated warning to explain it. coinbase_template_reward() now asks the transaction, and is asserted against the builder rather than a constant. - new pplns_payout_floor_sats (default 546, clamped up to the dust limit) - COINBASE_DUST_SATS promoted to coinbase.h; three copies of 546 were three chances to disagree - removes store_record_window_carry(), window_outcome_fn, and the per-job worker-id array that existed only to name who was owed - README, INSTALL, proxy.conf.example and docs/simplepool.html carry the fifth mode and say plainly that small claims are forfeited Verified: make test, make asan, all five regtest e2e suites, both node suites; six mutations of the new floor/reward logic all killed.
Solo is the default mode and the one most operators run, and it had no
end-to-end test anywhere. tests/test_integration.sh looks like one and is
not: it subscribes, authorizes, submits one deliberately bogus share and
asserts a reject row. It never mines, so it cannot see the thing solo IS --
a coinbase paying the finder -- and it is not in CI either. The mode with
the fewest moving parts had the weakest evidence.
test_solo_regtest.sh mines two blocks through stratum with two different
miner addresses and asserts, from the chain:
- each block's coinbase pays ITS OWN finder, plus the operator fee, and
nothing else. Two addresses is the whole point: a regression that
rendered one coinbase for every connection would still pass a
single-miner test, and conn_render_coinbase() is shared with the pooled
modes, so that is a live risk rather than a hypothetical one.
- every OP_RETURN the enforcer's template carried is still there. Counted
from the template at mining time rather than hardcoded, because
BIP300/301 commitments come and go with sidechain activity and a
constant would be vacuous or wrong depending on the day.
- nothing is credited off-chain, and no pplns or window code path ran.
- the pool reports mode=solo. The config sets no pool_mode at all, so this
pins the default: if it drifted to a pooled mode every coinbase
assertion above would still pass, since a one-miner window pays the same
address.
Verified by mutation: rendering the solo coinbase to a fixed address instead
of the connection's own is caught the moment miner B mines.
Also fixes the last grep in that suite matching the startup banner's git
branch name rather than the binary's behaviour, and wipes the pool DB on
start -- without it "at least 2 blocks" was an assertion about every previous
run, which is how a stale-state pass hides a regression.
README now lists what each end-to-end suite proves, since "there is also a
full end-to-end regtest" undersold five of them and omitted this one.
… mode solo
The forfeit policy rests entirely on being disclosed up front, and the
disclosure stopped at the operator's terminal. The proxy stated the floor at
startup and per block; the miner it actually costs reads the dashboard, and
the dashboard could not see the number because the proxy never published it.
A policy nobody can check from outside is a surprise, not a policy.
pool_meta now carries pplns_payout_floor_sats, NULL in every mode but
pplns-coinbase — distinctly from 0, which is a real floor meaning "pay
anything the dust limit allows". The miner-facing card states it before
anyone connects, in the words that matter: not carried forward, not paid
later, and a floor on how small a miner this pool is worth using. It renders
only when the proxy actually published a floor; an older proxy stores NULL,
and defaulting to 546 there would be stating someone else's policy for them.
Checking that turned up three places answering "not pps-classic" with the
word "solo", so every PPLNS pool was told it was solo by the same page whose
header named the mode correctly:
- the worker page read "Owed: N/A (solo mode)" — shown on all three pplns
rails, and on a pplns pool that simply had not distributed yet
- the health check read "solo — no accrual"
- the templates page read "PPS rate: n/a (solo)"
And the miner-facing card branched on pps-classic/solo only, so all three
pplns modes fell through to "This pool has not published its mode yet"
followed by guidance for two modes, neither of which was theirs. Each mode
now gets its own prose and the right username type; the unknown-mode branch
names all five rather than the two it was written for.
Worst of the set: "Pool solvency" summed blocks_found.reward_sats as pool
revenue in pplns-coinbase, where that is what the block paid the MINERS. It
reported a healthy 50 BTC margin for a pool that holds nothing and has no
wallet — a green light asserting custody that does not exist. Now skipped
with the reason, and still exact where custody is real.
Verified against a real pplns-coinbase DB from the regtest e2e, plus nine new
tests. Seven mutations — hiding the floor, describing it as carried, showing
a default where the proxy published none, collapsing a zero floor to no
floor, counting solvency again, restoring the "solo" label, and dropping the
mode branch — all killed. 155 dashboard tests, 87 payout, full C suite, ASan,
and all six regtest e2e suites pass.
…he first template It was logged down by the stratum config, which runs only after the initial getblocktemplate succeeds. A configured fact should not be contingent on the node answering: an operator debugging a pool that cannot reach its backend saw the mode but not the policy. It now prints beside the pool identity line, and mentions that the dashboard carries the same number to miners.
The mixed-window forfeit was called an untested path. Chasing it turned up
something worse: attach_pplns_window() had no test AT ALL. It is static in
main.c, so nothing could reach it — and it holds the fee split, the
floating-point difficulty-to-satoshis division, the remainder rule and the
below-floor prediction. A bug there does not crash and does not log; it pays
somebody the wrong amount, which is the failure this rail must be trusted not
to have.
pplns.c now holds that arithmetic, the same extraction reconcile.c got for the
confirmation pass. Pure: no store, no template, no logging, so an expected
split can be stated exactly instead of mined for. main.c keeps the parts that
need the world.
That makes the mixed windows testable, which the regtest harness cannot
produce: share difficulty is clamped to network difficulty on regtest, so the
window holds about two shares and every run reports "window of 1 miner(s)".
test_pplns.c drives a few large claims and a tail of small ones, asserts which
the floor will drop and that the block is still spent whole, and walks the
floor up through four values checking the prediction tracks it.
Two findings while writing it, both mine and both instructive:
- my first expected values were wrong twice. 100,000,002 divides by three
exactly, and a clean 60% claim comes to 187,500,001 rather than
187,500,000 because the other claims truncate down and the remainder rule
puts the satoshi on the largest. Asserting the tidy number would have
been asserting a bug.
- mutation testing left three survivors, all boundary-exact. Changing the
fee from floor to ceiling division was invisible, and that one is not
cosmetic: coinbase_build_window() computes the fee itself and REFUSES a
split that disagrees by a satoshi, so the pool would render no coinbase
at all, on every connection, on every job. Closed by asserting against
the builder rather than against a constant — five rewards whose fee does
not divide evenly, each fed through the real builder. The other two were
the floor and dust comparisons; a claim worth exactly the floor is PAID,
and a 545-sat fee is dust.
Twelve mutations now killed. Plus a 20,000-iteration conservation check over
random windows of deliberately mixed magnitudes: paid + fee == reward,
exactly, with no negative payee.
Wired into make test, make asan and make coverage. Full C suite, ASan, and the
solo and coinbase e2e suites all pass unchanged — the extraction is meant to
be behaviour-preserving and the chain says it is.
The last uncovered path in this mode. Claims of 100 : 10 : 1 with a floor
between the last two: the first two are paid in the block's coinbase, the
third gets no output at all, and its satoshis turn up on the operator's.
4459459461 sats -> BIG (100 shares, incl. the rounding remainder)
445945945 sats -> MID (10)
94594594 sats -> operator = 50,000,000 fee + 44,594,594 forfeited
— sats -> SMALL (1) — no output
1 claim(s) worth 44594594 sats were forfeited
pps_credits rows=0
Why it could not be mined for before: share difficulty is clamped to network
difficulty on regtest, so a 2.0x window holds about two shares — every other
stage in this file reports "window of 1 miner(s)". A mixed window needs both a
much wider multiple and a share history, so this seeds the shares table
directly with the pool stopped. That is replaying the pool's own record of
accepted work, not stubbing what is under test: the window query, the split,
the builder, the block, and the outputs read back off the chain are all real,
and the amounts match the arithmetic exactly, remainder included.
Two things this cost, both worth recording. The first run failed claiming the
pool never warned about the small miner — it had simply not built a second job
yet, because the first job of a process carries no window (network difficulty
is unread until a template arrives) and the tip watcher rebuilds on a new tip
or a 30-second refresh. A 20-second wait read as a missing warning. And the
stage is mutation-verified: dropping the floor check in the builder pays SMALL
and the suite catches it in the block, which is the only place that mattered.
#78 indexed the share-dedupe ring, which lands in the same part of tests/test_stratum.c this branch appended its pplns-coinbase cases to. The conflict is purely additive — both sides added test functions at the same point — so both are kept. 485 stratum assertions pass on the merge.
Seven documents discussed pool modes and none of them mentioned
pplns-coinbase. Two were actively misleading rather than merely incomplete:
- INSTALL.md's "Part F — payout worker (every mode except solo)". Wrong:
pplns-coinbase needs no payout worker either. An operator following it
would install and monitor a service that finds an empty ledger forever.
Retitled, and both no-worker modes are now a row in the rail table with
an explicit "skip this whole part".
- payout/README.md's pool_mode -> PAYOUT_RAIL table had no row for
pplns-coinbase, so someone reading it would hunt for the right rail and
find none. Same fix: the mode is in the table, saying the coinbase is
the payment.
The rest were gaps:
- INSTALL.md gained a pplns-coinbase config section — the keys, the two
limits, and the forfeit policy stated plainly, since it is the one thing
an operator has to decide rather than configure.
- VERIFY.md was titled "pps-thunder — verification checklist" and organised
by the commits that landed it, predating four of the five modes. It now
says so, points at the one end-to-end suite per mode that supersedes a
manual pass, and carries a new section 13 for pplns-coinbase: the config
refusals, the four disclosure points, the money read off the CHAIN rather
than the pool's own database, and the byte budget.
- dashboard/README.md documented a card branching on solo/pps-classic. That
is the code this branch changed, so the table now covers five modes and
records where the "not pps-classic means solo" mislabels were, for
whoever adds a sixth.
- tests/README.md listed two integration suites; there are six. It also now
says plainly that test_integration.sh looks like a solo end-to-end test
and is not — it never mines.
- OPERATOR_GUIDE.md and CLASSIC_PAYOUTS.md are legitimately scoped to
pps-classic, so they say so and point at the modes they do not cover.
CLASSIC_PAYOUTS additionally notes that its finding rules out depositing
from the coinbase to a SIDECHAIN, and says nothing against paying miners
on L1 from the coinbase — which is what solo and pplns-coinbase do.
- scripts/regtest/README.md described a stack serving one mode; it serves
all five now, one suite each.
Every internal link and anchor added here resolves.
|
Thank you for building this — and for asking before finishing it. I read the whole branch. The parts I would have worried about are already right: So the shape matches what we run. There is one decision I would change, and one 1. Where the unpayable money goesToday a payee below the floor, or past the byte budget, has its sats added to A small miner's share of one block is small in every block, not occasionally. We hit exactly this and solved it in a way that keeps the property you are Pay the whole reward to the miners you can pay. Instead of the dropped Remember the unfairness as a fraction, not as sats. This is the part that
The pool holds no funds at any point. Nothing is ever withheld from a coinbase Why a fraction and not raw difficulty: shares stay in the window across several One thing we got wrong first, in case it saves you the same bug. Ranking by 2. The window query runs on every template
Our shares database is 5.5 GB. It would not survive this. Bounding the walk — 3. A comment that disagrees with the code
The offerHappy to write the per-connection coinbase — it is the item still on your list Say the word on the ledger question and I will send whichever you prefer: the |
The HTML explainer had deep sections for solo and pps-classic only, so three
of the five modes were named in the overview and never explained. It also
still described the payout worker as pps-classic's, and its stack diagram
labelled that worker "pps-classic only".
New section "How each mode pays, step by step": five inline-SVG sequence
diagrams, one per mode plus the payout protocol, drawn in the page's existing
idiom — hand-written SVG, no library, colours from the page's own CSS
variables so they follow the reader's theme. Verified rendered in both light
and dark, not just in the markup.
What the diagrams are for is the thing prose kept burying: WHEN a miner's work
becomes money, and whether the pool ever holds it. Solo and pplns-coinbase
have no step after the block — the coinbase is the payment. pps-classic
credits before it has earned anything, which is what the reserve funds. The
two custodial rails credit on maturity. Drawn side by side that is one glance
rather than four sections.
Also corrected, all of it stale rather than merely thin:
- "Payouts over Thunder" is now "Payouts, and the modes that need none",
opening with which three modes run a worker and which two must not.
- the stack diagram's "pps-classic only" label on the payout worker, and the
prose under it claiming only solo can skip the right-hand half.
- the config table said pool_btc_address was "pps-classic only"; it is
required by three modes and REFUSED by pplns-coinbase, where setting it is
a config error rather than a no-op.
- the accrual-gate note said the gate skips solo; it skips everything except
pps-classic, because nothing else prices a share on arrival.
Every internal anchor still resolves.
store.h and store.c both say out_total_diff covers exactly the rows returned — when the window is truncated the tail leaves the total as well as the entries. pplns.h said the total still counted the truncated rows. The code is the safe version, so nothing is mispaid today. The comment is still worth fixing before it becomes true: acting on it would make the denominator larger than the claims sum, every payee would be shorted, and the remainder rule would land the entire shortfall on out[0] — the largest miner silently absorbing everyone else's share. The assigned > payable guard catches only the opposite error. Caught by Wired4ncer reviewing #76.
…anning every share Two findings from Wired4ncer's review of #76, both verified on this branch. ## The operator was taking a quarter of the block A claim below the payout floor or past the byte budget had its sats added to the operator's output. The rule was defended as a dust policy. It was not one. Measured here: 100 miners on a 1/n hashrate spread, default 1000-byte budget. 28 paid, 72 cut by the byte cap, NONE by the dust floor — and the operator received 25.05% of the block on a 1% advertised fee. Two things made that indefensible rather than merely harsh. A miner's window share tracks its hashrate, so the same miners fall below the cut every block: the rule paid them nothing ever, not occasionally. And the operator's take rose as the coinbase shrank — 46% of the block at a 400-byte budget against 2% at 3000 — so starving your own miners was the revenue-maximising move. Dropped claims are now redistributed across the miners the coinbase COULD pay. Every property the forfeit had is kept: the block still pays out to the satoshi, the pool still holds nothing, no ledger appears. What changes is only who receives what there was no room for — the other miners, not the house. The operator's take is now 1.00% at every budget from 400 to 3000 bytes, and a test pins that it cannot be moved by tightening the coinbase. A pool with no operator_address can now also run this mode, since nothing but the fee lands there any more. ## store_pplns_window() re-read the entire shares table, every template The running SUM() was computed over all of `shares` and the window boundary applied afterwards, so there was no early exit and no bound. Measured at 250ms per million rows, linear, on the template thread. The pool that reported this runs a 5.5 GB shares database — order of a hundred million rows, half a minute per template — at which point it stops publishing work entirely. It now walks back in bounded batches, doubling until the batch covers the window, and the aggregate uses the primary-key index from that boundary. Same answer, same boundary rule (the share crossing it still counts whole, matching store_pplns_distribute exactly). 4,000,000 shares: 1033 ms -> 2.88 ms 8,000,000 shares: 1.08 ms 50,000 shares: 1.03 ms Flat in history size rather than linear. test_store.c covers the widening path, which is the part that could silently return a partial window: a window wider than the first batch, and one wider than the whole table.
…ipped for ever
The third of Wired4ncer's three points, and the one that needed a design
rather than a fix. Redistribution stopped the operator taking the money a
coinbase had no room for, but it did not change WHO the coinbase has room for:
a miner's window share tracks its hashrate, so the largest claims take the same
slots every block and the same addresses are never paid.
His measurement on a production pool: over 31 blocks, 279 payout slots reached
34 addresses, 12 of which took 91% of them, while 88 addresses got nothing —
and 28 of those cleared the payout floor comfortably. The floor was not what
excluded them, and no additive ranking fixes it, because a large miner's share
of the current window beats any priority a small one can accumulate.
So a fraction of the slots is reserved outright for whoever has waited longest.
Costs no coinbase bytes, changes nobody's total, changes only how often people
are paid.
What is remembered, and what is not:
- a signed fraction of ONE block reward per worker, in pplns_fractions.
Positive means skipped and first in the queue; negative means paid early
out of somebody else's skipped share. The column sums to zero.
- it is NOT a balance and the pool holds nothing against it. Nothing is
withheld from a coinbase and released later — that would need a block
paying less than the reward followed by one paying more, and the second is
invalid. Delete the table and nobody is owed a payment; the pool just
forgets whose turn it was.
- fractions rather than difficulty, because shares stay in the window across
several blocks (rolling unpaid difficulty forward counts the same work
twice) and difficulty is not comparable across a retarget.
Orphans get this right. Deltas are staged against the block hash when a block
is found, which is a CANDIDATE, and the confirmation pass applies them only
once the block is confirmed — discarding them if it is orphaned. Applying at
found time would record a rotation that never happened and move a miner down
the queue for a payment it never received.
Two bugs found writing it, both mine:
- the builder sorted payees largest-first internally, so no ordering policy
was expressible at all. It now pays down the order the caller gives, and
the rounding remainder goes to the largest claim PAID rather than to
index 0, which was only the largest while the builder did its own sort.
- the first delta computation divided by res.paid_sats, which redistribution
sets to the whole payable amount — so every delta came out as exactly zero
and the queue silently recorded nobody. Caught by the e2e, which asserts
the queue is non-empty after a block that skipped someone rather than
merely that it balances. A test for zero-sum alone would have passed.
Verified end to end: a 100:10:1 window on a real chain pays the two that fit,
redistributes the third's 44,594,596 sats across them, leaves the operator
holding its 50,000,000-sat fee to the satoshi, and stages 3 queue rows summing
to zero with the skipped miner owed 9/1000 of a block. Full C suite, ASan, all
six regtest suites, both node suites.
Wired4ncer's fourth point, and the last one outstanding. The ceiling that
actually binds is a MARKETPLACE rule — whoever rents you hashrate verifies the
coinbase and refuses a job it considers oversized — and it applies to the port
they connect to and nowhere else. Every byte of it costs a payout: measured on
this branch, a 100-miner window pays 9 at a 400-byte ceiling and 93 at 3000.
Applying a rental market's limit to your own miners' port therefore buys
nothing and costs them their slots.
`max_coinbase_bytes` is now settable on a `listener` line, overriding the
server-wide value; 0 or absent means "use the server-wide one", the same
convention the other per-listener fields already use. The precedent is exact —
`min_diff` exists on a listener for the same reason, with the same comment
about a marketplace measuring what the port advertises.
coinbase_max_bytes = 3000
listener = port=3335 label=rental min_diff=500000 initial_diff=500000 max_coinbase_bytes=900
In pplns-coinbase this changes how many of the window a given port is served,
not the window itself: the payees and their priority order come off the job,
and each port takes as many of them as it can fit. A test drives two
connections against ONE job and gets 20 payouts on the home port against 12 on
the rented one.
One correctness detail worth naming. The coinbase is rendered in one place and
re-derived in another — to work out what a found block actually paid, for the
payout queue — and those two using different ceilings would record a rotation
that never happened. Both now go through a single conn_coinbase_budget()
accessor, so they cannot disagree.
A listener ceiling too small to hold one payout is refused at config load, the
same as the server-wide setting: a port that can pay nobody is not a port.
…e code no longer has
Redistribution and the payout queue landed in the code and the docs kept
saying the opposite. Nine files claimed a dropped claim is "forfeited to the
operator, permanently, not carried, not settled later" — which was true for
about a day and is now exactly backwards. Left alone it would have been worse
than no documentation: an operator reading it would have advertised a policy
their pool does not run, and a miner reading it would have been told their
money goes to the house when it goes to the other miners.
Corrected in README, INSTALL, VERIFY, OPERATOR_GUIDE, docs/simplepool.html,
proxy.conf.example, and the dashboard/payout/tests READMEs — plus the two
places it actually reaches people:
- the miner-facing dashboard card, which is the one a miner reads before
pointing a rig anywhere. It now says a single block may not pay everyone,
that what it cannot pay is shared among the miners it could, that the
operator takes only its fee, and that being small costs frequency rather
than money. Its tests were asserting the old wording, so they asserted the
new promise instead, including a doesNotMatch on "goes to the operator".
- the pplns-coinbase sequence diagram in docs/simplepool.html, which had
"FORFEITED to the operator" drawn into the SVG. Regenerated: it now shows
the ordering step, the staging of who was skipped, and that an orphaned
block rotates nobody. Rendered and looked at, not just re-emitted.
Also documented for the first time, since none of it existed when these pages
were last written: the payout queue itself (a signed fraction of a block reward
per worker, summing to zero, holding no money), the reserved slots that make it
work, and the per-listener coinbase ceiling.
VERIFY's section 13 gained the check that matters most — the operator output
must be EXACTLY fee_bps of the block, on every block, including ones that could
not pay the whole window. That is the one an operator can run to prove the 25%
bug is gone.
|
@Wired4ncer thank you for taking a look, now I'm sending more code towards your direction.
|
The five diagrams in docs/simplepool.html were machine-generated and the
generator existed only in a scratch directory. That is how documentation goes
stale: the committed artefact is ~4 KB per diagram of computed coordinates,
so the cheap path for the next person is to edit the prose around it and leave
the picture alone.
Not hypothetical. These diagrams already went stale once, in the space of a
day — the pplns-coinbase forfeit rule was reversed and "FORFEITED to the
operator" stayed drawn into the SVG, contradicting the paragraph directly
above it.
docs/sequence-diagrams.py now holds the drawing code and the specs, and
splices its output between HTML comment markers so a regeneration replaces
exactly the drawings and nothing around them. Prose about a diagram lives
outside the markers and stays hand-written.
python3 docs/sequence-diagrams.py # rewrite in place
python3 docs/sequence-diagrams.py --check # fail if out of date
One bug fixed to make that work: element ids came from Python's hash(), which
is randomised per process, so every run produced a different id and the script
could not reproduce its own output. They are sha1-derived now, which is what
makes --check meaningful — a no-op run is byte-identical.
CI runs --check in check_build.yaml. Verified it fails on a hand-edited
diagram and passes on the committed one, and the regenerated page still
renders correctly in light and dark.
The only change to the HTML is the markers, the whitespace around <figure>,
and the new deterministic ids; no diagram was redrawn.
|
Thank you for taking the whole review — the redistribution, the payout queue, the I ran the branch against our production shares database before saying anything, and the To be clear about where this comes from: our pool does not run this branch, and the But I think there is one problem left, and it is the kind that pays out wrong instead of The walk can give up early and still report successThe widening loop ends on
None of these returns an error. The function returns the number of payees and the caller Why I think this one matters more than it looks. In the other modes a bad number can be On reachability, so nobody has to take my word for it: the store opens one connection with What it does todayI injected a failure into the boundary query on this branch and left everything else alone. Both returned a payee list and a success code. The fix I would suggestMake the walk finish only when it has proved one of two things: that it covered the While doing that, the What does not work is taking I have this written and tested against Two smaller things I noticed while measuring, neither urgent:
|
Draft — the first piece only. Nothing calls the new code yet.
Implements the coinbase-direct rail @Wired4ncer proposed in
#61 (comment) —
pay the PPLNS window straight out of the coinbase of the block that produced
it, so the pool never receives the reward at all. No wallet, no payout
worker, no write-ahead row, no credit-on-confirmation.
Based on
2026-08-25-pplns-moderather thanmain, because it needs the flagsplit from that branch — as the proposal says, the split is what makes a third
rail expressible at all.
The premise checks out
CLASSIC_PAYOUTS.mdrules out coinbase payment because the enforcer will notcredit a coinbase output as a drivechain deposit. That constraint is about
paying into a sidechain. It says nothing about L1 destinations, and
solomode already pays miners straight from the coinbase.
It also removes more than the wallet: it removes the maturity gate. That
gate exists because crediting is additive with no negative share, so a credit
from a block that turns out not to be ours cannot be clawed back. Here there
is nothing to claw back — a reorged block simply never paid.
What is in this PR
coinbase_build_window()and its tests. The primitive everything else needs,and nothing else.
Where dropped value goes
The proposal names dust as a cost but leaves open what actually happens to the
sats. The answer is forced:
So a payee below the dust limit, or past the output cap, cannot simply be
skipped. Its value rides on the operator output and is reported as
carry_sats, separately fromfee_sats, so a ledger can tell theoperator's income from its liability.
That is the honest form of the cost: this is not zero custody, it is custody
proportional to dust, and the number is in the result struct rather than
implied. Where there is no operator address to carry to, the build is refused
rather than burning it.
Other decisions
ones — least cost to wait, smallest carried balance.
value - fee. A shortfall wouldbe forfeited to nobody, so it is refused as a caller bug.
entire block and calling it a fee is the worst available outcome.
the coinbase by weight, and even a thousand P2WPKH outputs is a few percent
of the budget. The real constraint is that some marketplaces verify a
coinbase and reject one they consider oversized.
same coinbase twice. A miner checking the block it was paid from has to get
the same answer the pool did.
Testing
Nine tests. The ones that matter read the assembled transaction rather
than the builder's own report: outputs are counted and summed out of
cb2,and every case asserts the whole block leaves in outputs. Mutation-verified —
making the carry vanish instead of riding the operator output fails the dust
test.
make testandmake asanclean.Status
Everything on the original list is done. Leaving the old checklist here would
be worse than deleting it — @Wired4ncer read it and offered to write the
per-connection coinbase, which is already built.
attach_pplns_window()inmain.c, over the shares in hand when the template is builtpplns-coinbase, withcoinbase_pays_windowas thefourth flag: it pays neither the miner (
solo) nor the poolN-output coinbase whose payout bytes live in
cb2, so it is identicalacross connections and only the extranonce differs.
test_stratum.casserts two connections receive the same coinbase for the same job, and
tests/test_pplns_coinbase_regtest.shproves it on a real chainproxy.conf.example,docs/simplepool.html(with sequence diagrams per mode), VERIFY, and the dashboard/payout/tests
READMEs
Since then: a
soloend-to-end suite (the default mode had none anywhere), thewindow split lifted out of
main.cinto a testablepplns.c, and fourdashboard bugs where every non-
pps-classicmode was labelled "solo".The carry question, answered the other way
carry_satsis gone. A claim below the payout floor, or past the byte budget,is forfeited to the operator — not carried, not recorded, not settled.
The floor is
pplns_payout_floor_sats(default 546) and it is disclosed atstartup, per template, per block, and on the dashboard before a miner connects.
@Wired4ncer has since made a strong case against this in
#76 (comment),
and measurement on this branch supports him: with 100 miners on a 1/n hashrate
spread and the default 1000-byte budget, 28 are paid, 72 are cut by the byte
cap, none by the dust floor, and the operator receives 25% of the block on
a 1% fee. Under discussion; the rule as merged is not the last word.
@Wired4ncer — this is your design; I have only written the first primitive and
answered the dust question one way. If you would rather take it from here, or
disagree with putting the carry on the operator output, say so and I will
adjust or hand it over.