Skip to content

coinbase-direct PPLNS — pay the window from the block's own coinbase - #76

Open
rsantacroce wants to merge 24 commits into
mainfrom
2026-09-07-pplns-coinbase
Open

coinbase-direct PPLNS — pay the window from the block's own coinbase#76
rsantacroce wants to merge 24 commits into
mainfrom
2026-09-07-pplns-coinbase

Conversation

@rsantacroce

@rsantacroce rsantacroce commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

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-mode rather than main, because it needs the flag
split from that branch — as the proposal says, the split is what makes a third
rail expressible at all.

The premise checks out

CLASSIC_PAYOUTS.md rules out coinbase payment because the enforcer will not
credit a coinbase output as a drivechain deposit. That constraint is about
paying into a sidechain. It says nothing about L1 destinations, and solo
mode 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:

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 simply 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: 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

  • Largest claims first, so the cap and the dust limit fall on the smallest
    ones — least cost to wait, smallest carried balance.
  • 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.
  • 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 (default 200) 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.
  • Deterministic — 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.

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 test and make asan clean.

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.

  • the PPLNS window at template timeattach_pplns_window() in
    main.c, over the shares in hand when the template is built
  • the mode itself — pplns-coinbase, with coinbase_pays_window as the
    fourth flag: it pays neither the miner (solo) nor the pool
  • the per-connection coinbase. A window job renders one shared
    N-output coinbase whose payout bytes live in cb2, so it is identical
    across connections and only the extranonce differs. test_stratum.c
    asserts two connections receive the same coinbase for the same job, and
    tests/test_pplns_coinbase_regtest.sh proves it on a real chain
  • carry-forward — removed rather than finished, see below
  • docs — README, INSTALL, proxy.conf.example, docs/simplepool.html
    (with sequence diagrams per mode), VERIFY, and the dashboard/payout/tests
    READMEs

Since then: a solo end-to-end suite (the default mode had none anywhere), the
window split lifted out of main.c into a testable pplns.c, and four
dashboard bugs where every non-pps-classic mode was labelled "solo".

The carry question, answered the other way

carry_sats is 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 at
startup, 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.

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.
@rsantacroce
rsantacroce marked this pull request as ready for review September 7, 2026 18:39
@rsantacroce
rsantacroce changed the base branch from 2026-08-25-pplns-mode to main September 7, 2026 18:41
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.
@Wired4ncer

Copy link
Copy Markdown
Contributor

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:
you budget by the actual address type (a taproot output costs 43 bytes where a
P2WPKH one costs 31), and you count the template's OP_RETURNs into the fixed
cost. That second one is the thing we learned the hard way: on a drivechain
pool the OP_RETURNs, not the payouts, are what decide how many miners fit. With
four of them we get 16 payouts into 817 bytes; with three, the same 16 cost 769.

So the shape matches what we run. There is one decision I would change, and one
performance note.

1. Where the unpayable money goes

Today a payee below the floor, or past the byte budget, has its sats added to
the operator's output. I understand why — the coinbase has to pay out the whole
reward, so the money has to go somewhere — but I think this is the wrong
somewhere, and the reason is not really about fairness in principle. It is that
the same miners fall below the floor every single time.

A small miner's share of one block is small in every block, not occasionally.
So this rule does not spread dust around; it pays those miners nothing, ever,
and gives their share to the operator on every block the pool finds. On our pool
today, 62 of the 100 addresses we owe are below the floor. Under this rule the
operator would take all 62 shares out of every block.

We hit exactly this and solved it in a way that keeps the property you are
protecting. Two pieces:

Pay the whole reward to the miners you can pay. Instead of the dropped
share going to the operator, renormalise across the payees that survive the
floor and the byte budget. The block still pays out to the sat, the operator
still takes only its fee, and no miner funds the operator by being small.

Remember the unfairness as a fraction, not as sats. This is the part that
answers "carrying it rebuilds the custodial ledger this mode exists to avoid" —
it does not, as long as you never carry money. We carry a signed fraction of
one block reward:

  • a positive number means the address was skipped and is owed that fraction of
    some future block;
  • a negative number means it was paid early, covering someone else's skipped
    share, and owes it back;
  • the whole ledger always sums to zero.

