Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a36a7a2
Coinbase-direct PPLNS: the N-output builder
rsantacroce Sep 7, 2026
3e7b989
Merge remote-tracking branch 'origin/main' into 2026-09-07-pplns-coin…
rsantacroce Sep 7, 2026
413981f
Coinbase-direct PPLNS: the window as it stands now
rsantacroce Sep 7, 2026
6232002
Coinbase-direct PPLNS: the drivechain path, and one shared splitter
rsantacroce Sep 7, 2026
956c158
Coinbase-direct PPLNS: the mode, wired end to end
rsantacroce Sep 8, 2026
e7ed44e
Coinbase-direct PPLNS: prove it on chain, and fix the deadlock it found
rsantacroce Sep 8, 2026
b817cfd
Budget the coinbase in bytes, not outputs — the count cap was wrong
rsantacroce Sep 8, 2026
47f9ab4
cbwin e2e: wait for the new-height job before mining the second block
rsantacroce Sep 8, 2026
199d104
Coinbase-direct PPLNS: record who is owed the carry
rsantacroce Sep 8, 2026
383979b
pplns-coinbase: forfeit small claims to the operator instead of carry…
rsantacroce Sep 8, 2026
9794660
tests: prove solo mode on a real chain, with two miners
rsantacroce Sep 8, 2026
9d6edec
dashboard: tell miners about the payout floor, and stop calling every…
rsantacroce Sep 8, 2026
a2f3f4b
pplns-coinbase: state the payout floor with the identity, not after t…
rsantacroce Sep 8, 2026
c5c7379
pplns: lift the window split out of main.c, and test the mixed windows
rsantacroce Sep 8, 2026
15da40c
cbwin e2e: prove a mixed window forfeits, on chain
rsantacroce Sep 8, 2026
789b009
Merge origin/main into the pplns-coinbase branch
rsantacroce Sep 8, 2026
f78f606
docs: cover all five modes, not the two each doc was written for
rsantacroce Sep 8, 2026
91b88d7
docs: sequence diagrams for every mode and the payout protocol
rsantacroce Sep 8, 2026
d6a4299
pplns.h: the total_diff comment contradicted store.h and the code
rsantacroce Sep 8, 2026
a514213
pplns-coinbase: pay dropped claims to the other miners, and stop resc…
rsantacroce Sep 8, 2026
3ed58b7
pplns-coinbase: a payout queue, so being small stops meaning being sk…
rsantacroce Sep 8, 2026
9c1f556
pplns-coinbase: make the coinbase byte ceiling per-listener
rsantacroce Sep 9, 2026
532ac52
docs: the forfeit reversal left every document describing a policy th…
rsantacroce Sep 9, 2026
02a3451
docs: commit the sequence-diagram generator, and have CI check it
rsantacroce Sep 9, 2026
2e65ce0
pplns window: refuse a walk that cannot prove it covered the window
Wired4ncer Sep 9, 2026
a804a72
Merge #81 from Wired4ncer: refuse a window walk that cannot prove it …
rsantacroce Sep 10, 2026
439a455
pplns window: pin both ends of the walk, and close #81's leftovers
rsantacroce Sep 10, 2026
1dd77e2
store: use savepoints, because one connection is shared by three threads
rsantacroce Sep 10, 2026
d69251b
Cover the two payment paths that were fixed but unverified
rsantacroce Sep 10, 2026
a5aebf9
Release notes for 0.4.0, and finish the docs sweep
rsantacroce Sep 10, 2026
9931cd7
Test the two things I had called untestable, and fix one of them
rsantacroce Sep 10, 2026
4b94100
store: serialise transactions with a mutex, not savepoints
rsantacroce Sep 10, 2026
e9cbe96
pplns-coinbase: the payout queue was crediting the wrong miners
rsantacroce Sep 10, 2026
dde5f1d
pplns-coinbase: the first job after a restart carried no window
rsantacroce Sep 10, 2026
02c6a29
store: read the result of COMMIT
rsantacroce Sep 10, 2026
a4f3627
docs: say when the payout queue settles, finish the forfeit sweep, bu…
rsantacroce Sep 10, 2026
ea305a3
dashboard: say which port a miner should use, and what the floor cost…
rsantacroce Sep 10, 2026
0188572
dashboard: the templates page priced shares for a mode the pool is no…
rsantacroce Sep 10, 2026
8d58ca8
dashboard: the backend-stall warning was reading the chain's clock
rsantacroce Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/check_build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,10 @@ jobs:

