coinbase-direct PPLNS — pay the window from the block's own coinbase - #76
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:
|
store_pplns_window() had three paths that returned a PARTIAL or over-wide window and reported success. The caller renders whatever comes back into a coinbase and publishes it — the payment IS the block — so nothing downstream can notice. This 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. 1. `if (sqlite3_step(b) != SQLITE_ROW) break;` — on iteration 2 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. 2. The end-of-table probe had no error check at all. A failed prepare or step left `seen` at 0, so `seen < batch` was true and the loop broke on that same short boundary — it could not tell a failed probe from a short table. 3. A first-iteration failure left cutoff_id = 0, making the payout query `sh.id >= 0`: the whole table as the window, both the unbounded scan this code exists to remove and a window wider than configured. The walk now ends having PROVED one of two things: it covered the window, or it read the whole table. Anything else returns -2 and the caller keeps its last good template. Reachability: this connection is opened once with SQLITE_OPEN_FULLMUTEX and shared by the commit and template threads, so it cannot return BUSY against its own writer. The realistic triggers are IO error, NOMEM, a corrupt page, and cross-process BUSY outlasting the busy timeout — dashboard/lib/db-admin.js and payout/lib/db.js open this file read-write from other processes. Proved against the parent, where both injected failures are served as success: first step fails -> served 2000 for a 500 window (the whole table) fails after widening -> served 40,960 for a 50,000 window (short) The end-of-table question is now answered by COUNT(*) from the boundary query itself rather than a separate probe. In the only branch that reads it every row in the batch passes the filter — if any row were excluded, the row before it would have a running total at or past the window and `covered` would already have ended the walk — so `got < batch` means the table ran out, exactly, from the rows actually read.⚠️ An intermediate version of this commit answered that question with a MIN(id) taken once before the loop, and that was worse than the bug it fixed: deleting the oldest row mid-walk lifts the real table minimum above the stale value, so neither exit test can fire. Measured on a 50-row table with a 1e9 window, it ran 27 iterations — each an unbounded scan and sort of the whole table, the exact pathology this batching exists to remove — until `batch` overflowed (UBSan: signed integer overflow, 4611686018427387904 * 4). 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. test_a_row_deleted_during_the_walk_still_terminates pins it, and `batch > INT64_MAX / 4` refuses rather than overflowing. tests/test_store_walk.c includes store.c as source and redirects sqlite3_step for the boundary query only. sqlite3_progress_handler was no good — it also fires for the store's background commit thread on the same connection — and an authorizer runs at prepare time, before the loop. The fault injection lives entirely in the test binary; production code carries no test seam. The suite also pins the three cases that must NOT become errors: a pool younger than its own window, an empty table, and a concurrent delete. One test exists only to pin the seam itself. Every other case returns before the payout query is reached, so none of them could catch a matcher that was too wide — and a too-wide matcher is how an earlier attempt at this suite went green for the wrong reason, by interrupting the payout query as well. test_the_injection_seam_does_not_reach_the_payout_query arms the injection with a budget the walk never spends and asserts the window still comes back whole, with exactly one statement ever matched. Widening the matcher to any statement touching `shares` makes that test, and only that test, abort. make test, make asan, and the new suite under -fsanitize=address,undefined are all clean. With src/store.c alone reverted, the two injection tests abort; the other five pass either way and are there as controls.
|
Opened it as #81, against It is the partial-window fix from the comment above, plus the tests. |
…covered
My bounded walk could end on three error paths that were indistinguishable
from a normal finish, and every one of them returned SUCCESS with a wrong
window. Reproduced independently before merging, on a 20,000-row table:
configured window 500 served 20000 (the whole table, 40x too wide)
configured window 50000 served 4096 (short: those miners underpaid)
Both returned rc=1 and a payee list. In this mode that is not a number to be
corrected next pass — the window is rendered into the coinbase and published,
so a wrong window is mined, irreversible, and invisible afterwards.
The fix makes the walk end only having PROVED it covered the window or read
the whole table, and errors otherwise so the caller keeps its last good
template. It also folds COUNT(*) into the boundary query rather than probing
separately, which is both cheaper and immune to the staleness trap he
describes: a MIN(id) taken before the loop goes stale when the oldest row is
deleted mid-walk, and the end-of-table test can then never come true.
Wired4ncer's #81 is merged as-is; this finishes the two things his review raised but his PR deliberately left alone, plus the wiring. ## Shares landing mid-walk were swept into the window The boundary search and the payout query are two statements in two implicit read transactions, so a bare `sh.id >= cutoff` paid out work that arrived AFTER the window was measured. The single statement they replaced was atomic for free; nothing had replaced that property, and nothing said so. Measured with 50 shares committed between the two: 550 difficulty served against a configured 500, and it grows with the share rate. Small, but it means blocks_found.pplns_window_diff records a window the block did not actually pay across. The boundary query already reads the rows, so it now reports MAX(id) too and the payout query is bounded at both ends. The two statements describe exactly the same rows without needing a transaction. Verified at 500 exactly with the same 50 shares landing in between, and the test fails at 550 if the upper bound is removed. ## The comments said "doubling" and the code multiplied by four He fixed store.c; the same claim was in four more places in test_store.c, along with a reference to a cap that does not exist. Corrected rather than left to mislead the next person reading the loop. ## Wiring test_store_walk was in `test` and `asan` but not `coverage`; added. tests/ README.md now explains why that suite is white-box — it includes store.c as source to inject a sqlite failure part-way through the widening loop, because a progress handler also fires for the store's commit thread on the same connection and cannot single out one statement. Verified: full C suite, ASan clean, and the solo, pps-classic, pplns and pplns-coinbase e2e suites.
CI caught the mixed-window e2e failing on a stage that had passed locally
minutes before. The cause was a race, and a data-losing one:
WARN pplns-coinbase: could not record the payout queue for block
36173cc41b9844f7: cannot start a transaction within a transaction
— rotation for this block is lost
The store keeps ONE sqlite connection and shares it between the commit thread,
the tip watcher and the stratum submit path. BEGIN IMMEDIATE fails outright
when another thread is already mid-transaction, so whether a write lands
depends on where the commit thread happens to be. The block was paid correctly
— that is the coinbase, already on chain — but the record of whom it skipped
was dropped, so the miner it skipped never moved up the queue. A WARN, and
nothing else.
Three functions had it. store_stage_block_fractions and
store_settle_block_fractions are mine. store_pplns_distribute has had it since
before this branch and was surviving on being retried every tip, which is why
it never looked like a bug.
All three now use SAVEPOINT, which nests: with no transaction open it starts
one, inside another it is a nested unit that RELEASE folds into the outer
commit. Either way the caller still gets all-or-nothing.
Pinned by a test that puts the shared connection into a transaction first —
the exact state the commit thread leaves it in — so the nesting is exercised
deterministically instead of by luck. Restoring BEGIN IMMEDIATE fails the
store suite.
Worth noting what this says about the earlier green runs: the same mutation
also fails test_only_a_confirmed_block_moves_the_queue, which has been passing
since the payout queue landed. It was racing all along and winning.
Also fixes the e2e's own blind spot — the mixed-window stage runs a second pool
with its own log, and dump_logs only ever tailed the first one, so the failure
arrived with no diagnostics attached. It now dumps that log and prints what the
pool said about the block.
Verified: full C suite, ASan, five regtest e2e suites, both node suites.
Both were coverage gaps on code that decides what miners are paid, and both
were the same shape: a fix landed, the suite went green, and nothing actually
exercised the fixed path.
## The payout queue was only ever proved to STAGE
The e2e asserted rows appear in pplns_pending_fractions. It never checked they
are applied, because the block was still pending when it looked — so the
assertion passed on staging alone and would have passed with settling
completely broken. That is the same vacuous-stage shape this file was caught
with once before, where a stage printed "carried: 0 sats" and passed
regardless.
It now mines one more block so the confirmation pass has something to decide
with, and asserts the transition:
before: staged only, applied=0 (the block is still pending)
after: confirmed_blocks=1 applied_rows=3
the applied queue still sums to zero
## store_pplns_distribute's savepoint was never driven nested
The previous commit fixed three functions that opened BEGIN IMMEDIATE on the
shared connection, but the nesting test only covered two. distribute's
savepoint is inside its per-block loop, so reaching it needs a matured block
with a window — the test now builds one and drives distribute inside an open
transaction. Restoring BEGIN IMMEDIATE there fails the suite.
Verified: full C suite, ASan, and the solo, pplns and pplns-coinbase e2e
suites.
## CHANGELOG.md, published by the release job
The release workflow emitted install boilerplate and nothing else, so what
changed in a version lived only in 83 commit messages. CHANGELOG.md now holds
it, and the job extracts the section matching the tag and publishes it above
the boilerplate — notes get reviewed in the PR that writes them, for the same
reason the binary is built in CI: a release note pasted into the web UI traces
back to nothing. A tag with no section still releases, with the boilerplate
alone.
The 0.4.0 entry leads with what costs money if it goes unread: that
pplns-coinbase cannot pay everyone in one block, that what it cannot pay goes
to the other miners rather than the operator, and that being small costs a
miner frequency rather than money. RELEASING.md says to write notes that way
and shows how to preview exactly what will be published.
## The docs sweep this turned up
Four gaps, all in the newest work:
- proxy.conf.example described the payout floor as it was before the queue
existed — the floor with no mention of where the money goes or that the
skipped miner is paid first next time. That file is where an operator sets
the number, so it is the worst place to be a version behind.
- the refuse-to-publish behaviour was documented nowhere. A pool that cannot
measure its window now publishes no job at all, which an operator will meet
as "jobs stopped updating" with `pplns window walk did not cover` in the
log. README and the HTML now say that is the guard firing, not a crash, and
why holding the template back is the only safe direction here.
- the HTML data model listed every table but the two this branch added.
- README described the per-listener ceiling without ever showing the syntax.
Diagrams still regenerate byte-identically, every anchor resolves, and the
full C suite passes.
Asked how to verify the two caveats I had been repeating, the honest answer
turned out to be "measure them" — and one was wrong.
## expected_slots was over by 24 slots on taproot
I had been describing it as a documented estimate. Measured against what the
builder actually admits, across six budgets and three address types:
P2WPKH within 1-2 slots
P2PKH within 0-7
P2TR over by up to 24 at a 3000-byte budget
Because it assumed 31 bytes an output and a P2TR output is 43. The consequence
is bounded — everyone still receives their own claim — but it reserved a third
of a coinbase for rotation where a quarter was meant.
The caller has the addresses, so it now charges each one what it costs.
Worst error across the same matrix is 2 slots, and always on the conservative
side. A test pins both properties: never over, never more than 3 short.
Reverting to a fixed 31 bytes fails it.
## "a persistent window failure freezes job updates" is testable in regtest
I had said this needed production conditions. It does not: renaming `shares`
from a second connection makes a live pool's window query fail with "no such
table", which is a different cause from the IO errors the unit tests inject
but the same path out of store_pplns_window() — and it exercises the real
binary rather than a redirected sqlite3_step.
The e2e now hides the table, forces a rebuild with a new tip, and asserts the
property that matters:
refused: window query failed: no such table: shares
published no job while the window was unreadable (3, unchanged)
resumed once the window was readable again (3 -> 5)
The middle line is the one worth having. A pool that logged the failure and
shipped a job anyway would be worse than one that crashed, because in this mode
the window is rendered into a coinbase and published. Mutating
attach_pplns_window to publish on failure fails the stage.
What regtest genuinely cannot do is production SCALE — a 5.5 GB shares table,
a hundred million rows. That is what Wired4ncer's run of the bounded walk
against his own database covers, and nothing here replaces it. I had been
using that limit to excuse two things it did not cover.
|
On The short version. A savepoint nests into whatever transaction is already open on that connection. On a connection shared by three threads, "whatever is already open" is usually the commit thread's share batch — and once a write is inside that batch, the commit thread decides its fate. 1.
|
Wired4ncer caught this in review before 0.4.0 ships, and he is right: the
savepoint fix could lose more than the bug it replaced, and more quietly.
Both of his claims reproduce in plain sqlite, no pool involved:
ROLLBACK TO discards intervening writes: 0 rows survive (RELEASE: 1)
a RELEASEd nested write, outer rollback: 0 rows survive
A savepoint joins whatever transaction is already open. On this connection
that is usually the commit thread's share batch, so:
- RELEASE does not commit. When commit_batch() hits a failed COMMIT it runs
ROLLBACK and replays the batch — the shares come back, the nested write
does not, and its caller was already told it succeeded.
- ROLLBACK TO is not scoped to the caller that opened the savepoint. It
rewinds the connection, taking the commit thread's shares with it. That is
reachable from six ordinary error paths across the three functions.
So the trade was a lost payout-queue row for lost SHARES, which is what every
window is measured from.
His premise checks out too, and it is the same one my own commit message
stated: writer_main() releases `mu` before calling commit_batch(), and
commit_batch() takes no lock at all, so `BEGIN IMMEDIATE … COMMIT` ran
unserialised against everything else on that connection.
Fixed as he suggested — one mutex held across the whole transaction span, in
commit_batch() and around each of the three functions, keeping BEGIN
IMMEDIATE. A stratum-path write now waits for at most one batch, bounded by
commit_window_ms. Taken per attempt rather than around the retry loop, so a
backoff does not hold every other writer off for the sleep.
On his deadlock question: none of the three is reachable from the commit
thread — process_event() calls none of them, and their only callers are
reconcile.c and main.c — so a plain mutex is safe. Checked rather than
assumed.
Two tests. One drives the real race: shares streaming in while the queue is
written, asserting nothing is refused and no row of either kind is lost. That
one passes on savepoints, because the loss needs the outer batch to actually
roll back — so the second plays the commit thread's failed COMMIT on another
thread and asserts the staged rows survive it. That one fails on savepoints
with "staging returned before the other transaction ended, so it nested".
Verified: full C suite, ASan, all six regtest e2e suites.
Review of the whole rail, with the bugs it turned up fixed and pinned. The one that mattered: stratum.c reconstructed the set of miners a block had paid as "the first res.paid_count payees". The builder does not stop at the first payee it cannot pay -- it skips it and keeps going, because a later one may be a cheaper address type and still fit -- so the paid set is a SUBSEQUENCE of the window, never a prefix of it. When the dropped payee was not last, the ledger inverted: the miner the block SKIPPED was recorded as paid early and sent to the back of the queue, and a miner that WAS paid was recorded as owed and promoted ahead of it. Exactly backwards, on the commonest case there is -- pplns_order_claims() deliberately puts a reserved small claim first, and a small claim is what the floor drops. The regtest could not see it. A fresh window is ordered largest-first, so the dropped claim is always the tail and the prefix reading is right by coincidence; it only bites once the queue has started rotating. The builder now reports paid_payee[] and stratum.c reads that. Also: - store_stage_block_fractions() checked the deltas summed to zero and THEN skipped rows with no worker behind them, so a set that balanced only with the orphan reached the table unbalanced -- by passing the check rather than failing it. Summed over what is actually written now. - A floor nobody clears made the pool publish no work at all: the builder refuses such a window, per connection, per job, with a warning per miner per template and no single cause to find. Reachable without anything exotic -- 5000 sats across 20 miners is 250 each. Caught at template time now, once, with the arithmetic and the two knobs named. - The slot reservation was sized from the server-wide byte ceiling while the ceiling that binds is per-listener. Oversizing it is not symmetric with undersizing: reserve more positions than a tight coinbase has slots and every slot goes to the queue, so the largest claims are paid nothing and immediately re-enter the queue themselves. Sized from the tightest ceiling any listener can impose. - A reserved slot could go to a claim the floor was about to drop, which pays nobody and denies a miner that could have used it. It compounds: a permanently sub-floor miner's owed_fraction only grows, while a byte-capped one is paid and resets, so given long enough the miners who can never be paid crowd out the ones the rotation exists for. main.c now splits before ordering -- which also restores pplns_split_window's documented largest-first precondition, so the truncation remainder lands on the largest claim again -- and pplns_order_claims() skips what it cannot pay. - conn_render_coinbase() reached the solo shape by a goto into an `else if (0)` block sitting beside a verbatim copy of itself. One renderer now. - store_settle_block_fractions() opened a BEGIN IMMEDIATE on every reconcile pass in every mode, taking the write lock and txn_mu to settle a table that is empty and always will be on four of the five. Gated on a read. - config.h, coinbase.h, pplns.h and both operator-facing log lines still said a dropped claim is "forfeited to the operator, not carried". It goes to the other miners and is recorded as a turn. The dashboard already said so, so the operator's own logs contradicted the page shown to their miners. Every fix has a test that fails without it. The two that could not be reached from a chain -- the non-tail drop, and settling with the write lock held -- are pinned in test_stratum.c and test_store.c.
The initial job was published windowless on the premise that a process which has just started has no shares to pay and no network difficulty to size a window with. Neither holds on a restart: the shares table persists, and refresh_pps_rate() had already read the difficulty out of the same template a few lines earlier. The tip watcher only rebuilds on a new tip or after its 30-second refresh, so the windowless job stood for up to 30 seconds after every restart, and a block found in that gap paid its finder alone -- the whole window skipped, and nothing staged in the payout queue to say so. main() now attaches the window to the initial job the way the watcher does for every other one. A fresh pool still gets the bootstrap job, but from attach_pplns_window() itself, which already logs that case. A first template that cannot carry a window is not published, on the watcher's rule, and last_built_ms is cleared so the watcher's first poll rebuilds rather than waiting out the refresh. The e2e's mixed-window stage is a restart with a full shares table, which is exactly the shape that was wrong. It now asserts the floor warning appears before the watcher builds a single job -- a "new job:" line ahead of it means the initial job went out without a window -- and the bootstrap stage keys on attach_pplns_window()'s own "no shares yet" line, which is deterministic for the same reason the old one was.
txn_commit() ran COMMIT, dropped the mutex and returned void. A COMMIT that fails and that sqlite does not roll back on its own -- BUSY is the documented case -- left the connection inside the transaction after the caller had been told its write succeeded. From then on every BEGIN on the shared connection failed with "cannot start a transaction within a transaction"; commit_batch() gave up after three attempts per batch and logged each as LOST, so one unread return code became every share dropped until restart. Rare under WAL with BEGIN IMMEDIATE; the blast radius is the whole pool. It now returns 0 only when the transaction is durable, and otherwise rolls back, counts a pg_error and returns -1. The three writers propagate it: staging reports that nothing was staged (the caller already logs that the block's rotation is lost, which is now true rather than optimistic), settlement leaves the staged rows for the next pass, and the distributor treats it as a failed distribution -- the latch was inside the transaction, so the block is retried and nothing is credited twice. Pinned in the walk harness, which already includes store.c as source: sqlite3_exec is redirected alongside sqlite3_step, and one test refuses a COMMIT without executing it. The caller must be told, the connection must be back in autocommit, the share batches after it must land, and the refused staging must have written nothing. Against the old helper it fails with "COMMIT failed and staging returned 2 (success) anyway".
…mp to 0.4.0 Three leftovers from review. The queue is applied at ONE confirmation, not the distributor's hundred, because it has to describe the last block before the next one is built and nothing in it is money. The cost -- a block reorged out after that keeps its rotation, one turn out of order, corrected by the next block found -- was nowhere in the docs. README, VERIFY, the HTML, proxy.conf.example and schema.sql now say so beside the sentence that promised an orphan rotates nobody. Three comments still described the rule the redistribution replaced: the pool_meta floor column in schema.sql, the header of the pool_meta floor test in test_store.c, and the header of the dashboard disclosure test -- whose own assertions forbid the page from saying what the header said. VERSION goes to 0.4.0. CHANGELOG.md carries the 0.4.0 section in this PR and RELEASING.md says the bump belongs in the same one, or the binary a v0.4.0 tag builds reports 0.3.0.
5ddab3a to
a4f3627
Compare
…s them The pool serves more than one stratum port and nothing miner-facing said why. The identity strip lists them, but a strip has room to name a port, not to explain the choice — and the choice is not cosmetic: a marketplace aggregates a whole fleet behind ONE connection, so the same hashrate that is 12 shares a minute from a home ASIC is ~232 000 submits a second on the port meant for that ASIC. The pool rate-limits the connection, the miner sees rejects, and the order is cancelled for work the pool appears to be refusing. Vardiff cannot repair it after the fact either: at 4x per window it takes eight windows to climb from 1 to 65 536, and the rejects on the way up are what gets the order pulled. So the connect card now lists every published port as a dialable URL with the difficulty behind it and who it is for, followed by why there is more than one. The second half is the disclosure. A port with min_diff KEEPS its floor when the chain is easier — deliberately, since a marketplace measures the wire and cancels an order served under what the port advertised — and the cost of that lands on the miner, who filters locally at the assigned difficulty and throws away blocks the chain would have taken. On a 500 000 port over a chain at 1 200 that is 416 of every 417 blocks solved. health.js has reported this to the operator since the floors landed; the miner paying for it was never told. It is now stated on the card, with the live ratio, and only when the chain is actually under the floor — a floor the network has passed costs nothing and says so, and a pool with no floored port at all gets neither paragraph. promised_min_diff is carried through parseListeners for this. It is the field that separates the two cases: min_diff is the rate-loop bound the network difficulty still clamps, promised_min_diff is the one that is kept. Reading a missing promise as a floor would print a block-loss warning to every miner of a pool losing nothing, so an older proxy's listener JSON reads as no promise. The earnings note branches on mode. "Work is credited by its difficulty" is the right answer to "does the big port pay less per share" in four modes and a promise solo never makes, where nothing is credited between blocks at all.
…t in The PPS rate row answered a zero rate with "only pps-classic prices a share on arrival". True of pps-classic, and no answer at all to the pplns-coinbase operator looking at their own pool and wondering what is n/a and why -- on the page you open when something looks wrong. The zero itself is correct: refresh_pps_rate() stores 0 in every mode but pps-classic, because PPLNS prices a share in hindsight out of a block actually found. Only the wording was wrong, and it was wrong in a familiar way. Three places used to answer "not pps-classic" with the word solo; this row was the fourth, and the 0.4.0 changelog already claimed it fixed. That bullet is corrected here too. The row now names the mode the pool is actually in, the label stops calling itself a PPS rate on a pool that has none, and for the PPLNS rails the card says where the price does come from: the block value above it is what gets divided, among the window, when a block is found. Under pps-classic a zero keeps its own meaning -- accrual gated by the difficulty floor -- rather than being softened into an n/a. The history table drops its rate column when no row was ever priced. It keys off the data rather than the current mode, so a pool that switched away from pps-classic keeps its priced history legible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkTEy3SBLUZp1j3kGdtRE7
"No new template for N min -- the proxy may not be reaching its backend" measured staleness from templates.ts, which is when a template was FIRST seen. Since a578a48 folded repeat polls into the row they match, ts stops advancing for as long as the chain sits on one block, so the warning was reporting chain speed as a proxy fault. On alphanet that fires almost continuously: mean block time is around 30 minutes against a 10-minute target, so sitting on one prev block past the 900s threshold is the normal case. The live row showed ts 30 minutes old, last_seen 11 seconds old, 62 polls over 1831s -- one every 30.0s, exactly bitcoind_poll_interval_ms. The backend was fine and the page said it was not, which is the failure mode that teaches an operator to ignore a red line. Measure from last_seen, the column that actually tracks backend contact. On a DB predating the fold every poll inserted its own row, so stats.templates() coalesces last_seen to ts there and this stays correct on both. The threshold is derived rather than fixed. A flat two minutes would be the same bug one layer down: bitcoind_poll_interval_ms is configurable and the dashboard cannot read it, so a pool polling every five minutes would be called unreachable between every pair of polls. The row knows its own cadence -- polls observations spanning held_sec -- so warn at six missed polls, floored at two minutes. A fresh row spans no interval and falls back to the floor. How long the chain has stood on one tip is still shown, since that was the only place it appeared in words, but as the plain fact it is rather than in the error colour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkTEy3SBLUZp1j3kGdtRE7
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
The whole rail, wired end to end. This started as
coinbase_build_window()and nothing else; it is now
pool_mode = pplns-coinbase, working against bothplain bitcoind and an enforcer-served template, with an end-to-end regtest
that proves the payment on a real chain.
attach_pplns_window()inmain.c, overthe shares in hand when the template is built
coinbase_pays_windowas the fourth flag: it paysneither the miner (
solo) nor the poolmain.cinto a testablepplns.cand one on plain bitcoind cannot divide a window differently
pplns_fractions), staged on a found block andapplied only once it confirms
proxy.conf.example,docs/simplepool.html(withsequence diagrams per mode), VERIFY, and the dashboard/payout/tests READMEs
Along the way: a
soloend-to-end suite (the default mode had none anywhere),and four dashboard bugs where every non-
pps-classicmode was labelled "solo".Where dropped value goes
The proposal names dust as a cost but leaves open what actually happens to the
sats. The first half of the answer is forced:
So a payee below the payout floor, or past the byte budget, cannot simply be
skipped — its value has to leave in somebody's output. This PR first sent it
to the operator, as
carry_sats. That was wrong, and @Wired4ncer's caseagainst it in
#76 (comment)
is what settled it.
The value now goes to the other miners in the same window. The operator
receives its fee and nothing else, at every byte budget. Two measurements made
the old rule indefensible rather than merely harsh:
28 are paid, 72 are cut by the byte cap, none by the dust floor — and
the operator received 25% of the block on a 1% fee. The rule was
defended as a dust policy and dust was never involved.
400 bytes against 2% at 3000 — so an operator maximised revenue by starving
its own miners.
Redistributing keeps every property the forfeit had: the block still pays out
to the satoshi, the pool still holds nothing, and no balance ledger appears.
What changes is only who receives what the coinbase had no room for.
Nobody is left out of pocket by one block being unable to pay them, but nobody
is made whole by it either — so
pplns_fractionsrecords whose turn wasskipped, as a signed fraction of one block reward that sums to zero across
the window. It is a memory of the rotation, not a balance: the pool holds no
money against it, and a quarter of each coinbase's payout slots are reserved
for whoever has waited longest. Measured on a production coinbase-direct pool,
this is what the queue is for: 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 cleared the payout floor comfortably.
Staged against the block hash and applied only when it confirms, discarded
when it is orphaned: a block that never stood paid nobody and rotated nobody.
Other decisions
marketplace verifies the coinbase and refuses one it considers oversized,
and it measures bytes. On a drivechain the dominant term is not the payouts
at all — it is the BIP300/301 commitment OP_RETURNs sharing the transaction;
the same 16 payouts cost 817 bytes against four of them and 769 against
three. A count cap cannot express that.
coinbase_max_bytes, default 1000,and settable per listener — the ceiling belongs to whichever marketplace
an operator sells to, so tighten the rented port and leave the home one
alone.
first is
pplns_order_claims()'s default — it puts the floor and the bytebudget on the smallest claims — but a fixed rule inside the builder made the
slots unwinnable, because a large miner's share of the window beats any
priority a small one can accumulate.
value - fee. A shortfall wouldbe forfeited to nobody, so it is refused as a caller bug.
than per render — otherwise the pool publishes no work and logs a warning
per miner per template with no single cause to find.
pool_btc_addresswithpool_mode = pplns-coinbaseis a config error: the claim of this mode isthat the pool never holds the reward, and a configured wallet is the shape
of a pool that does.
same coinbase twice. A miner checking the block it was paid from has to get
the same answer the pool did.
Testing
make testandmake asanclean.Unit: 38 coinbase cases, 20 split/ordering cases, 31 store cases, plus the
stratum suite at 514. 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.End-to-end on a real chain (
tests/test_pplns_coinbase_regtest.sh): the poolstarts with no wallet configured, mines, and the block's coinbase is read back
off the chain to prove it pays the window, that no output pays an address the
pool controls, and that
pps_creditsis empty. A mixed window of 100 : 10 : 1with the floor between the last two proves the redistribution on chain — the
skipped claim's satoshis turn up spread across the two that fit, the operator
holds its fee to the satoshi, and the payout queue records the skip and sums
to zero.
tests/test_solo_regtest.shcovers the mode this one sharesconn_render_coinbase()with, since that is what work here would most easilybreak.
Open
@Wired4ncer — the carry question is answered your way now, and the measurement
that decided it is yours. The remaining judgement calls are the size of the
reserved fraction (a quarter) and whether the payout floor should exist at all
given that redistribution already handles the byte cap.