The pool holds no funds at any point. Nothing is ever withheld from a coinbase
and released later — that would need a block that pays less than the reward and
then one that pays more, and the second is invalid. It is a memory of who was
skipped, used to order the next block's payouts. If the pool were deleted
tomorrow, nobody is owed a payment, because nobody was ever holding one.

Why a fraction and not raw difficulty: shares stay in the window across several
blocks, so rolling unpaid difficulty forward counts the same work twice. And
difficulty is not comparable over time — it swings and it resets at a fork — so
a claim recorded in difficulty units quietly changes meaning at every retarget.
A fraction of a block reward does not.

One thing we got wrong first, in case it saves you the same bug. Ranking by
"window share plus what you are owed" is not enough on its own. A large miner's
share of the current window is bigger than the largest carry a small miner can
ever build up, so the large miners retake the top slots every block and the
queue of skipped miners never moves. We measured it on alphanet on 5 September:
over 31 blocks, 279 payout slots reached 34 addresses, 12 of which took 91% of
them, while 88 addresses were paid nothing — and 28 of those were already above
the payout floor, so the floor was not what excluded them. The fix is to reserve
a few of the payout slots for whoever has been waiting longest, which costs no
coinbase bytes and changes nobody's total. It changes how often people are paid,
not how much.

2. The window query runs on every template

store_pplns_window() sums over the whole shares table — the running total is
computed first and the window boundary is only applied afterwards, so there is
no early exit and no limit. That is a full pass over every share the pool has
ever recorded, on the template thread, every time a template is built.

Our shares database is 5.5 GB. It would not survive this. Bounding the walk —
by id, or by time, or simply by stopping once the running total passes the
window — would make it read only the newest rows it actually needs.

3. A comment that disagrees with the code

pplns.h says total_diff is "computed over rows this array may have been
truncated from". store.h and store.c say the opposite, and the code agrees
with store.h: the total is summed only over the rows returned. The code is the
safe version. But someone reading the header will believe the truncated rows are
still in the denominator, and the shortfall that would create lands on out[0],
which is the largest miner. Worth fixing before it becomes true.

The offer

Happy to write the per-connection coinbase — it is the item still on your list
and it is the part we have already been through. Two things worth knowing
before anyone starts it: a shared N-output coinbase is identical across
connections, which is a change to the extranonce layout and the job rebuild
path, not just to the builder; and the byte ceiling that actually binds is a
marketplace rule, so it only needs to apply to the port rented hashrate connects
to. We ended up making the cap per-listener for that reason — the ceiling costs
payouts, and there is no reason to make our own miners pay it.

Say the word on the ledger question and I will send whichever you prefer: the
renormalise-plus-fraction change against this branch, or the per-connection
coinbase, or both as separate PRs.

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.
@rsantacroce

Copy link
Copy Markdown
Collaborator Author

@Wired4ncer thank you for taking a look, now I'm sending more code towards your direction.

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: you budget by the actual address type (a taproot output costs 43 bytes where a P2WPKH one costs 31), and you count the template's OP_RETURNs into the fixed cost. That second one is the thing we learned the hard way: on a drivechain pool the OP_RETURNs, not the payouts, are what decide how many miners fit. With four of them we get 16 payouts into 817 bytes; with three, the same 16 cost 769.

So the shape matches what we run. There is one decision I would change, and one performance note.

1. Where the unpayable money goes

Today a payee below the floor, or past the byte budget, has its sats added to the operator's output. I understand why — the coinbase has to pay out the whole reward, so the money has to go somewhere — but I think this is the wrong somewhere, and the reason is not really about fairness in principle. It is that the same miners fall below the floor every single time.

A small miner's share of one block is small in every block, not occasionally. So this rule does not spread dust around; it pays those miners nothing, ever, and gives their share to the operator on every block the pool finds. On our pool today, 62 of the 100 addresses we owe are below the floor. Under this rule the operator would take all 62 shares out of every block.

We hit exactly this and solved it in a way that keeps the property you are protecting. Two pieces:

Pay the whole reward to the miners you can pay. Instead of the dropped share going to the operator, renormalise across the payees that survive the floor and the byte budget. The block still pays out to the sat, the operator still takes only its fee, and no miner funds the operator by being small.