- name: Test
run: make test

# The sequence diagrams in docs/simplepool.html are generated, and they
# have gone stale once already — the pplns-coinbase forfeit rule was
# reversed and the old wording stayed drawn into the SVG. This fails if
# the committed HTML no longer matches the specs it came from.
- name: Docs diagrams are up to date
run: python3 docs/sequence-diagrams.py --check
21 changes: 21 additions & 0 deletions .github/workflows/integration_tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ jobs:
- name: Build
run: make -j"$(nproc)"

# Solo first, because it is the default mode and the one most operators
# actually run -- and until now the only mode with no end-to-end test
# anywhere. tests/test_integration.sh looks like one but never mines.
# Two miners with two addresses, so "each coinbase pays its own finder"
# is a claim a single-miner run could not make.
- name: Run solo end-to-end regtest test
run: bash tests/test_solo_regtest.sh

- name: Run end-to-end regtest test
run: bash tests/test_e2e_regtest.sh

Expand All @@ -56,19 +64,32 @@ jobs:
- name: Run PPLNS end-to-end regtest test
run: bash tests/test_pplns_regtest.sh

# The coinbase-direct rail. Distinct from the two above because it has
# no ledger step at all -- the payment IS the block -- so what it proves
# is on-chain: the coinbase pays the window, and no output pays anything
# the pool controls beyond its fee.
- name: Run coinbase-direct PPLNS end-to-end regtest test
run: bash tests/test_pplns_coinbase_regtest.sh

