diff --git a/.github/workflows/check_build.yaml b/.github/workflows/check_build.yaml index 26ebb1a..d00a83f 100644 --- a/.github/workflows/check_build.yaml +++ b/.github/workflows/check_build.yaml @@ -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 diff --git a/.github/workflows/integration_tests.yaml b/.github/workflows/integration_tests.yaml index 2e6a9d2..b6fab09 100644 --- a/.github/workflows/integration_tests.yaml +++ b/.github/workflows/integration_tests.yaml @@ -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 @@ -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 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index bd2576b..f4c9bfa 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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' diff --git a/.gitignore b/.gitignore index 957b9fe..4e837b2 100644 --- a/.gitignore +++ b/.gitignore @@ -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. beside proxy.conf on every diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..db4d835 --- /dev/null +++ b/CHANGELOG.md @@ -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). diff --git a/CLASSIC_PAYOUTS.md b/CLASSIC_PAYOUTS.md index 8333b07..3b6f095 100644 --- a/CLASSIC_PAYOUTS.md +++ b/CLASSIC_PAYOUTS.md @@ -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 diff --git a/INSTALL.md b/INSTALL.md index b550f80..cc6ef35 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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.) @@ -518,6 +527,7 @@ pays, and the rail decides what a stratum username is: | --- | --- | | `pplns-thunder` | `[.]` | | `pplns-btc` | `[.]` | +| `pplns-coinbase` | `[.]` | Nothing is credited when a share arrives. A block that reaches **100 confirmations** is split across the shares that produced it, pro rata by @@ -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`: @@ -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`: @@ -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 diff --git a/Makefile b/Makefile index 4fc3d46..df5cdeb 100644 --- a/Makefile +++ b/Makefile @@ -60,14 +60,14 @@ BIN := $(BUILD_DIR)/simplepool # different question: a tree gets patched or moves on past the last `make`, # and from then on its HEAD is not what the running process was built from. # Empty outside a git checkout (release tarball) — reported as "unknown". -VERSION := 0.3.0 +VERSION := 0.4.0 GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null) GIT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD 2>/dev/null) GIT_DIRTY := $(shell git status --porcelain --untracked-files=no 2>/dev/null | head -1) VERSION_H := $(BUILD_DIR)/version_gen.h # Sources compiled in this wave. More modules land in later waves. -SRCS := src/main.c src/log.c src/config.c src/coinbase.c \ +SRCS := src/main.c src/log.c src/config.c src/coinbase.c src/pplns.c \ src/share.c src/sha256.c src/stratum.c src/store.c \ src/bitcoind.c src/broadcast.c src/thunder.c src/version.c \ src/reconcile.c src/cjson/cJSON.c @@ -124,17 +124,21 @@ include tests/test_broadcast.mk include tests/test_thunder.mk include tests/test_config.mk include tests/test_reconcile.mk +include tests/test_pplns.mk +include tests/test_store_walk.mk -test: build/test_share build/test_bitcoind build/test_stratum build/test_store build/test_coinbase build/test_broadcast build/test_thunder build/test_config build/test_reconcile +test: build/test_share build/test_bitcoind build/test_stratum build/test_store build/test_store_walk build/test_coinbase build/test_broadcast build/test_thunder build/test_config build/test_reconcile build/test_pplns ./build/test_share ./build/test_bitcoind ./build/test_stratum ./build/test_store + ./build/test_store_walk ./build/test_coinbase ./build/test_broadcast ./build/test_thunder ./build/test_config ./build/test_reconcile + ./build/test_pplns # Run the suites under AddressSanitizer + UndefinedBehaviorSanitizer. # @@ -157,14 +161,20 @@ asan: src/log.c src/cjson/cJSON.c -lpthread $(CC) $(ASAN_CFLAGS) -o $(ASAN_DIR)/test_store tests/test_store.c \ src/store.c src/log.c $(PLATFORM_LDFLAGS) -lsqlite3 -lpthread + $(CC) $(ASAN_CFLAGS) -Wno-unused-function -o $(ASAN_DIR)/test_store_walk \ + tests/test_store_walk.c src/log.c $(PLATFORM_LDFLAGS) -lsqlite3 -lpthread $(CC) $(ASAN_CFLAGS) -o $(ASAN_DIR)/test_coinbase tests/test_coinbase.c \ src/coinbase.c src/sha256.c $(CC) $(ASAN_CFLAGS) -o $(ASAN_DIR)/test_share tests/test_share.c \ src/share.c src/sha256.c + $(CC) $(ASAN_CFLAGS) -o $(ASAN_DIR)/test_pplns tests/test_pplns.c \ + src/pplns.c src/coinbase.c src/sha256.c ./$(ASAN_DIR)/test_stratum ./$(ASAN_DIR)/test_store + ./$(ASAN_DIR)/test_store_walk ./$(ASAN_DIR)/test_coinbase ./$(ASAN_DIR)/test_share + ./$(ASAN_DIR)/test_pplns # Line and function coverage of the C suites, via LLVM source-based coverage. # @@ -205,6 +215,10 @@ coverage: $(CC) $(COV_CFLAGS) -o $(COV_DIR)/test_thunder tests/test_thunder.c src/thunder.c $(CC) $(COV_CFLAGS) -o $(COV_DIR)/test_config tests/test_config.c \ src/config.c src/log.c src/coinbase.c src/sha256.c + $(CC) $(COV_CFLAGS) -Wno-unused-function -o $(COV_DIR)/test_store_walk \ + tests/test_store_walk.c src/log.c -lsqlite3 -lpthread + $(CC) $(COV_CFLAGS) -o $(COV_DIR)/test_pplns tests/test_pplns.c \ + src/pplns.c src/coinbase.c src/sha256.c $(CC) $(COV_CFLAGS) -o $(COV_DIR)/test_reconcile tests/test_reconcile.c \ src/reconcile.c src/store.c src/log.c $(PLATFORM_LDFLAGS) -lsqlite3 -lpthread @set -e; for t in stratum store coinbase share bitcoind broadcast thunder config reconcile; do \ @@ -216,7 +230,8 @@ coverage: @xcrun llvm-cov report $(COV_DIR)/test_stratum \ $(addprefix -object ,$(COV_DIR)/test_store $(COV_DIR)/test_coinbase \ $(COV_DIR)/test_share $(COV_DIR)/test_bitcoind $(COV_DIR)/test_broadcast \ - $(COV_DIR)/test_thunder $(COV_DIR)/test_config $(COV_DIR)/test_reconcile) \ + $(COV_DIR)/test_thunder $(COV_DIR)/test_config $(COV_DIR)/test_reconcile \ + $(COV_DIR)/test_pplns $(COV_DIR)/test_store_walk) \ -instr-profile=$(COV_DIR)/all.profdata $(COV_IGNORE) format: diff --git a/OPERATOR_GUIDE.md b/OPERATOR_GUIDE.md index d13d9ed..f6501fe 100644 --- a/OPERATOR_GUIDE.md +++ b/OPERATOR_GUIDE.md @@ -4,6 +4,26 @@ Everything you need to run this pool day-to-day. Assumes the branch already deployed (see `CLASSIC_PAYOUTS.md` for background on why the design looks like this). +> **This guide is specific to `pool_mode = pps-classic`.** The pool ships five +> modes and they differ in what a stratum username is, whether a payout worker +> exists at all, and who holds the money in between — so the operational +> advice below does not transfer wholesale. See +> [the five modes](README.md#the-five-modes) for what each one is, and in +> particular: +> +> - `solo` and `pplns-coinbase` have **no payout worker and no pool wallet**; +> the coinbase is the payment. Everything here about Thunder deposits, the +> reserve, and `simplepool-payout.service` simply does not apply. +> - `pplns-thunder` and `pplns-btc` reuse this guide's payout worker, but pay +> on maturity out of a block actually found rather than a reserve, so there +> is no reserve to size or top up. +> - `pplns-coinbase` additionally has a **payout floor**: a claim worth less +> than `pplns_payout_floor_sats` gets no output in that block. What it was +> owed is shared among the miners the block could pay — never you — and the +> skipped miner goes first in the queue for the next block. You take your fee +> and nothing else. That is a policy to publish to your miners, not just a +> setting. [`VERIFY.md` section 13](VERIFY.md) is its operational checklist. + --- ## Quick reference diff --git a/README.md b/README.md index 22fe786..4ba0f82 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,16 @@ connections on TCP `:3334`, builds block templates via `bitcoind`'s accepted share into a local SQLite database. A separate Node.js dashboard reads that file for stats. -It runs in four modes, which differ in who carries the variance: **solo**, -where the miner who finds a block is paid in that block's own coinbase; -**pps-classic**, where every accepted share earns a derivable amount and the -operator absorbs the variance out of a reserve; and **pplns-thunder** / -**pplns-btc**, where a matured block is split across the shares that produced -it, so the miners carry the variance and the pool never owes more than it has -just been paid. All four ship in this repo — see [The four -modes](#the-four-modes) below. +It runs in five modes, which differ in who carries the variance and who holds +the money in between: **solo**, where the miner who finds a block is paid in +that block's own coinbase; **pps-classic**, where every accepted share earns a +derivable amount and the operator absorbs the variance out of a reserve; +**pplns-thunder** / **pplns-btc**, where a matured block is split across the +shares that produced it, so the miners carry the variance and the pool never +owes more than it has just been paid; and **pplns-coinbase**, which is that +same PPLNS accounting with the custody removed — the block's own coinbase pays +the whole window directly, one output per miner. All five ship in this repo — +see [The five modes](#the-five-modes) below. Created by **Roberto Santacroce**. Canonical repository: . @@ -46,11 +48,11 @@ curl -fsSL https://raw.githubusercontent.com/LayerTwo-Labs/simplepool/main/scrip > audit every number — lives at [`docs/simplepool.html`](docs/simplepool.html). > Open it from disk or serve it next to the dashboard. -### The four modes +### The five modes -This repository ships **all four**, selected by `pool_mode` in -`proxy.conf`. They differ in two independent things — whether the coinbase -pays the miner or the pool, and what a stratum username is: +This repository ships **all five**, selected by `pool_mode` in +`proxy.conf`. They differ in two independent things — who the coinbase pays, +and what a stratum username is: | `pool_mode` | coinbase pays | username | who carries the variance | | --- | --- | --- | --- | @@ -58,6 +60,7 @@ pays the miner or the pool, and what a stratum username is: | `pps-classic` | the pool | Thunder address | the operator, out of a reserve | | `pplns-thunder` | the pool | Thunder address | the miners | | `pplns-btc` | the pool | Bitcoin address | the miners | +| `pplns-coinbase` | **the whole window, directly** | Bitcoin address | the miners | - **`pool_mode = solo`** (default) — every share lands in the local SQLite store, every accepted block is paid directly in its own @@ -129,8 +132,10 @@ pays the miner or the pool, and what a stratum username is: recomputed when it is paid: those moments are ~100 blocks apart and the chain can retarget in between. - The two differ only in the rail the balance is finally paid over, and - that choice is what a stratum username has to be: + These two differ only in the rail the balance is finally paid over, and + that choice is what a stratum username has to be. (The third PPLNS mode, + `pplns-coinbase` below, has no balance and no rail at all — it pays out of + the block itself.) - **`pplns-thunder`** pays over Thunder, like `pps-classic`, and reuses the same payout worker draining the same `pps_credits` table. Username @@ -147,6 +152,100 @@ pays the miner or the pool, and what a stratum username is: separate rail knob, so the inconsistent configuration is unrepresentable rather than merely rejected. +- **`pool_mode = pplns-coinbase`** — the same PPLNS accounting as the two + rails above, with the custody taken out. There is no pool wallet, no + payout worker, no `pps_credits` row and no maturity wait: the block's own + coinbase pays the entire window directly, one output per miner, largest + claim first. A reorged block simply never paid, so there is nothing to + claw back. Username is a Bitcoin address. + + The window is snapshotted onto the job when the template is built, so the + coinbase pays the work that exists *now* rather than work from 100 blocks + ago. On a drivechain the coinbase comes from the CUSF enforcer, and its + BIP300/301 commitment `OP_RETURN`s are preserved byte-for-byte — only the + enforcer's own reward output is replaced, by the window. + + **Two limits decide how many miners one block can pay:** + + - `coinbase_max_bytes` (default 1000) budgets the *whole serialized + coinbase*, commitments included, because that is what a rented-hashrate + marketplace measures when it decides a job is oversized. A production + coinbase-direct pool reports whole coinbases of 721–817 bytes paying up + to 16 miners, where the same 16 payouts cost 817 bytes against four + drivechain `OP_RETURN`s and 769 against three. A cap counted in outputs + cannot see that; a byte budget can. + + It is settable **per listener**, and usually should be. The ceiling is a + marketplace rule enforced on the port the rented hashrate connects to, and + every byte of it costs a payout — a 100-miner window pays 9 at 400 bytes + and 93 at 3000 — so there is no reason to make your own miners live under a + limit their port is never measured against: + + ``` + coinbase_max_bytes = 3000 + listener = port=3335 label=rental min_diff=500000 initial_diff=500000 max_coinbase_bytes=900 + ``` + + A listener that sets none uses the server-wide value. + - `pplns_payout_floor_sats` (default 546, the dust limit) is the minimum + 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. + + > This was the other way round until [#76][pr76]. A dropped claim used to + > ride on the operator's output, defended as a dust policy. Measurement + > killed it: with 100 miners on a 1/n hashrate spread and the default + > budget, 28 were paid, **72 were cut by the byte cap and none by the dust + > floor**, and the operator received **25% of the block on a 1% fee**. The + > take also rose as the coinbase shrank — 46% at 400 bytes against 2% at + > 3000 — so starving your own miners was the revenue-maximising move. + > Credit to [@Wired4ncer][pr76], who runs the pool that showed it. + + **Being small costs you frequency, not money.** A miner's share of the + window tracks its hashrate, so without help the largest claims would take + the same slots every block and the same addresses would never be paid at + all. A quarter of each coinbase's slots are therefore reserved for whoever + has waited longest, tracked in `pplns_fractions`: a signed fraction of one + block reward per worker, positive if you were skipped and negative if you + were paid early out of someone else's skipped share. The column sums to + zero. + + That is **not a balance and the pool holds nothing against it**. Nothing is + ever 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. Rows are staged when a block is found and applied + only once it is confirmed, so an orphaned block — which paid nobody — + rotates nobody. + + "Confirmed" means one block deep, not a hundred: the queue has to reflect + the last block before the next one is built, and money is not at stake. The + cost is that a block reorged out *after* that first confirmation keeps its + rotation — the miners it paid stay at the back of the queue and the ones it + skipped stay at the front — for a payment that never stood. That is one turn + out of order, never a satoshi, and the next block found corrects it. + + **If the pool cannot measure the window, it publishes no job at all.** The + window is read back over a bounded walk of the shares table; if that walk + cannot prove it covered the configured window — an IO error, a lock held too + long — it returns an error rather than a short answer, and the template is + held back. Miners keep working the last job until it recovers, which costs + hashrate on a new tip but is the only safe direction: in this mode the window + is rendered into a coinbase and published, so a wrong one is mined, + irreversible, and invisible afterwards. `pplns window walk did not cover …` + in the log is that guard firing, not a crash. + + The floor is disclosed in four places: the proxy states it at startup, logs + how many miners in the current window fall below it, reports per block what + was redistributed and to whom — and publishes the number to `pool_meta`, so + the **dashboard states it to miners before they connect**. That last one is + the one that matters: the operator's log is the one place the miner it + affects cannot look. + +[pr76]: https://github.com/LayerTwo-Labs/simplepool/pull/76 + In every mode the operator fee stays in BTC, paid to `operator_address` out of the same coinbase. On PPLNS it is normally set lower than on PPS: there is no variance being absorbed, so there is no risk premium to charge @@ -601,9 +700,22 @@ The script: 5. Asserts that `workers` has at least one row, `workers.payout_address` is populated, and `rejects` has at least one row. -There is also a full end-to-end regtest (`tests/test_e2e_regtest.sh`) and a -payout regtest (`tests/test_payout_regtest.sh`); both run in CI. For the -verification checklist behind each mode, see [`VERIFY.md`](VERIFY.md). +Note what that integration test is not: it never mines, so it cannot see +whether a coinbase pays the right person. The end-to-end suites do, one per +mode, each mining a real chain: + +| Suite | Proves | +| --- | --- | +| `tests/test_solo_regtest.sh` | two miners, two addresses, a block each — every coinbase pays **its own finder**, rendered per connection | +| `tests/test_e2e_regtest.sh` | `pps-classic`: the coinbase pays the pool, and shares accrue at the derived rate | +| `tests/test_pplns_regtest.sh` | both pooled PPLNS rails distribute a matured block exactly once | +| `tests/test_pplns_btc_payout_regtest.sh` | `pplns-btc` pays miners on L1 through the enforcer wallet | +| `tests/test_pplns_coinbase_regtest.sh` | `pplns-coinbase`: the block's coinbase pays the window, the pool holds nothing, the payout floor is disclosed, and a mixed 100 : 10 : 1 window really does redistribute the smallest claim across the miners that fit — on chain, with the operator holding only its fee and the payout queue summing to zero | +| `tests/test_payout_regtest.sh` | the Thunder payout rail settles and confirms | + +All of them run in CI. For the verification checklist behind each mode, see +[`VERIFY.md`](VERIFY.md); for what changed in each release, see +[`CHANGELOG.md`](CHANGELOG.md). ## Layout diff --git a/RELEASING.md b/RELEASING.md index b63ee6e..8e0a0db 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -8,7 +8,7 @@ to a commit anyone can check out.** Nothing is uploaded by hand. PR (bump VERSION) → merge → git tag vX.Y.Z → CI builds + publishes ``` -## 1. Bump the version in a PR +## 1. Write the notes, and bump the version, in a PR `VERSION` lives in the [Makefile](Makefile) and is compiled into the binary — `simplepool --version` reports it, and so does `/api/versions` on the @@ -17,13 +17,31 @@ because a release whose own binary reports a different version is worse than no release: it makes every later "which version is this box running?" answer untrustworthy. +Add a `## X.Y.Z` section at the top of [CHANGELOG.md](CHANGELOG.md) in the +same PR. The release job publishes that section verbatim above the install +boilerplate, so the notes are reviewed like everything else — a release note +pasted into the web UI traces back to nothing, which is the one thing this +process exists to prevent. A tag with no matching section still releases; it +just ships the boilerplate alone. + +Lead with anything that changes **what a miner is paid** or **what an operator +has to tell their miners**. Those are the lines that cost somebody money if +they go unread. + ```sh git checkout -b release-0.2.0 sed -i 's/^VERSION := .*/VERSION := 0.2.0/' Makefile +$EDITOR CHANGELOG.md # add the 0.2.0 section git commit -am "Release 0.2.0" gh pr create --fill ``` +Preview exactly what the release job will publish: + +```sh +awk -v v="## 0.2.0" 'index($0,v)==1{on=1;print;next} on&&/^## /{exit} on{print}' CHANGELOG.md +``` + Merge it. Everything below runs against `main`. ## 2. Tag diff --git a/VERIFY.md b/VERIFY.md index efe4ba0..6507bb9 100644 --- a/VERIFY.md +++ b/VERIFY.md @@ -1,4 +1,4 @@ -# `pps-thunder` — verification checklist +# Verification checklist Step-by-step checks to confirm each landed piece behaves as advertised. Tick boxes as you go; each section is independent and you can skip @@ -8,6 +8,25 @@ investigate. If a step fails, the section header points at the commit that owns the behaviour, so `git show ` is a quick way to inspect. +**Sections 0–12 were written for the original `pps-thunder` work and are +organised by the commits that landed it.** They still hold, but they predate +four of the five pool modes. What covers the modes now is one end-to-end +regtest suite each, all of them in CI, all of them mining a real chain: + +| Mode | Suite | +| --- | --- | +| `solo` | `tests/test_solo_regtest.sh` | +| `pps-classic` | `tests/test_e2e_regtest.sh` | +| `pplns-thunder`, `pplns-btc` | `tests/test_pplns_regtest.sh` | +| `pplns-btc` payouts | `tests/test_pplns_btc_payout_regtest.sh` | +| `pplns-coinbase` | `tests/test_pplns_coinbase_regtest.sh` | +| Thunder payout rail | `tests/test_payout_regtest.sh` | + +Running `bash tests/.sh` is a stronger check than any manual section +below, because it asserts against the chain rather than against a log. Section +13 is the manual pass for `pplns-coinbase`, which is the mode with a policy an +operator has to decide on rather than merely configure. + --- ## 0 · Prerequisites (one-time) @@ -459,7 +478,101 @@ drove the operator-triggered deposit design in --- -## 13 · Teardown +## 13 · Coinbase-direct PPLNS (`pplns-coinbase`) + +Owns: the coinbase-direct rail. The automated version of all of this is +`bash tests/test_pplns_coinbase_regtest.sh`; do that first. This section is +the manual pass, and it exists because this mode has a **policy** an operator +has to agree with, not just a config to fill in. + +### 13.1 · The config refuses what the mode cannot do + +- [ ] `pool_btc_address` set alongside `pool_mode = pplns-coinbase` is + refused at startup: *"'pool_btc_address' must not be set when + pool_mode=pplns-coinbase"*. There is no pool wallet in this mode. +- [ ] `pplns_payout_floor_sats = -1` is refused (*"must be >= 0"*). +- [ ] `coinbase_max_bytes = 150` is refused (*"too small to hold a coinbase + and a single payout"*). +- [ ] No payout worker is installed. `solo` and `pplns-coinbase` need none — + see [`payout/README.md`](payout/README.md). + +### 13.2 · The floor is disclosed, four ways + +A block cannot pay everyone in a large window, so miners have to know both +halves: what one block may not pay them, and what happens to it. Check this +rather than assume it. + +- [ ] **Startup**, beside the identity line: *"payout floor N sats — a miner + whose share of a block is worth less than that is NOT PAID…"*. It prints + even when the node is unreachable, because it is a config fact. +- [ ] **Per template**, when someone in the window is below it: *"N of M + miner(s) in the window are below the …-sat payout floor and will earn + NOTHING from the next block"*. Only re-logged when the count changes. +- [ ] **Per block**: either *"paid all N miner(s)"* or *"N claim(s) worth X + sats had no room and were REDISTRIBUTED across the miners who did fit"*. +- [ ] **The dashboard**, before anyone connects. Open `/` and read the + "About the numbers" card: it must state the floor in sats and say the + amount is *shared out among the miners that block could pay*, and that + the miner goes *first in the queue* for the next one. If it does not, the + proxy is on a build that predates `pool_meta.pplns_payout_floor_sats` — + the card stays silent rather than inventing a default, so check + `sqlite3 shares.db "SELECT pplns_payout_floor_sats FROM pool_meta"`. +- [ ] You have published the floor on your pool page. Nothing in the software + can do this one for you. + +### 13.3 · The money, read off the chain + +Not out of the pool's own database — that is the pool marking its own +homework. `bitcoin-cli getblock 2 | jq '.tx[0].vout'`: + +- [ ] One output per miner in the window, plus the operator's. +- [ ] **No output pays an address the pool controls** beyond the operator fee. + There is no pool wallet, so a third address means something is wrong. +- [ ] The outputs sum to the whole block reward. A coinbase paying out less + than it may destroys the difference. +- [ ] The operator output is **exactly `fee_bps` of the block, and no more**, + on every block — including ones that could not pay the whole window. + This is the check that matters most: until #76 a dropped claim rode on + the operator's output, and on a 100-miner window that came to 25% of the + block against a 1% advertised fee. +- [ ] The miners who *were* paid received **more than their own window + share**, and the outputs still sum to the whole block. That is the + redistribution arriving — if the total is short, value was destroyed + rather than shared. +- [ ] `sqlite3 shares.db "SELECT COUNT(*) FROM pps_credits"` is **0**. This + mode credits no balance, ever. Any row means a pooled mode's accrual + path ran. +- [ ] The payout queue balances: + `sqlite3 shares.db "SELECT ROUND(COALESCE((SELECT SUM(delta) FROM + pplns_pending_fractions),0) + COALESCE((SELECT SUM(owed_fraction) FROM + pplns_fractions),0), 9)"` is **0**. It is a record of whose turn it is, + not money — a non-zero sum means somebody's turn was invented or + destroyed. +- [ ] After a block is found but before it confirms, its rows are in + `pplns_pending_fractions` and **not** in `pplns_fractions`. An orphaned + block paid nobody and must rotate nobody; the confirmation pass is what + applies them. It applies them at ONE confirmation, so a block reorged + out after that has already rotated the queue and is not reversed — a + turn out of order, not money, and the next block corrects it. + +### 13.4 · The byte budget + +- [ ] On a rented port, set `max_coinbase_bytes=` on that **listener** rather + than server-wide. The ceiling is a marketplace rule that binds only on + the port the rented hashrate connects to, and every byte of it costs a + payout — a 100-miner window pays 9 at 400 bytes and 93 at 3000. + +- [ ] Measure a real coinbase: `bitcoin-cli getblock 2 | + jq -r '.tx[0].hex' | wc -c` ÷ 2 = bytes. Compare against + `coinbase_max_bytes`. +- [ ] On a drivechain, note the BIP300/301 `OP_RETURN` count. They spend the + same budget the payouts do, so the number of miners a block can pay + moves with sidechain activity. Reported in production: the same 16 + payouts cost 817 bytes against four commitments and 769 against three. + +--- + +## 14 · Teardown ``` scripts/regtest/stop.sh diff --git a/dashboard/README.md b/dashboard/README.md index 9fbe7d1..2ac571a 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -76,11 +76,11 @@ report that a check is failing was still produced successfully. Watch ## Pool identity Every page carries a strip under the header naming what this pool actually -is: the **network** its coinbases are built for, the **mode** (`solo` or -`pps-classic`) and fee, the **coinbase tag**, the **operator address** the -fee is paid to, and — under `pps-classic` — the **pool wallet** the -net-of-fee reward goes to. `/api/status` returns the same five fields under -`pool`. +is: the **network** its coinbases are built for, the **mode** (one of `solo`, +`pps-classic`, `pplns-thunder`, `pplns-btc` or `pplns-coinbase`) and fee, the +**coinbase tag**, the **operator address** the fee is paid to, and — in the +modes that pool the reward — the **pool wallet** the net-of-fee reward goes +to. `/api/status` returns the same fields under `pool`. None of it is derivable from the stratum URL a miner was handed. The port looks identical whether the pool is mining mainnet or regtest, whether a @@ -110,17 +110,39 @@ question. The explanatory card on `/` branches on `pool_mode`, because almost nothing in it is shared between the modes: -| | `solo` | `pps-classic` | -| --- | --- | --- | -| A share that isn't a block | worth nothing | credited at the live rate | -| Block reward goes to | the finder, in the coinbase | the pool's BTC wallet | -| Stratum username | a **Bitcoin** address (P2WPKH / P2PKH / P2SH — **not** taproot) | a **Thunder** address | -| Rejection if you get it wrong | `invalid payout address in stratum username` | `invalid thunder address` | - -That last row is why this is not cosmetic. `src/stratum.c` branches on -`pps_enabled` at authorize, so the card's instructions are load-bearing: a -solo pool that tells miners to use a Thunder address is telling them to do -the one thing that cannot work. +| | `solo` | `pps-classic` | `pplns-thunder` | `pplns-btc` | `pplns-coinbase` | +| --- | --- | --- | --- | --- | --- | +| A share that isn't a block | worth nothing | credited at the live rate | a claim on the next block found | a claim on the next block found | a claim on the next block found | +| Block reward goes to | the finder, in the coinbase | the pool's BTC wallet | the pool's BTC wallet | the pool's BTC wallet | **the whole window, in the coinbase** | +| A balance moves | never | as each share arrives | on maturity, over Thunder | on maturity, on L1 | never — the block is the payment | +| Stratum username | a **Bitcoin** address (P2WPKH / P2PKH / P2SH — **not** taproot) | a **Thunder** address | a **Thunder** address | a **Bitcoin** address | a **Bitcoin** address | +| Rejection if you get it wrong | `invalid payout address in stratum username` | `invalid thunder address` | `invalid thunder address` | `invalid payout address in stratum username` | `invalid payout address in stratum username` | + +The username row is why this is not cosmetic. `src/stratum.c` branches at +authorize, so the card's instructions are load-bearing: a pool that tells +miners to use a Thunder address when it wants a Bitcoin one is telling them to +do the one thing that cannot work. + +**`pplns-coinbase` gets one more thing the others do not: the payout floor.** +That mode does not pay a claim worth less than `pplns_payout_floor_sats` — it +shares it out among the miners that block could pay — never the operator, who +takes only its fee — and puts the skipped miner first in the queue for the next +block. The card says all three things, because a miner deciding whether to +point a rig here needs to know that being small costs them frequency rather +than money, and that nothing is being held on their behalf. The card states the number before anyone connects, because the +operator's log is the one place the miner it costs cannot look. It renders +only when the proxy published a floor (`pool_meta.pplns_payout_floor_sats`); +an older proxy stores NULL, and printing a default there would be stating some +other operator's policy for them. + +A note on what this card used to do: it branched on `solo` / `pps-classic` +only, so all three PPLNS modes fell through to *"this pool has not published +its mode yet"* — directly beneath an identity strip that named the mode +correctly — followed by connection guidance for two modes, neither of which +was theirs. Three other places answered "not `pps-classic`" with the word +*solo*: the worker page's **Owed** field, the templates page's PPS rate, and +the `pps_difficulty` health check. If you add a sixth mode, those are the +places to check. Every figure comes from `pool_meta` — rate, gross, fee, operator address, pool wallet, network — and the address examples follow the pool's network, so diff --git a/dashboard/lib/health.js b/dashboard/lib/health.js index e11fde5..855ad94 100644 --- a/dashboard/lib/health.js +++ b/dashboard/lib/health.js @@ -130,6 +130,17 @@ export function health(handle) { * its first confirmed block, so the margin is legitimately negative until * one lands. That is the honest number, not a fault in the check. */ checks.push(guard('margin', 'Pool solvency', () => { + /* Not a question that exists in pplns-coinbase. There the coinbase + * pays the miners directly, so blocks_found.reward_sats is what the + * block paid THEM -- the pool never received it and owes nobody. + * Summing it as pool revenue reported a healthy 50 BTC of solvency + * for a pool holding precisely nothing, which is a green light + * asserting custody that does not exist. */ + const mode = one(d, 'SELECT pool_mode FROM pool_meta WHERE id = 1')?.pool_mode; + if (mode === 'pplns-coinbase') { + return { ok: true, value: null, + detail: 'pplns-coinbase — the pool never holds the reward' }; + } const r = one(d, ` SELECT (SELECT COALESCE(SUM(reward_sats),0) + COALESCE(SUM(fee_sats),0) FROM blocks_found WHERE status = 'confirmed') @@ -161,7 +172,14 @@ export function health(handle) { checks.push(guard('pps_difficulty', 'Difficulty supports PPS', () => { const meta = one(d, 'SELECT pool_mode, network_difficulty FROM pool_meta WHERE id = 1'); if (!meta || meta.pool_mode !== 'pps-classic') { - return { ok: true, value: null, detail: 'solo — no accrual' }; + /* Name the mode we are actually in. This used to say "solo — no + * accrual" for every non-pps-classic mode, so a pplns pool of any + * kind was told it was solo by the same page whose header said + * otherwise. The check skipping is right -- only pps-classic + * prices a share on arrival -- but the reason has to be true. */ + const m = meta?.pool_mode || 'unknown'; + return { ok: true, value: null, + detail: `${m} — shares are not priced on arrival` }; } const r = one(d, ` SELECT COALESCE(SUM(difficulty),0) AS sd, diff --git a/dashboard/lib/stats.js b/dashboard/lib/stats.js index 25f239b..1577bf8 100644 --- a/dashboard/lib/stats.js +++ b/dashboard/lib/stats.js @@ -438,11 +438,13 @@ function poolIdentity(d) { const blank = { network: null, network_source: null, coinbase_tag: null, operator_address: null, pool_btc_address: null, listeners: null, + pplns_payout_floor_sats: null, }; try { const r = d.prepare(` SELECT network, network_source, coinbase_tag, - operator_address, pool_btc_address, listeners + operator_address, pool_btc_address, listeners, + pplns_payout_floor_sats FROM pool_meta WHERE id = 1 `).get(); if (!r) return blank; @@ -456,6 +458,15 @@ function poolIdentity(d) { operator_address: or_(r.operator_address), pool_btc_address: or_(r.pool_btc_address), listeners: parseListeners(r.listeners), + /* NULL means "this mode has no payout floor", which is every mode + * but pplns-coinbase. Kept distinct from 0, which is a real floor + * meaning "pay anything the dust limit allows" -- so `?? null` + * rather than `|| null`, or a zero floor would read as no floor + * and the page would stop disclosing a policy that still applies. */ + pplns_payout_floor_sats: + r.pplns_payout_floor_sats === undefined || + r.pplns_payout_floor_sats === null + ? null : Number(r.pplns_payout_floor_sats), }; } catch { return blank; /* DB predating the identity columns */ @@ -481,6 +492,15 @@ function parseListeners(raw) { label: (typeof l.label === 'string' && l.label) ? l.label : null, min_diff: Number.isFinite(Number(l.min_diff)) ? Number(l.min_diff) : null, initial_diff: Number.isFinite(Number(l.initial_diff)) ? Number(l.initial_diff) : null, + /* Carried for the same reason health.js reads it: min_diff is the + * rate-loop bound, which the network difficulty still clamps, + * while promised_min_diff is KEPT when the chain is easier. Only + * the second one costs a miner blocks, so only the second one + * earns the warning the connect card prints. A proxy predating + * the field publishes nothing, which reads as 0 — no promise. */ + promised_min_diff: + Number.isFinite(Number(l.promised_min_diff)) + ? Number(l.promised_min_diff) : 0, })); return out.length ? out : null; } @@ -519,9 +539,10 @@ export function poolMeta(handle) { fee_drift_bps: Number(r.effective_fee_bps || 0) - Number(r.fee_bps || 0), /* Does a balance build up in pps_credits between payouts? * - * True of PPS and of both PPLNS modes -- they share the table and - * the payout worker that drains it. Only solo accrues nothing, - * because its coinbase pays the finder directly. + * True of PPS and of the two CUSTODIAL PPLNS rails -- they share + * the table and the payout worker that drains it. Solo and + * pplns-coinbase accrue nothing, because in both the coinbase + * itself is the payment and there is no balance to hold. * * It is deliberately not "is there a rate": PPS prices a share the * moment it arrives, PPLNS values it in hindsight out of a block diff --git a/dashboard/test/about-numbers.test.js b/dashboard/test/about-numbers.test.js index e56e175..e149877 100644 --- a/dashboard/test/about-numbers.test.js +++ b/dashboard/test/about-numbers.test.js @@ -139,10 +139,14 @@ test('an unknown mode describes both and commits to neither', async () => { for (const pool of [null, { pool_mode: null, fee_bps: 0 }]) { const html = await card(pool); assert.match(html, /has not published its mode/); - /* Both named, so a miner knows what to ask the operator — but no - * username form is asserted, because guessing costs them time. */ - assert.match(html, /solo/); - assert.match(html, /pps-classic/); + /* Every mode named, so a miner knows what to ask the operator — but + * no username form is asserted, because guessing costs them time. + * The list grew from two to five; a page that names only the two it + * was written for is a page that quietly stopped being complete. */ + for (const m of ['solo', 'pps-classic', 'pplns-thunder', + 'pplns-btc', 'pplns-coinbase']) { + assert.match(html, new RegExp(m), `${m} should be named`); + } assert.doesNotMatch(html, /your-Thunder-address/); assert.doesNotMatch(html, /your-bitcoin-address/); } diff --git a/dashboard/test/audit.test.js b/dashboard/test/audit.test.js index 319bcf1..63f573f 100644 --- a/dashboard/test/audit.test.js +++ b/dashboard/test/audit.test.js @@ -736,3 +736,84 @@ test('the templates page renders on a DB with no template history', async () => }); assert.match(html, /has not recorded any templates yet/); }); + +/* ---------- template staleness (the two clocks) -------------------------- */ + +/* The warning under the current template claims the proxy may not be reaching + * its backend. It used to measure that from `ts` — first seen — which stopped + * advancing once repeat polls began folding into the row they match (a578a48). + * From then on it was reading chain speed and reporting it 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 being polled every 30 seconds. */ +async function tipHeldFor(db, { heldSec, polledSecAgo, polls }) { + const now = Math.floor(Date.now() / 1000); + addTemplate(db, { height: 997058, ts: now - heldSec }); + db.prepare('UPDATE templates SET last_seen = ?, polls = ? WHERE height = 997058') + .run(now - polledSecAgo, polls); + const html = await render('templates.ejs', { + templates: stats.templates(db), fmtBtc: stats.fmtBtc, + }); + /* The markup wraps these sentences across source lines, so match them + * the way the reader sees them rather than the way EJS emits them. */ + return html.replace(/\s+/g, ' '); +} + +test('a long-standing tip is not reported as an unreachable backend', async () => { + /* The alphanet case: 30 minutes on one template, polled 10 s ago. */ + const { db } = makeDb(); + const html = await tipHeldFor(db, { heldSec: 1830, polledSecAgo: 10, polls: 62 }); + assert.doesNotMatch(html, /may not be reaching/, + 'a slow chain is not a broken proxy'); + /* The wait is still stated — just as chain speed, and not in the error + * colour. Losing the information would trade one wrong answer for none. */ + assert.match(html, /mining this tip for 30 min/); + assert.doesNotMatch(html, /#c66"> mining this tip/); +}); + +test('a backend that has stopped answering is reported, on the right clock', async () => { + const { db } = makeDb(); + const html = await tipHeldFor(db, { heldSec: 1800, polledSecAgo: 1200, polls: 20 }); + assert.match(html, /no word from the backend for 20 min/); + assert.match(html, /may not be reaching it/); +}); + +test('the stall threshold follows the cadence the pool actually polls at', async () => { + /* A pool with bitcoind_poll_interval_ms = 300000 polls every 5 minutes. + * A fixed two-minute threshold would call it unreachable between every + * pair of polls — the same false alarm one layer down. The dashboard + * cannot read that config, so it measures the cadence off the row. */ + const slow = makeDb().db; + const html = await tipHeldFor(slow, { heldSec: 3000, polledSecAgo: 300, polls: 11 }); + assert.doesNotMatch(html, /may not be reaching/); + + /* Six missed polls at that cadence is a genuine stall, and still caught. */ + const stalled = makeDb().db; + const bad = await tipHeldFor(stalled, { heldSec: 3000, polledSecAgo: 2100, polls: 11 }); + assert.match(bad, /may not be reaching it/); +}); + +test('a fresh template with one poll falls back to the two-minute floor', async () => { + /* polls = 1 spans no interval, so there is no cadence to measure. */ + const { db } = makeDb(); + const ok = await tipHeldFor(db, { heldSec: 0, polledSecAgo: 30, polls: 1 }); + assert.doesNotMatch(ok, /may not be reaching/); + + const { db: db2 } = makeDb(); + const bad = await tipHeldFor(db2, { heldSec: 0, polledSecAgo: 400, polls: 1 }); + assert.match(bad, /may not be reaching it/); +}); + +test('a DB predating the fold measures staleness from its inserted rows', async () => { + /* Before a578a48 every poll inserted its own row, so the newest row's ts + * IS the last backend contact. stats.templates() coalesces last_seen to ts + * there, which keeps this correct rather than silently never warning. */ + const { db } = makeDb(); + db.exec('ALTER TABLE templates DROP COLUMN last_seen'); + db.exec('ALTER TABLE templates DROP COLUMN polls'); + addTemplate(db, { height: 997058, ts: Math.floor(Date.now() / 1000) - 1800 }); + const html = (await render('templates.ejs', { + templates: stats.templates(db), fmtBtc: stats.fmtBtc, + })).replace(/\s+/g, ' '); + assert.match(html, /may not be reaching it/); +}); diff --git a/dashboard/test/connect-ports.test.js b/dashboard/test/connect-ports.test.js new file mode 100644 index 0000000..d560d16 --- /dev/null +++ b/dashboard/test/connect-ports.test.js @@ -0,0 +1,156 @@ +/* The "which port" block on the connect card. + * + * A stratum URL says nothing about the difficulty behind it, and pointing a + * rented fleet at the home-miner port is not a subtle failure: one connection + * carrying a whole fleet at difficulty 1 is hundreds of thousands of submits + * a second, the pool limits it, and the marketplace cancels the order for + * work the pool appears to be rejecting. So the ports are listed with what + * each is for. + * + * The second property here is the one the operator-facing health check + * already reports and the miner never saw: a port holding a floor ABOVE the + * network difficulty makes its miners discard blocks they solved. That is + * the miner's loss, so it is disclosed to the miner. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs'; +import os from 'node:os'; +import ejs from 'ejs'; +import Database from 'better-sqlite3'; + +import * as fmt from '../lib/fmt.js'; +import { poolMeta } from '../lib/stats.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const VIEWS = path.resolve(__dirname, '../views'); +const SCHEMA = path.resolve(__dirname, '../../schema.sql'); +const URL_ = 'stratum+tcp://pool.example.org:3334'; + +const HOME = { port: 3334, label: null, min_diff: 1, initial_diff: 1, + promised_min_diff: 0 }; +const RENTED = { port: 3335, label: 'braiins', min_diff: 65536, + initial_diff: 65536, promised_min_diff: 65536 }; + +const ports = (pool, stratumUrl = URL_) => + ejs.renderFile(path.join(VIEWS, 'partial/connect-ports.ejs'), + { ...fmt.all, pool, stratumUrl }, { views: [VIEWS] }); + +const pool = (listeners, extra = {}) => ({ + pool_mode: 'pplns-coinbase', fee_bps: 100, network: 'signet', + network_difficulty: 1e12, listeners, ...extra, +}); + +test('each port is printed as a dialable URL with who it is for', async () => { + const html = await ports(pool([HOME, RENTED])); + assert.match(html, /stratum\+tcp:\/\/pool\.example\.org:3334\s+individual miners/); + assert.match(html, /stratum\+tcp:\/\/pool\.example\.org:3335\s+rented or aggregated hashrate \(braiins\)/); + assert.match(html, /65,536/); +}); + +test('a pool with one port explains nothing — there is no choice to make', async () => { + assert.equal((await ports(pool([HOME]))).trim(), ''); + assert.equal((await ports(pool(null))).trim(), ''); + assert.equal((await ports(null)).trim(), ''); +}); + +test('the difficulty of a port is not a claim about what it pays', async () => { + /* The fear this answers: "the big-difficulty port must pay less per + * share". It pays the same per unit of difficulty, and a miner who does + * not know that will avoid the port they belong on. */ + const html = await ports(pool([HOME, RENTED])); + assert.match(html, /does not change what you earn/); + assert.match(html, /credited by its difficulty/); +}); + +test('solo does not answer that with a credit it does not pay', async () => { + /* Same reassurance, different reason: in solo a share is credited + * nothing at all, so "work is credited by its difficulty" would be the + * card promising a payment this mode never makes. */ + const html = await ports(pool([HOME, RENTED], { pool_mode: 'solo' })); + assert.match(html, /does not change what you earn/); + assert.doesNotMatch(html, /credited by its difficulty/); + assert.match(html, /only a block\s+pays/); +}); + +test('a floor held above the network difficulty is disclosed, with its size', async () => { + /* 500000 held over a chain at 1200: ~416 of every 417 blocks solved on + * that port are filtered out by the miner before the pool sees them. */ + const nice = { port: 3336, label: 'nicehash', min_diff: 500000, + initial_diff: 500000, promised_min_diff: 500000 }; + const html = await ports(pool([HOME, nice], { network_difficulty: 1200 })); + assert.match(html, /A held floor costs blocks/); + assert.match(html, /3336/); + assert.match(html, /416 of every\s+417/); + assert.match(html, /1,200/); +}); + +test('the worst floor is the one reported, not the first', async () => { + const nice = { port: 3336, label: 'nicehash', min_diff: 500000, + initial_diff: 500000, promised_min_diff: 500000 }; + const html = await ports(pool([HOME, RENTED, nice], { network_difficulty: 1200 })); + assert.match(html, /Port 3336<\/strong>/); +}); + +test('a floor the chain is already above costs nothing and says so', async () => { + const html = await ports(pool([HOME, RENTED], { network_difficulty: 1e12 })); + assert.doesNotMatch(html, /A held floor costs blocks/); + assert.match(html, /not in that position right now/); +}); + +test('a pool where no port holds a floor gets neither paragraph', async () => { + /* initial_diff high, promised_min_diff 0: configured, not promised. The + * network difficulty clamps it, so no block is lost and there is nothing + * to disclose. */ + const configured = { port: 3335, label: 'big', min_diff: 1, + initial_diff: 65536, promised_min_diff: 0 }; + const html = await ports(pool([HOME, configured], { network_difficulty: 1200 })); + assert.doesNotMatch(html, /A held floor costs blocks/); + assert.doesNotMatch(html, /not in that position right now/); + /* Still listed, and still on the rented side of the list. */ + assert.match(html, /rented or aggregated hashrate \(big\)/); +}); + +test('no published stratum URL yields a placeholder host, not a wrong one', async () => { + const html = await ports(pool([HOME, RENTED]), ''); + assert.match(html, /stratum\+tcp:\/\/<pool-host>:3335/); + assert.doesNotMatch(html, /example\.org/); +}); + +test('a forknet difficulty is not rounded away to zero', async () => { + const tiny = { port: 3335, label: 'tiny', min_diff: 3e-10, + initial_diff: 3e-10, promised_min_diff: 3e-10 }; + const html = await ports(pool([HOME, tiny], { network_difficulty: 1e-12 })); + assert.match(html, /3e-10/); + assert.doesNotMatch(html, /difficulty 0,/); +}); + +test('a proxy that never published promised_min_diff promises nothing', async () => { + /* An older proxy writes port/label/min_diff/initial_diff only. Reading a + * missing promise as a floor would print a block-loss warning at every + * miner of a pool that is not losing any. Goes through the real + * pool_meta read rather than a hand-built object, because the default is + * parseListeners' to get wrong. */ + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'sp-ports-')), + 'shares.db'); + const db = new Database(file); + db.exec(fs.readFileSync(SCHEMA, 'utf8')); + db.prepare(`INSERT INTO pool_meta + (id, pool_mode, fee_bps, rate_source, rate_sats_per_diff, + gross_sats_per_diff, effective_fee_bps, network_difficulty, + block_value_sats, credited_from, listeners, updated_at) + VALUES (1, 'pplns-coinbase', 100, 'derived', 0, 0, 100, 1200, + 312500000, 1, ?, 1)`) + .run(JSON.stringify([ + { port: 3334, label: '', min_diff: 1, initial_diff: 1 }, + { port: 3335, label: 'old', min_diff: 65536, initial_diff: 65536 }, + ])); + const meta = poolMeta(db); + assert.equal(meta.listeners[1].promised_min_diff, 0); + + const html = await ports(meta); + assert.match(html, /rented or aggregated hashrate \(old\)/); + assert.doesNotMatch(html, /A held floor costs blocks/); +}); diff --git a/dashboard/test/pplns-coinbase-disclosure.test.js b/dashboard/test/pplns-coinbase-disclosure.test.js new file mode 100644 index 0000000..f160068 --- /dev/null +++ b/dashboard/test/pplns-coinbase-disclosure.test.js @@ -0,0 +1,255 @@ +/* What a pplns-coinbase pool tells the people it costs. + * + * This mode pays a claim below the payout floor nothing from that block: its + * share goes to the other miners in the window (never the operator) and the + * miner is owed a turn in the payout queue, so being small costs frequency + * rather than money. That is defensible as a stated rule and indefensible as + * a discovery, and the whole case for the policy rests on the miner being + * able to see it BEFORE pointing a rig at the pool. The operator's log is the + * one place they cannot look, so these tests treat the disclosure as part of + * the feature rather than as decoration. The header of this file said the + * old rule -- forfeited to the operator -- for a while after the tests below + * started asserting the page must not say that. + * + * They also pin the mislabels this mode exposed. The dashboard used to answer + * "not pps-classic" with the word "solo" in three places, so every pplns pool + * was told it was solo by the same pages whose header said otherwise. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Database from 'better-sqlite3'; +import ejs from 'ejs'; + +import { poolMeta, fmtHashrate, templates as statsTemplates } from '../lib/stats.js'; +import { health as runHealth } from '../lib/health.js'; +import * as fmt from '../lib/fmt.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SCHEMA = path.resolve(__dirname, '../../schema.sql'); +const VIEWS = path.resolve(__dirname, '../views'); + +const OPERATOR = 'bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080'; + +function makeDb({ mode = 'pplns-coinbase', floor = 546 } = {}) { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'sp-cbwin-')), 'shares.db'); + const db = new Database(file); + db.exec(fs.readFileSync(SCHEMA, 'utf8')); + db.prepare(`INSERT INTO pool_meta + (id, network, network_source, coinbase_tag, operator_address, + pool_btc_address, pool_mode, fee_bps, rate_source, + rate_sats_per_diff, gross_sats_per_diff, effective_fee_bps, + network_difficulty, block_value_sats, credited_from, + listeners, updated_at, pplns_payout_floor_sats) + VALUES (1, 'regtest', 'node', '/sp/', @op, NULL, @mode, 100, + 'derived', 0, 0, 100, 1, 5000000000, 1, NULL, 1, @floor)`) + .run({ op: OPERATOR, mode, floor }); + return db; +} + +const render = (view, locals) => + ejs.renderFile(path.join(VIEWS, view), { ...fmt.all, ...locals }, + { views: [VIEWS] }); + +const about = db => render('partial/about-numbers.ejs', + { pool: poolMeta(db), stratumUrl: 'stratum+tcp://x:3334', + sidechainId: 9 }); + +test('the payout floor is stated to the miner, in sats', async () => { + const html = await about(makeDb({ floor: 25000 })); + assert.match(html, /25,000 sats/, 'the floor itself'); + /* Integers, not "25,000.00 sats" -- satoshis do not have decimals, and + * the shared BTC formatter rendered the first version that way. */ + assert.doesNotMatch(html, /25,000\.00 sats/); +}); + +test('a skipped claim is described as shared out, never as the operator\'s', async () => { + const html = await about(makeDb()); + /* The exact claim a miner has to come away with, and it is the opposite of + * what this test asserted before #76: what a block cannot pay goes to the + * OTHER MINERS, and the operator still takes only its fee. */ + assert.match(html, /shared out\s*among the miners/is); + assert.match(html, /takes only its fee/i); + /* And that the cost is frequency, not amount — the sentence a small miner + * needs in order to decide whether to point a rig here. */ + assert.match(html, /less often/i); + assert.match(html, /first in the queue/i); + /* The page must NOT tell miners their share goes to the operator, which + * is what it used to say and is now simply false. */ + assert.doesNotMatch(html, /amount goes to the operator/i); + assert.doesNotMatch(html, /forfeit/i); +}); + +test('the queue is described as an order, not a balance', async () => { + /* The property that makes it defensible: no money is held. A miner who + * reads this must not come away believing the pool owes them a payout + * they could one day claim. */ + const html = await about(makeDb()); + assert.match(html, /no balance to withdraw/i); + assert.match(html, /nobody would be short a payment/i); +}); + +test('a proxy that never published a floor claims none', async () => { + /* An older proxy stores NULL here. Rendering the default 546 anyway would + * be stating a policy on that operator's behalf, which is worse than + * staying quiet: the operator may be running a build that has no floor. */ + const db = makeDb(); + db.prepare('UPDATE pool_meta SET pplns_payout_floor_sats = NULL').run(); + const html = await about(db); + assert.doesNotMatch(html, /may not pay everyone/i); + assert.doesNotMatch(html, /546/); + /* But the mode itself is still described -- silence about the floor must + * not become silence about the mode. */ + assert.match(html, /pplns-coinbase/); +}); + +test('a zero floor is still a floor, and still disclosed', async () => { + /* 0 means "pay anything the dust limit allows" -- a real policy, and + * distinct from NULL. A `|| null` normalisation would collapse the two + * and silently stop disclosing. */ + const html = await about(makeDb({ floor: 0 })); + assert.match(html, /may not pay everyone/i); +}); + +test('every mode gets its own guidance, and none is called solo', async () => { + for (const mode of ['pplns-coinbase', 'pplns-btc', 'pplns-thunder']) { + const html = await about(makeDb({ mode })); + assert.match(html, new RegExp(mode), + `${mode} should name itself`); + /* The bug: all three fell through to the unknown-mode branch. */ + assert.doesNotMatch(html, /has not published its mode yet/, + `${mode} should not read as unknown`); + } +}); + +test('the pplns rails ask for the right username type', async () => { + const thunder = await about(makeDb({ mode: 'pplns-thunder' })); + assert.match(thunder, /your-thunder-address/); + const btc = await about(makeDb({ mode: 'pplns-btc' })); + assert.match(btc, /your-bitcoin-address/); + const cb = await about(makeDb({ mode: 'pplns-coinbase' })); + assert.match(cb, /your-bitcoin-address/); +}); + +test('solvency is not claimed for a pool that holds nothing', async () => { + /* In pplns-coinbase blocks_found.reward_sats is what the block paid the + * MINERS. Summing it as pool revenue reported a healthy 50 BTC margin for + * a pool with no wallet -- a green light asserting custody that does not + * exist. */ + const db = makeDb(); + db.prepare(`INSERT INTO blocks_found (ts, height, hash, reward_sats, + fee_sats, status) + VALUES (1, 11, 'aa', 4950000000, 50000000, 'confirmed')`).run(); + const margin = runHealth(db).checks.find(c => c.id === 'margin'); + assert.equal(margin.value, null, 'no margin figure for a custody-free pool'); + assert.match(margin.detail, /never holds the reward/); + + /* And the check still works where custody is real. */ + const pps = makeDb({ mode: 'pps-classic' }); + pps.prepare(`INSERT INTO blocks_found (ts, height, hash, reward_sats, + fee_sats, status) + VALUES (1, 11, 'aa', 4950000000, 50000000, 'confirmed')`).run(); + assert.equal(runHealth(pps).checks.find(c => c.id === 'margin').value, + 5000000000); +}); + +test('the accrual check names the mode it is actually in', async () => { + for (const mode of ['solo', 'pplns-coinbase', 'pplns-btc', 'pplns-thunder']) { + const c = runHealth(makeDb({ mode })).checks + .find(x => x.id === 'pps_difficulty'); + assert.match(c.detail, new RegExp(mode), + `${mode} should be named, not called solo`); + } +}); + +test('a mode with no balance does not report one as owed', async () => { + const html = await render('worker.ejs', { + pool: poolMeta(makeDb()), + health: { ok: true, checks: [] }, + worker: { name: 'w', payout_address: 'bcrt1q', first_seen: 1, + last_seen: 1, window_shares: 0, window_hashrate: 0 }, + name: 'w', shares: [], buckets: [], window_sec: 86400, + pps_audit: null, pplns_audit: null, payouts: [], blocks: [], + fmtHashrate, + stratumUrl: 'stratum+tcp://x:3334', sidechainId: 9, + }); + assert.doesNotMatch(html, /solo mode/, + 'a pplns-coinbase worker page must not claim solo'); + assert.match(html, /paid in the coinbase/); +}); + +/* The fourth place. templates.ejs 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 operator who is looking at their own pool and wondering what is + * n/a and why. Same bug as the three above, one page later. */ +function withTemplate(db, { rate = 0, height = 997058 } = {}) { + db.prepare(`INSERT INTO templates + (ts, height, prev_hash, bits, network_difficulty, + coinbase_value_sats, tx_count, tx_fees_sats, source, + cb_spendable, cb_op_returns, longpoll, rate_sats_per_diff, + last_seen, polls) + VALUES (@ts, @height, @prev, '1900ffff', 4294967296, + 313374735, 1158, 874735, 'enforcer', 1, 8, 1, @rate, + @ts, 62)`) + .run({ ts: Math.floor(Date.now() / 1000), height, rate, + prev: '00'.repeat(32) }); + return db; +} + +const templatesPage = db => render('templates.ejs', { + templates: statsTemplates(db), pool: poolMeta(db), + health: { ok: true, checks: [] }, + stratumUrl: 'stratum+tcp://x:3334', sidechainId: 9, +}); + +test('the templates page names the mode it is in, not pps-classic', async () => { + for (const mode of ['pplns-coinbase', 'pplns-btc', 'pplns-thunder', 'solo']) { + const html = await templatesPage(withTemplate(makeDb({ mode }))); + assert.doesNotMatch(html, /only pps-classic prices a share on arrival/, + `${mode} was told about a mode it is not in`); + assert.match(html, new RegExp(mode), `${mode} should name itself`); + /* And the label must stop claiming a PPS rate this pool has none of. */ + assert.doesNotMatch(html, /