Remember the unfairness as a fraction, not as sats. This is the part that answers "carrying it rebuilds the custodial ledger this mode exists to avoid" — it does not, as long as you never carry money. We carry a signed fraction of one block reward:

  • a positive number means the address was skipped and is owed that fraction of
    some future block;
  • a negative number means it was paid early, covering someone else's skipped
    share, and owes it back;
  • the whole ledger always sums to zero.

The pool holds no funds at any point. Nothing is ever withheld from a coinbase and released later — that would need a block that pays less than the reward and then one that pays more, and the second is invalid. It is a memory of who was skipped, used to order the next block's payouts. If the pool were deleted tomorrow, nobody is owed a payment, because nobody was ever holding one.

Why a fraction and not raw difficulty: shares stay in the window across several blocks, so rolling unpaid difficulty forward counts the same work twice. And difficulty is not comparable over time — it swings and it resets at a fork — so a claim recorded in difficulty units quietly changes meaning at every retarget. A fraction of a block reward does not.

One thing we got wrong first, in case it saves you the same bug. Ranking by "window share plus what you are owed" is not enough on its own. A large miner's share of the current window is bigger than the largest carry a small miner can ever build up, so the large miners retake the top slots every block and the queue of skipped miners never moves. We measured it on alphanet on 5 September: over 31 blocks, 279 payout slots reached 34 addresses, 12 of which took 91% of them, while 88 addresses were paid nothing — and 28 of those were already above the payout floor, so the floor was not what excluded them. The fix is to reserve a few of the payout slots for whoever has been waiting longest, which costs no coinbase bytes and changes nobody's total. It changes how often people are paid, not how much.

2. The window query runs on every template

store_pplns_window() sums over the whole shares table — the running total is computed first and the window boundary is only applied afterwards, so there is no early exit and no limit. That is a full pass over every share the pool has ever recorded, on the template thread, every time a template is built.

Our shares database is 5.5 GB. It would not survive this. Bounding the walk — by id, or by time, or simply by stopping once the running total passes the window — would make it read only the newest rows it actually needs.

3. A comment that disagrees with the code

pplns.h says total_diff is "computed over rows this array may have been truncated from". store.h and store.c say the opposite, and the code agrees with store.h: the total is summed only over the rows returned. The code is the safe version. But someone reading the header will believe the truncated rows are still in the denominator, and the shortfall that would create lands on out[0], which is the largest miner. Worth fixing before it becomes true.

The offer

Happy to write the per-connection coinbase — it is the item still on your list and it is the part we have already been through. Two things worth knowing before anyone starts it: a shared N-output coinbase is identical across connections, which is a change to the extranonce layout and the job rebuild path, not just to the builder; and the byte ceiling that actually binds is a marketplace rule, so it only needs to apply to the port rented hashrate connects to. We ended up making the cap per-listener for that reason — the ceiling costs payouts, and there is no reason to make our own miners pay it.

Say the word on the ledger question and I will send whichever you prefer: the renormalise-plus-fraction change against this branch, or the per-connection coinbase, or both as separate PRs.

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.
@rsantacroce rsantacroce changed the title WIP: coinbase-direct PPLNS — pay the window from the block's own coinbase coinbase-direct PPLNS — pay the window from the block's own coinbase Sep 9, 2026
@Wired4ncer

Copy link
Copy Markdown
Contributor

Thank you for taking the whole review — the redistribution, the payout queue, the
per-listener ceiling and the bounded window walk all landed, and your own measurement of
the operator's take is a better argument than the one I made.

I ran the branch against our production shares database before saying anything, and the
window walk is correct there: I compared the batched boundary against the old whole-table
running sum across 19 window sizes from 1e3 to 1e13, including windows larger than the
table's total difficulty, and got identical cutoffs every time. At our real operating
point it reads about 434,000 rows instead of the whole table, and that figure does not grow
as the table does, which is exactly what our 5.5 GB database needs.

To be clear about where this comes from: our pool does not run this branch, and the
function below is not in our production binary — I went looking because we intend to merge
it, not because anything is broken here. So this is a review finding, not an incident.