- name: Upload logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-logs-${{ github.run_id }}
path: |
.regtest-solo/logs/
.regtest-e2e/logs/
.regtest-pplns/logs/
.regtest-cbwin/logs/
/tmp/simplepool-e2e.log
/tmp/simplepool-e2e.conf
/tmp/simplepool-int.log
/tmp/simplepool-pplns-*.log
/tmp/simplepool-pplns-*.conf
/tmp/simplepool-cbwin.log
/tmp/simplepool-solo.log
/tmp/simplepool-solo.conf
/tmp/simplepool-cbwin.conf
retention-days: 14
if-no-files-found: ignore

Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,26 @@ jobs:
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
# This release's section of CHANGELOG.md, if it has one. Notes live
# in the repo and are reviewed in the PR that writes them, for the
# same reason the binary is: a release note pasted into the web UI
# traces back to nothing.
{
if [ -f CHANGELOG.md ]; then
awk -v v="## ${version}" '
index($0, v) == 1 { on = 1; print; next }
on && /^## / { exit }
on { print }
' CHANGELOG.md > section.md
if [ -s section.md ]; then
cat section.md
echo
echo "---"
echo
else
echo "> No CHANGELOG.md section for ${version}." >&2
fi
fi
echo "## Install"
echo
echo '```sh'
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
/.regtest-payout/
/.regtest-pplns/
/.regtest-btcpay/
/.regtest-cbwin/
/.regtest-solo/
/.regtest-solo.lock/
/proxy.conf
/tests/integration.proxy.conf
# The installer writes proxy.conf.bak.<timestamp> beside proxy.conf on every
Expand Down
150 changes: 150 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Changelog

Notable changes per release. The newest version is first; each section is what
the release workflow publishes as that release's notes, above the install
boilerplate.

Anything that changes what a miner is paid, or what an operator has to tell
their miners, is called out explicitly — those are the changes that cost
somebody money if they go unread.

## 0.4.0 — three PPLNS modes, and coinbase-direct payouts

The headline is that a pool no longer has to hold miners' money to run PPLNS.

### Three new pool modes

`pool_mode` gains `pplns-thunder`, `pplns-btc` and `pplns-coinbase`, alongside
the existing `solo` and `pps-classic`. All five are documented in
[README](README.md#the-five-modes), with a sequence diagram each in
[docs/simplepool.html](docs/simplepool.html).

PPLNS divides a block among the shares that produced it, so **the pool never
owes more than it has just been paid**. There is no operator reserve to fund
and operator ruin is not a failure mode — the trade is that miners carry the
variance, which is why the fee is normally set lower than on PPS.

- **`pplns-thunder`** settles over Thunder, reusing the existing payout worker.
- **`pplns-btc`** settles on Bitcoin L1 through the enforcer's own wallet.
Needs `bip300301_enforcer --enable-wallet` and `PAYOUT_RAIL=btc`.
- **`pplns-coinbase`** settles in the block itself.

### `pplns-coinbase`: the pool never receives the reward

The block's own coinbase pays the entire window, one output per miner. No pool
wallet, no payout worker, no ledger row, no maturity wait. A reorged block
simply never paid, so there is nothing to claw back.

**What operators must tell their miners.** A coinbase has a fixed budget of
bytes, so one block cannot pay everyone in a large window. Two limits decide
who it pays — `coinbase_max_bytes` (default 1000) and
`pplns_payout_floor_sats` (default 546, the dust limit).

A claim that clears neither is **shared out among the miners that block could
pay** — never the operator, who takes only its fee at every byte budget. The
skipped miner then goes **first in the queue** for the next block: a quarter of
every coinbase's payout slots are reserved for whoever has waited longest.

So being a small miner here costs **frequency, not money**. That is the single
sentence to put on a pool page, and the proxy states the floor at startup, per
template, per block, and on the dashboard before a miner connects.

The queue lives in `pplns_fractions`: a signed fraction of one block reward per
worker, summing to zero. **It is not a balance and the pool holds nothing
against it** — delete the table and nobody is owed a payment, the pool only
forgets whose turn it was. Rows are staged when a block is found and applied
only once it confirms, so an orphaned block rotates nobody.

`coinbase_max_bytes` is settable **per listener**, and usually should be: the
ceiling is a marketplace rule that binds only on the port rented hashrate
connects to, and every byte of it costs a payout.

```
coinbase_max_bytes = 3000
listener = port=3335 label=rental min_diff=500000 initial_diff=500000 max_coinbase_bytes=900
```

### Safety

- **The window walk is bounded.** Reading the PPLNS window used to re-scan the
entire `shares` table on every template — 250 ms per million rows, on the
template thread. It now walks back in bounded batches: flat in history size
rather than linear (8 M rows: 1033 ms → 1.08 ms).
- **A walk that cannot prove it covered the window returns an error**, and the
pool publishes no job rather than a wrong one. Miners keep working the last
job until it recovers. In this mode a wrong window is mined into a coinbase
and published, so there is no later pass that could notice.
- **The payout-slot estimate charges each address what it costs.** It decides
how many slots are reserved for long-waiting miners; assuming a fixed 31
bytes was over by 24 slots on a window of taproot addresses at a 3000-byte
budget, reserving a third of the coinbase where a quarter was meant. Now
within 2 slots across every budget and address type tested, and never over.
- **Store transactions are serialised.** The store shares one SQLite connection
across three threads and nothing guarded it: `BEGIN IMMEDIATE` failed
outright when another was mid-transaction, dropping the write with only a
warning. `store_pplns_distribute` was affected too, surviving on being
retried each tip. A single mutex is now held across each transaction, so a
write waits for at most one batch instead of losing to it.

### Dashboard

- Every mode gets its own guidance on the "About the numbers" card. Previously
all three PPLNS modes fell through to *"this pool has not published its mode
yet"*, directly beneath a header that named the mode correctly.
- Three places answered "not `pps-classic`" with the word *solo*: the worker
page's **Owed** field, the "About the numbers" card, and the
`pps_difficulty` health check.
- The templates page's **PPS rate** row was the fourth, and this bullet used
to claim it fixed. It answered a zero rate with *"only pps-classic prices a
share on arrival"* — a true sentence about a mode the pool is not in, on the
page an operator opens when something looks wrong. It now names the pool's
own mode, and for the PPLNS rails says where the price does come from: the
block value above it is what gets divided, among the window, when a block is
found. The label stops calling itself a PPS rate on a pool that has none,
and the history table drops the rate column when no row was ever priced.
- **"The proxy may not be reaching its backend" was reading the wrong clock.**
The templates page measured staleness from `ts` — when a template was first
seen — which stopped advancing once repeat polls began folding into the row
they match. From then on it reported chain speed as a proxy fault: on a
chain averaging ~30 minutes a block against a 10-minute target, the 900 s
threshold fired on roughly every second block, permanently, while the
backend was in fact being polled every 30 seconds. It now measures from
`last_seen`, which is the column that tracks backend contact, and the
threshold follows the cadence the row was actually polled at — six missed
polls, never sooner than two minutes — so a pool with a deliberately slow
`bitcoind_poll_interval_ms` is not accused of being unreachable either. How
long the chain has stood on one tip is still shown, as the plain fact it is
rather than in the error colour.
- **Pool solvency** counted `blocks_found.reward_sats` as pool revenue in
`pplns-coinbase`, where that is what the block paid the *miners* — reporting
a healthy margin for a pool that holds nothing. Now skipped, with the reason.
- The connect card now says **which port to point which miner at**. Every
published port is listed as a dialable URL with the difficulty behind it and
who it is for, because a stratum URL says nothing about either and a rented
fleet on the home-miner port is one connection submitting hundreds of
thousands of shares a second — the pool limits it and the marketplace
cancels the order for work the pool appears to be rejecting.
- **What a held floor costs is now disclosed to the miner paying for it.** A
port holding difficulty 500 000 over a chain at 1 200 makes its miners
discard roughly 416 of every 417 blocks they solve, since a miner filters
locally at the difficulty it was assigned. That arithmetic was already in
the operator's *"Stratum ports can hold their difficulty"* health check;
nobody mining on the port ever saw it.

### Testing

One end-to-end regtest suite per mode, all in CI, each mining a real chain —
including `solo`, which had none anywhere despite being the default. See
[tests/README.md](tests/README.md).

### Upgrading from 0.3.0

Nothing is required: `solo` and `pps-classic` are unchanged, and the new
`pool_meta` and `pplns_*` tables are created on open. To adopt a PPLNS mode,
set `pool_mode` and read that mode's section in
[INSTALL.md](INSTALL.md) — `pplns-coinbase` in particular refuses
`pool_btc_address`, because it has no pool wallet at all.

## 0.3.0 and earlier

See the [release list](https://github.com/LayerTwo-Labs/simplepool/releases).
7 changes: 7 additions & 0 deletions CLASSIC_PAYOUTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ This is the design behind `pool_mode = pps-classic`, the pool's
Thunder-paying PPS mode. It is implemented and running; this doc
explains the shape and why it looks the way it does.

> Scoped to `pps-classic`. Four other modes exist — see
> [the five modes](README.md#the-five-modes). The finding below (that the
> enforcer does not credit coinbase outputs as deposits) is what rules out
> depositing straight from the coinbase **on a sidechain**; it says nothing
> about paying miners on L1 from the coinbase, which is exactly what `solo`
> and `pplns-coinbase` do and which works.

## Why not deposit straight from the coinbase

The original design (`pool_mode = pps`, since removed) embedded a BIP300
Expand Down
71 changes: 67 additions & 4 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,21 @@ possibilities are called out clearly:
- **`pool_mode = pplns-btc`** — the same accounting, paid on Bitcoin L1
through the enforcer's own wallet. Usernames are Bitcoin addresses.
No Thunder node anywhere in the stack.
- **`pool_mode = pplns-coinbase`** — the same accounting with no custody
at all: the block's coinbase pays the whole window directly, one output
per miner. No pool wallet, no payout worker, no maturity wait.
Usernames are Bitcoin addresses. **A miner whose share of a block is
worth less than `pplns_payout_floor_sats` (default 546) is not paid,
and the amount goes to the operator — it is not carried and not settled
later.** That is deliberate; see the mode's section in
[README.md](README.md#the-five-modes) and publish the floor to your
miners before you run it.

If you cannot fund a PPS reserve, one of the `pplns-*` modes is the
pooled mode you can actually run: the pool never owes more than it has
just been paid.

(A fifth mode, `pool_mode = pps`, put the drivechain deposit directly in
(A sixth mode, `pool_mode = pps`, put the drivechain deposit directly in
the coinbase. The enforcer never credited it, so it has been removed —
`CLASSIC_PAYOUTS.md` has the evidence.)

Expand Down Expand Up @@ -518,6 +527,7 @@ pays, and the rail decides what a stratum username is:
| --- | --- |
| `pplns-thunder` | `<their Thunder address>[.<rig_label>]` |
| `pplns-btc` | `<their Bitcoin address>[.<rig_label>]` |
| `pplns-coinbase` | `<their Bitcoin address>[.<rig_label>]` |

Nothing is credited when a share arrives. A block that reaches **100
confirmations** is split across the shares that produced it, pro rata by
Expand All @@ -532,6 +542,53 @@ The proxy logs all three at startup, because otherwise the first sign of
a misconfiguration is a payout failing 100 blocks after the block was
found.

#### `pplns-coinbase` — the same accounting, no custody

```
# ... same as solo, plus:
pool_mode = pplns-coinbase
pplns_window_diff_multiple = 2.0 # optional; this is the default
pplns_payout_floor_sats = 546 # optional; this is the default (dust limit)
coinbase_max_bytes = 1000 # optional; this is the default
# NO pool_btc_address — the config refuses one in this mode
```

Everything above about maturity and `pps_credits` stops applying here. The
block's own coinbase pays the whole window directly, one output per miner, so
there is no pool wallet, no payout worker, no ledger row and no 100-block
wait. A reorged block simply never paid, and there is nothing to claw back.
**Skip Part F entirely.**

Two limits decide how many miners a block can pay, and both cost miners money
rather than the pool:

- `coinbase_max_bytes` budgets the **whole serialized coinbase**, commitments
included — that is what a rented-hashrate marketplace measures when it
refuses a job as oversized. On a drivechain the BIP300/301 `OP_RETURN`s
spend it before any payout does. Settable per listener
(`listener = port=3335 … max_coinbase_bytes=900`), which is usually what you
want: the ceiling only applies to the port rented hashrate connects to, and
every byte of it costs a payout.
- `pplns_payout_floor_sats` is the least a claim must be worth to get an
output at all.

**A claim that clears neither is paid to the other miners in the window, not
to the operator.** The block still pays out to the satoshi, the pool still
holds nothing, and the operator still takes only its fee.

Being small costs your miners **frequency, not money**. A quarter of every
coinbase's payout slots are reserved for whoever has waited longest, tracked in
`pplns_fractions` as a signed fraction of one block reward per worker that sums
to zero. It is not a balance and you hold nothing against it — delete the table
and nobody is owed a payment, the pool just forgets whose turn it was.

The proxy states the floor at startup, warns per template how many miners fall
below it, reports per block what was redistributed, and publishes the number so
the dashboard states it to miners before they connect. **Publish it on your
pool page as well.** See
[the five modes](README.md#the-five-modes) and
[`VERIFY.md` section 13](VERIFY.md).

### Optional: Redis broadcast

Add to any mode's `proxy.conf`:
Expand Down Expand Up @@ -609,7 +666,7 @@ on every request.

---

## Part F — payout worker (every mode except solo)
## Part F — payout worker (only the modes that pool the reward)

The payout worker drains `pps_credits.accrued_sats - paid_sats`. One
worker, two rails, selected by `PAYOUT_RAIL`:
Expand All @@ -619,9 +676,15 @@ worker, two rails, selected by `PAYOUT_RAIL`:
| `pps-classic` | `thunder` (default) | Thunder transactions from the pool reserve |
| `pplns-thunder` | `thunder` (default) | the same |
| `pplns-btc` | `btc` | Bitcoin L1, via `WalletService/SendTransaction` on the enforcer |
| `solo`, `pplns-coinbase` | — | **do not install this worker**: the coinbase is the payment |

**Skip this whole part on `solo` and `pplns-coinbase`.** Neither writes a
`pps_credits` row, so there is nothing to drain — the worker would run,
find an empty ledger and pay nobody. Harmless, but it is a service to
monitor, alert on and misdiagnose for no reason.

The rail must match `pool_mode`: it is the same choice, and getting it
wrong means the worker cannot pay anyone. Deploy as a systemd service:
For the other three, the rail must match `pool_mode`: it is the same
choice, and getting it wrong means the worker cannot pay anyone. Deploy as a systemd service:

```sh
# assumes deploy/systemd/simplepool-payout.service was already installed
Expand Down
Loading
Loading