But I think there is one problem left, and it is the kind that pays out wrong instead of
falling over.

The walk can give up early and still report success

The widening loop ends on break in three places. Two of them are error paths, and neither
is distinguished from a normal finish:

  • If the boundary query's sqlite3_step() fails on the second iteration or later,
    cutoff_id still holds the previous iteration's boundary — which is known to be short
    of the window, because falling short is the only reason a second iteration happens at all.
  • The end-of-table probe has no error check. If its prepare or step fails, seen stays 0,
    so seen < batch is true and the loop breaks on that same short boundary. It cannot tell
    a failed probe from a short table.
  • If the failure happens on the first iteration, cutoff_id is never assigned. It stays
    0, the payout query becomes sh.id >= 0, and the window becomes the entire table —
    both the unbounded scan this PR exists to remove, and a window far wider than configured.

None of these returns an error. The function returns the number of payees and the caller
carries on.

Why I think this one matters more than it looks. In the other modes a bad number can be
corrected on the next pass, because the pool holds the money. Here it cannot: the window is
rendered into the coinbase and the block is published, so the payment is the block. There
is no reconciliation step that could notice afterwards, and nothing on chain to claw back.
It is the divergence the function's own comment warns about — "a block pays out differently
from what its template promised"
— arriving through the error path rather than the
arithmetic.

On reachability, so nobody has to take my word for it: the store opens one connection with
SQLITE_OPEN_FULLMUTEX and shares it between the commit thread and the template thread, so
it cannot return SQLITE_BUSY against its own writer — I had that wrong at first. What is
left is still real: an IO error, SQLITE_NOMEM, a corrupt page, and cross-process BUSY
outlasting the busy timeout, since dashboard/lib/db-admin.js and payout/lib/db.js open
the same file read-write from other processes.

What it does today

I injected a failure into the boundary query on this branch and left everything else alone.
Both cases were served as success:

boundary query fails on its first step
  configured window   500      served  2000   <- the whole table, 4x too wide
boundary query fails after one widening
  configured window 50,000     served 40,960  <- short: those miners are underpaid

Both returned a payee list and a success code.

The fix I would suggest

Make the walk finish only when it has proved one of two things: that it covered the
window, or that it reached the oldest row in the table (a pool younger than its own window,
which is legitimate and must keep working). Anything else returns an error, and the caller
keeps its last good template rather than mining a wrong one.

While doing that, the SELECT COUNT(*) FROM (SELECT 1 FROM shares LIMIT ?) probe can go —
but it is worth saying how, because the obvious replacement is a trap I walked into. In the
only branch that reaches the probe, every row in the batch passes the filter, so the
boundary query already knows the answer: adding COUNT(*) to it and comparing against
batch tells you the table ran out, exactly, from the rows actually read.

What does not work is taking SELECT MIN(id) FROM shares once before the loop and
comparing cutoff_id against it. Delete the oldest row while the walk is running and the
real minimum rises above that stale value, so the end-of-table test can never come true; if
the window is also wider than what remains, neither can the covered test. I measured 27
iterations on a 50-row table before batch overflowed — and every one of those iterations
is an unbounded scan and sort of the whole table, which is the exact thing this PR removes.
The batch's own row count describes the rows read rather than the table as it is at that
instant, so it cannot go stale.

I have this written and tested against 02a3451 and I am happy to open it as a PR against
your branch — say the word and it is yours. The tests inject the failure by including
store.c and redirecting sqlite3_step for the boundary query only, so the production code
carries no test seam; a progress handler was no good because it also fires for the store's
background commit thread on the same connection.

Two smaller things I noticed while measuring, neither urgent:

  • The comment says the batch "doubles" in three places, and so does the test name, but the
    code does batch *= 4. The same comment describes a cap that stops it "looping forever"
    — 4096 is the initial batch and the growth is unbounded; the terminator is the probe above.
  • The boundary search and the payout query now run in separate implicit read transactions,
    so shares landing between them are swept in by sh.id >= cutoff and the window total can
    come out slightly above the configured window under load. The pre-fix code was a single
    statement and therefore atomic. Probably harmless, but it is a behaviour change that is
    not written down anywhere.

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.

2 participants