diff --git a/.github/workflows/integration_tests.yaml b/.github/workflows/integration_tests.yaml index a79d539..2e6a9d2 100644 --- a/.github/workflows/integration_tests.yaml +++ b/.github/workflows/integration_tests.yaml @@ -21,7 +21,7 @@ on: jobs: integration-test: runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 35 # Match check_build.yaml: CI runs only in the LayerTwo-Labs org repo. if: github.repository_owner == 'LayerTwo-Labs' steps: @@ -48,6 +48,14 @@ jobs: - name: Run end-to-end regtest test run: bash tests/test_e2e_regtest.sh + # PPLNS distribution, both rails. Separate from the pps-classic e2e + # above because it is testing a different thing: not the coinbase shape + # but what happens 100 blocks later, when a matured block is split + # across the window that produced it. It mines its own chain to + # maturity, so it is slower and worth failing independently. + - name: Run PPLNS end-to-end regtest test + run: bash tests/test_pplns_regtest.sh + - name: Upload logs if: failure() uses: actions/upload-artifact@v4 @@ -55,9 +63,12 @@ jobs: name: e2e-logs-${{ github.run_id }} path: | .regtest-e2e/logs/ + .regtest-pplns/logs/ /tmp/simplepool-e2e.log /tmp/simplepool-e2e.conf /tmp/simplepool-int.log + /tmp/simplepool-pplns-*.log + /tmp/simplepool-pplns-*.conf retention-days: 14 if-no-files-found: ignore @@ -121,11 +132,20 @@ jobs: - name: Run payout regtest test run: bash tests/test_payout_regtest.sh + # The other rail. pplns-btc pays on L1 through the enforcer's own + # wallet and never touches a sidechain, so it shares none of the + # Thunder path above beyond the ledger bookkeeping -- and until this + # existed it had no coverage outside unit tests against a stub. + - name: Run pplns-btc L1 payout regtest test + run: bash tests/test_pplns_btc_payout_regtest.sh + - name: Upload logs if: failure() uses: actions/upload-artifact@v4 with: name: payout-logs-${{ github.run_id }} - path: .regtest-payout/logs/ + path: | + .regtest-payout/logs/ + .regtest-btcpay/logs/ retention-days: 14 if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index b06c4b9..957b9fe 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ /.regtest/ /.regtest-e2e/ /.regtest-payout/ +/.regtest-pplns/ +/.regtest-btcpay/ /proxy.conf /tests/integration.proxy.conf # The installer writes proxy.conf.bak. beside proxy.conf on every diff --git a/INSTALL.md b/INSTALL.md index e3a76a6..b550f80 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -11,17 +11,29 @@ tracks: Thunder node. There's a one-shot deploy script; this doc also walks through what it does step by step so you can do it by hand. -The doc is mode-agnostic where possible; where mode matters, the two +The doc is mode-agnostic where possible; where mode matters, the four possibilities are called out clearly: - **`pool_mode = solo`** — miners paid direct in the coinbase. - Simplest. No drivechain, no Thunder, no PPS accrual. + Simplest. No drivechain, no Thunder, no accrual. - **`pool_mode = pps-classic`** — traditional coinbase paying a pool BTC address, operator-driven Thunder deposits from the admin dashboard. This is the mode you want for a Thunder-paying PPS pool. + Needs an operator reserve big enough to absorb variance. See [CLASSIC_PAYOUTS.md](CLASSIC_PAYOUTS.md). - -(A third mode, `pool_mode = pps`, put the drivechain deposit directly in +- **`pool_mode = pplns-thunder`** — pooled like PPS, but a block is + divided among the shares that produced it once it matures, so there + is no reserve to fund. Paid over Thunder; usernames are Thunder + addresses, and the payout worker is the same one. +- **`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. + +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 the coinbase. The enforcer never credited it, so it has been removed — `CLASSIC_PAYOUTS.md` has the evidence.) @@ -393,7 +405,10 @@ simplepool talks to. Port `:50051` is the gRPC surface for sidechain management (used by the deposit runbook in [OPERATOR_GUIDE.md](OPERATOR_GUIDE.md)). -### Thunder (needed for `pool_mode=pps-classic` payouts) +### Thunder (needed for `pool_mode=pps-classic` and `pplns-thunder` payouts) + +Not needed for `pplns-btc`, which pays on the mainchain and has no +sidechain in it at all. Prebuilt: (no x86_64 Linux prebuilt as of this doc — build from source at @@ -476,12 +491,47 @@ Miner username: `[.]`. Password ignored. pool_mode = pps-classic pool_btc_address = bc1q... # pool wallet; ideally an enforcer-owned # address (see OPERATOR_GUIDE.md open items) -pps_sats_per_diff = 1000 ``` Miner username: `[.]`. Startup logs `pool_mode=pps-classic: pool_btc_address=…`. +Do **not** set `pps_sats_per_diff`. The proxy derives the rate from each +block template — the block's own value over the network difficulty, net +of `fee_bps` — so it tracks the chain. A pinned value silently bypasses +`fee_bps` and cannot follow a retarget; it exists as an escape hatch, not +as a setting to fill in. + +### PPLNS modes + +``` +# ... same as solo, plus: +pool_mode = pplns-thunder # or: pplns-btc +pool_btc_address = bc1q... # the coinbase pays the pool, as in pps-classic +pplns_window_diff_multiple = 2.0 # optional; this is the default +``` + +`pool_mode = pplns` on its own is refused — it does not say which rail +pays, and the rail decides what a stratum username is: + +| mode | miner username | +| --- | --- | +| `pplns-thunder` | `[.]` | +| `pplns-btc` | `[.]` | + +Nothing is credited when a share arrives. A block that reaches **100 +confirmations** is split across the shares that produced it, pro rata by +difficulty, over a window of `pplns_window_diff_multiple` × the current +network difficulty. The credits land in the same `pps_credits` table the +PPS payout worker already drains. + +`pplns-btc` additionally needs `bip300301_enforcer` running with +`--enable-wallet`, `pool_btc_address` set to an address **from that +wallet**, and the payout worker started with `PAYOUT_RAIL=btc` (Part F). +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. + ### Optional: Redis broadcast Add to any mode's `proxy.conf`: @@ -559,10 +609,19 @@ on every request. --- -## Part F — payout worker (PPS modes only) +## Part F — payout worker (every mode except solo) + +The payout worker drains `pps_credits.accrued_sats - paid_sats`. One +worker, two rails, selected by `PAYOUT_RAIL`: + +| `pool_mode` | `PAYOUT_RAIL` | how it pays | +| --- | --- | --- | +| `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 | -The payout worker drains `pps_credits.accrued_sats - paid_sats` by -issuing Thunder transactions. Deploy as a systemd service: +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 @@ -571,6 +630,16 @@ sudo mkdir -p /etc/systemd/system/simplepool-payout.service.d sudo tee /etc/systemd/system/simplepool-payout.service.d/local.conf <<'CONF' [Service] Environment=THUNDER_FROM_ADDRESS= +# +# For pool_mode=pplns-btc, drop the Thunder line above and use these two +# instead — the Thunder variables are then never read: +# Environment=PAYOUT_RAIL=btc +# Environment=ENFORCER_RPC_ADDR=127.0.0.1:50051 +# Environment=PAYOUT_FEE_RATE_SAT_VB=5 +# The enforcer computes the fee from the transaction it actually builds, +# so this is a rate, not an amount, and there is no local estimator to +# drift out of date. +# # Below have defaults; override if you want: # Environment=PAYOUT_MIN_SATS=10000 # Payout runs are a daily batch (24h). The settle clock is separate on @@ -586,7 +655,8 @@ sudo systemctl enable --now simplepool-payout.service sudo journalctl -u simplepool-payout.service -f ``` -The worker is idle when the Thunder reserve has no funds — it logs +The worker is idle when the paying wallet has no funds — the Thunder +reserve, or the enforcer wallet on `PAYOUT_RAIL=btc`. It logs `payout: reserve short — available=0 needed=N` and skips harmlessly, retrying on the 5-minute retry clock rather than the daily one. See the deposit runbook in [OPERATOR_GUIDE.md](OPERATOR_GUIDE.md) for how to actually fund it. @@ -616,7 +686,7 @@ Start everything: ```sh sudo systemctl enable --now simplepool.service sudo systemctl enable --now simplepool-dashboard.service -sudo systemctl enable --now simplepool-payout.service # PPS modes only +sudo systemctl enable --now simplepool-payout.service # every mode but solo sudo systemctl status simplepool simplepool-dashboard simplepool-payout ``` @@ -686,6 +756,13 @@ Install-time trouble usually falls into one of these: was ready. If you're using systemd, add `After=bip300301-enforcer.service` and `Requires=` to your Thunder unit; if running by hand, sleep 2s. +- **`config error: 'pool_mode = pplns' does not say which rail pays`** — + use `pplns-thunder` or `pplns-btc`. A pool runs one or the other, and + the rail decides what a stratum username is. +- **`config error: 'pplns_window_diff_multiple' must be > 0`** — it is a + multiple of the network difficulty; 2.0 is the default. Below 1.0 the + proxy warns rather than refuses: a block would then pay out across less + work than it took to find, which rewards pool hopping. - **`config error: 'pool_btc_address' is required when pool_mode=pps-classic`** — self-explanatory; set it. - **The pool logs `stratum listening on 0.0.0.0:3335` but miners are diff --git a/Makefile b/Makefile index 259bfdc..4fc3d46 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,14 @@ ifeq ($(UNAME_S),Darwin) BREW_PREFIX := /usr/local endif endif - PLATFORM_CFLAGS := -I$(BREW_PREFIX)/include \ + # _POSIX_C_SOURCE (below) is what makes clock_gettime and friends visible + # on glibc, but on Darwin the same macro works in reverse: asking for a + # strict POSIX namespace *hides* everything BSD, and INADDR_LOOPBACK and + # MSG_DONTWAIT are BSD, not POSIX. Without this the test suites do not + # compile on macOS at all. _DARWIN_C_SOURCE puts them back; it is a no-op + # anywhere else because this block is Darwin-only. + PLATFORM_CFLAGS := -D_DARWIN_C_SOURCE \ + -I$(BREW_PREFIX)/include \ -I$(BREW_PREFIX)/opt/sqlite/include \ -I$(BREW_PREFIX)/opt/curl/include \ -I$(BREW_PREFIX)/opt/hiredis/include @@ -63,11 +70,11 @@ VERSION_H := $(BUILD_DIR)/version_gen.h SRCS := src/main.c src/log.c src/config.c src/coinbase.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/cjson/cJSON.c + src/reconcile.c src/cjson/cJSON.c OBJS := $(SRCS:%.c=$(BUILD_DIR)/%.o) DEPS := $(OBJS:.o=.d) -.PHONY: all clean test asan format install help FORCE +.PHONY: all clean test asan coverage format install help FORCE all: $(BIN) @@ -116,8 +123,9 @@ include tests/test_coinbase.mk include tests/test_broadcast.mk include tests/test_thunder.mk include tests/test_config.mk +include tests/test_reconcile.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 +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 ./build/test_share ./build/test_bitcoind ./build/test_stratum @@ -126,6 +134,7 @@ test: build/test_share build/test_bitcoind build/test_stratum build/test_store b ./build/test_broadcast ./build/test_thunder ./build/test_config + ./build/test_reconcile # Run the suites under AddressSanitizer + UndefinedBehaviorSanitizer. # @@ -157,6 +166,59 @@ asan: ./$(ASAN_DIR)/test_coinbase ./$(ASAN_DIR)/test_share +# Line and function coverage of the C suites, via LLVM source-based coverage. +# +# What it measures is the UNIT suites only. The three regtest e2e scripts drive +# the real binary and cover a great deal that never shows up here -- the tip +# watcher, the reconcile pass, the distributor, every RPC path -- so a low +# number for a file like main.c means "not covered by `make test`", not +# "untested". Reading it the other way round is how a coverage number starts +# doing harm. +# +# Vendored cJSON is excluded: it is upstream code, and including it would move +# the headline number without saying anything about this project's tests. +COV_DIR := build/cov +COV_CFLAGS := -std=c11 -g -O0 -fprofile-instr-generate -fcoverage-mapping \ + -D_POSIX_C_SOURCE=200809L -Iinclude -Isrc -Isrc/cjson $(PLATFORM_CFLAGS) +# Vendored cJSON, the test files themselves, and every system / Homebrew header +# the suites pull in. Without the last of these the totals are dominated by +# hiredis and curl inlines this project never calls. +COV_IGNORE := --ignore-filename-regex='(tests/|src/cjson/|^/usr/|/opt/|/Applications/|/Library/)' + +coverage: + @command -v xcrun >/dev/null 2>&1 || { echo "coverage needs llvm-profdata/llvm-cov"; exit 1; } + @mkdir -p $(COV_DIR) + @rm -f $(COV_DIR)/*.profraw $(COV_DIR)/*.profdata + $(CC) $(COV_CFLAGS) -o $(COV_DIR)/test_stratum tests/test_stratum.c \ + src/stratum.c src/coinbase.c src/share.c src/sha256.c src/thunder.c \ + src/log.c src/cjson/cJSON.c -lpthread + $(CC) $(COV_CFLAGS) -o $(COV_DIR)/test_store tests/test_store.c \ + src/store.c src/log.c $(PLATFORM_LDFLAGS) -lsqlite3 -lpthread + $(CC) $(COV_CFLAGS) -o $(COV_DIR)/test_coinbase tests/test_coinbase.c \ + src/coinbase.c src/sha256.c + $(CC) $(COV_CFLAGS) -o $(COV_DIR)/test_share tests/test_share.c \ + src/share.c src/sha256.c + $(CC) $(COV_CFLAGS) -o $(COV_DIR)/test_bitcoind tests/test_bitcoind.c \ + src/bitcoind.c src/log.c src/cjson/cJSON.c $(PLATFORM_LDFLAGS) -lcurl -lpthread + $(CC) $(COV_CFLAGS) -o $(COV_DIR)/test_broadcast tests/test_broadcast.c \ + src/broadcast.c src/log.c $(PLATFORM_LDFLAGS) -lhiredis -lpthread + $(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) -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 \ + LLVM_PROFILE_FILE=$(COV_DIR)/$$t.profraw ./$(COV_DIR)/test_$$t >/dev/null 2>&1 \ + || { echo "coverage: test_$$t FAILED"; exit 1; }; \ + done + @xcrun llvm-profdata merge -sparse $(COV_DIR)/*.profraw -o $(COV_DIR)/all.profdata + @echo + @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) \ + -instr-profile=$(COV_DIR)/all.profdata $(COV_IGNORE) + format: @if command -v clang-format >/dev/null 2>&1; then \ find src include tests -type f \( -name '*.c' -o -name '*.h' \) \ @@ -172,5 +234,5 @@ install: $(BIN) install -m 0755 $(BIN) $(DESTDIR)$(BINDIR)/simplepool help: - @echo "Targets: all clean test format install" + @echo "Targets: all clean test asan coverage format install" @echo " PREFIX=$(PREFIX) CC=$(CC) UNAME_S=$(UNAME_S)" diff --git a/README.md b/README.md index 65f30f0..22fe786 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,14 @@ 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 two modes: **solo**, where the miner who finds a block is paid in -that block's own coinbase, and **pps-classic**, where every accepted share -earns a derivable amount paid out over Thunder. Both ship in this repo — see -[The two modes](#the-two-modes) below. +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. Created by **Roberto Santacroce**. Canonical repository: . @@ -37,15 +41,23 @@ curl -fsSL https://raw.githubusercontent.com/LayerTwo-Labs/simplepool/main/scrip > verify what they're owed. simplepool aims to address this transparency > gap. (Hopefully!) -> A single-file, no-JavaScript explainer covering both modes end to end — +> A single-file, no-JavaScript explainer covering every mode end to end — > shares, difficulty, the coinbase, PPS credit, Thunder payouts and how to > audit every number — lives at [`docs/simplepool.html`](docs/simplepool.html). > Open it from disk or serve it next to the dashboard. -### The two modes +### The four modes -This repository ships **both modes**, selected by `pool_mode` in -`proxy.conf`: +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: + +| `pool_mode` | coinbase pays | username | who carries the variance | +| --- | --- | --- | --- | +| `solo` | the miner who found it | Bitcoin address | nobody: you are paid what you find | +| `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 | - **`pool_mode = solo`** (default) — every share lands in the local SQLite store, every accepted block is paid directly in its own @@ -81,9 +93,65 @@ This repository ships **both modes**, selected by `pool_mode` in > see [`CLASSIC_PAYOUTS.md`](CLASSIC_PAYOUTS.md) for the evidence and > the design that replaced it. -In both modes the operator fee stays in BTC, paid to `operator_address` -out of the same coinbase. See [`proxy.conf.example`](proxy.conf.example) -for the full set of PPS / Thunder keys. +- **`pool_mode = pplns-thunder`** and **`pool_mode = pplns-btc`** — the + coinbase pays the pool, exactly as in `pps-classic`, but **nothing is + credited when a share arrives**. Instead, once a block has matured **100 + confirmations** it is split across the shares that produced it — walking + back from the block's own share until their difficulty fills a window — + and each miner is credited its proportion of `(reward + fees)`, net of + `fee_bps`. + + That is the whole difference, and it is a difference about risk. PPS + prices a share the moment it arrives, whether or not it ever becomes a + block, so the operator needs a reserve measured in block rewards to + absorb the gap. Under PPLNS the pool never owes more than it has just + been paid: there is no reserve to size and operator ruin is not a failure + mode. The miners carry the variance instead, which is what makes it the + mode a small pool can actually run. + + Two consequences worth stating, because each has a plausible-looking + wrong answer: + + - **Maturity, not confirmation.** A coinbase output is unspendable until + it is 100 deep, so crediting at confirmation would create a balance the + pool genuinely cannot fund — the reserve requirement PPLNS exists to + remove, reintroduced by accident. Waiting also disposes of the orphan + question rather than answering it: crediting is additive and there is + no negative share, so a credit from a block that turns out not to be + ours could not be taken back. At 100 deep that stops being a risk. + - **Transaction fees are included**, unlike pure PPS: PPLNS shares what + the block actually earned. + + The window is `pplns_window_diff_multiple` × the network difficulty + (default 2.0, "the last two blocks' worth of expected work"), a multiple + rather than an absolute share count so it self-scales across retargets. + It is snapshotted onto the block row when the block is found, not + 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: + + - **`pplns-thunder`** pays over Thunder, like `pps-classic`, and reuses + the same payout worker draining the same `pps_credits` table. Username + is a bare base58 Thunder address. + - **`pplns-btc`** pays on Bitcoin L1, by asking the enforcer's own wallet + to send. Username is a Bitcoin address. This requires + `bip300301_enforcer` running with `--enable-wallet`, with + `pool_btc_address` an address from that wallet, and the payout worker + started with `PAYOUT_RAIL=btc` — the proxy says so at startup, because + otherwise the first sign of a misconfiguration is a payout failing 100 + blocks after the block was found. + + One rail per pool, encoded in `pool_mode` rather than a mode plus a + separate rail knob, so the inconsistent configuration is unrepresentable + rather than merely rejected. + +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 +for. See [`proxy.conf.example`](proxy.conf.example) for the full set of +PPS / PPLNS / Thunder keys. Optional: set `redis_url` to mirror accepted shares, rejects, blocks, tip changes and PPS credits to Redis pub/sub channels (`pool:shares`, @@ -109,6 +177,11 @@ and historical "blocks found by the pool" view. accepted share credits a balance at a rate derived from the live block template, and the pool — not the miner — carries the variance. +**In the `pplns-*` modes** the coinbase also pays the pool, but no balance +moves until a block matures; it is then divided among the shares that +produced it. Nobody is paid for work that did not become a block, which is +precisely why the pool needs no reserve. + ### A note on terminology: "share" vs "work" The codebase, schema, dashboard, and API all call accepted submissions @@ -550,7 +623,7 @@ src/ store.{c,h} # SQLite writer with batching bitcoind.{c,h} # libcurl-based JSON-RPC client broadcast.{c,h} # optional Redis pub/sub mirror of pool events - thunder.{c,h} # Thunder base58 address decoder (pps-classic) + thunder.{c,h} # Thunder base58 address decoder (pps-classic, pplns-thunder) version.{c,h} # build provenance compiled into the binary cjson/ # vendored cJSON (MIT) — see src/cjson/README.md tests/ # unit tests + integration shell scripts @@ -561,8 +634,9 @@ scripts/ release.sh # build a release tarball (CI runs this exact script) deploy-to-server.sh, sync-from-server.sh, record-build.sh, ... dashboard/ # Node/Express read-only stats UI -payout/ # Thunder payout worker (pps-classic) -docs/simplepool.html # single-file explainer: both modes, end to end +payout/ # payout worker: Thunder rail (pps-classic, pplns-thunder) + # and L1 rail via the enforcer wallet (pplns-btc) +docs/simplepool.html # single-file explainer: every mode, end to end ``` ## Roadmap @@ -576,14 +650,16 @@ Shipped since this list was first written: - **Redis broadcast.** Accepted shares, rejects, blocks, tip changes and PPS credits are mirrored onto Redis pub/sub when `redis_url` is set. SQLite remains the source of truth; the publish is fire-and-forget. -- **PPS billing as a separate, non-blocking service.** `pool_mode = - pps-classic` accrues credits in the proxy; the separate - [`payout/`](payout/) worker settles them over **Thunder** on its own - process and its own schedule. A payout outage cannot stop the proxy - accepting work. -- **Miner registration turned out to be unnecessary.** PPS miners are - identified by the Thunder address in the stratum username, exactly as solo - miners are identified by their BTC address. There is nothing to register. +- **Billing as a separate, non-blocking service.** Both the PPS and the + PPLNS modes accrue credits in the proxy, into the same `pps_credits` + table; the separate [`payout/`](payout/) worker settles them on its own + process and its own schedule — over **Thunder** for `pps-classic` and + `pplns-thunder`, and on **L1** through the enforcer's wallet for + `pplns-btc`. A payout outage cannot stop the proxy accepting work. +- **Miner registration turned out to be unnecessary.** Miners are identified + by the address in the stratum username — Thunder or Bitcoin depending on + the mode's rail — exactly as solo miners are identified by their BTC + address. There is nothing to register. Still open: diff --git a/dashboard/lib/stats.js b/dashboard/lib/stats.js index beb42a6..25f239b 100644 --- a/dashboard/lib/stats.js +++ b/dashboard/lib/stats.js @@ -320,8 +320,17 @@ export function worker(handle, name, windowSec = 86400) { SELECT accrued_sats, paid_sats, last_updated FROM pps_credits WHERE worker_id = ? `).get(w.id); + let pplnsAudit = null; if (credit) { const meta = poolMeta(d); + /* PPLNS prices a share in hindsight, out of a block actually found, so + * the per-share re-derivation below is structurally empty for it -- + * every share carries credited_sats = 0. Give it the audit that + * matches how it was actually paid instead of one that reports the + * pool as owing nothing. */ + const isPplns = meta && (meta.pool_mode === 'pplns-thunder' || + meta.pool_mode === 'pplns-btc'); + if (isPplns) pplnsAudit = pplnsAuditFor(d, w.id); const totals = d.prepare(` SELECT COUNT(*) AS share_count, COALESCE(SUM(difficulty), 0) AS sum_difficulty, @@ -342,7 +351,14 @@ export function worker(handle, name, windowSec = 86400) { share_count: Number(totals.share_count), sum_difficulty: Number(totals.sum_difficulty), accrued_computed: Number(totals.accrued_computed), - matches: Number(totals.accrued_computed) === accrued, + /* On PPLNS the per-share sum is meaningless rather than wrong, so + * it must not be presented as a mismatch: it said the pool was off + * by the miner's entire balance and told them to challenge the + * operator. pplns_audit carries the comparison that does hold. */ + matches: pplnsAudit + ? pplnsAudit.credited_total === accrued + : Number(totals.accrued_computed) === accrued, + mode: meta ? meta.pool_mode : null, }; } @@ -392,6 +408,7 @@ export function worker(handle, name, windowSec = 86400) { buckets, window_sec: windowSec, pps_audit: ppsAudit, + pplns_audit: pplnsAudit, payouts, blocks: workerBlocks, }; @@ -500,13 +517,108 @@ export function poolMeta(handle) { /* An override whose implied fee has drifted from fee_bps is the * failure this table exists to expose. */ fee_drift_bps: Number(r.effective_fee_bps || 0) - Number(r.fee_bps || 0), - accrues: (r.pool_mode || 'solo') === 'pps-classic', + /* 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. + * + * 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 + * actually found, and only the former leaves rate_used on the row. + * rate_source and rate_sats_per_diff above are the PPS-only facts; + * this one is about whether the pool owes anyone anything. */ + accrues: ['pps-classic', 'pplns-thunder', 'pplns-btc'] + .includes(r.pool_mode || 'solo'), }; } catch { return null; /* pre-pool_meta DB */ } } +/* The PPLNS answer to "why is this number what it is?". + * + * PPS credits a share when it arrives, so its audit re-derives from + * shares.credited_sats. PPLNS credits nothing on arrival -- every share has + * credited_sats = 0 and rate_used = 0 -- so that re-derivation returns zero + * against a real balance and the page reported the pool as off by the + * miner's whole balance, telling them to go and challenge the operator. On + * the one page whose entire purpose is being checkable. + * + * The honest re-derivation is the one the distributor actually performed: + * for each block that has matured and been distributed, walk back from that + * block's own share until the window fills, and take this worker's share of + * the difficulty in it. Reproduced here from the raw shares and blocks_found + * rows, with no reference to anything the payout path wrote, so a miner can + * check the pool's arithmetic rather than take delivery of it. + * + * The window SQL mirrors store_pplns_distribute() exactly, including the + * comparison against the running total EXCLUDING the current row, which is + * what includes the share that crosses the boundary whole rather than + * splitting it. + * + * ⚠ ONE ASSUMPTION, and it is stated in the UI rather than hidden: fee_bps is + * read from pool_meta as it stands NOW. The distributor used whatever it was + * when the block matured, and nothing records the value per block. An + * operator who has changed the fee will see older blocks fail to reproduce. + * That is a real limit of the stored data, not a discrepancy in the ledger, + * and saying so beats either hiding it or crying wolf. */ +export function pplnsAuditFor(d, workerId, limit = 50) { + const meta = d.prepare('SELECT fee_bps FROM pool_meta WHERE id = 1').get(); + const feeBps = Number(meta?.fee_bps || 0); + + const blocks = d.prepare(` + SELECT id, height, hash, ts, + COALESCE(reward_sats, 0) + COALESCE(fee_sats, 0) AS gross, + pplns_window_diff AS window_diff + FROM blocks_found + WHERE pplns_distributed = 1 AND pplns_window_diff > 0 + ORDER BY height DESC + LIMIT ? + `).all(limit); + + /* One block's window, split between this worker and everyone else. */ + const split = d.prepare(` + WITH anchored AS ( + SELECT id, worker_id, difficulty, + SUM(difficulty) OVER (ORDER BY id DESC ROWS UNBOUNDED PRECEDING) AS running + FROM shares + WHERE id <= (SELECT MAX(id) FROM shares WHERE block_hash = ?) + ) + SELECT COALESCE(SUM(CASE WHEN worker_id = ? THEN difficulty END), 0) AS mine, + COALESCE(SUM(difficulty), 0) AS total + FROM anchored + WHERE running - difficulty < ? + `); + + const rows = []; + let credited_total = 0; + for (const b of blocks) { + const gross = Number(b.gross); + /* Same truncating integer arithmetic as the distributor. */ + const payable = feeBps > 0 && feeBps <= 10000 + ? gross - Math.floor((gross * feeBps) / 10000) + : gross; + const { mine, total } = split.get(b.hash, workerId, b.window_diff); + const my_diff = Number(mine), win_diff = Number(total); + /* Truncating, exactly as the C does: the sum of everyone's credits can + * fall a few sats short of payable, never over it. */ + const credited = (win_diff > 0 && my_diff > 0) + ? Math.trunc(payable * (my_diff / win_diff)) + : 0; + credited_total += credited; + rows.push({ + height: Number(b.height), hash: b.hash, ts: Number(b.ts), + gross, payable, window_diff: Number(b.window_diff), + my_diff, win_diff, + share_pct: win_diff > 0 ? (my_diff / win_diff) * 100 : 0, + credited, + }); + } + return { fee_bps: feeBps, blocks: rows, credited_total, + block_count: rows.length, truncated: blocks.length === limit }; +} + /* Independently re-derive the PPS ledger instead of reporting it. * * Every other figure on the audit page is a number the proxy wrote and this diff --git a/dashboard/test/pool-identity.test.js b/dashboard/test/pool-identity.test.js index 45eade7..5012669 100644 --- a/dashboard/test/pool-identity.test.js +++ b/dashboard/test/pool-identity.test.js @@ -164,3 +164,41 @@ test('malformed listener JSON does not take the strip down', async () => { * ports must still say where the money goes. */ assert.ok(html.includes(OPERATOR)); }); + +/* PPLNS is neither of the two modes the strip used to know about, and calling + * it either one misstates where a miner's money is. + * + * The fallback branch was solo's: "Each block's coinbase pays the miner who + * found it, directly. No share credit accrues between blocks." Both halves are + * false under PPLNS -- the coinbase pays the pool wallet, and a matured block + * is split across the shares that produced it into the same pps_credits table + * PPS uses. A miner reading that would conclude the pool owed them nothing. */ +test('a pplns pool is not described as solo', async () => { + for (const mode of ['pplns-thunder', 'pplns-btc']) { + const html = await strip(makeDb({ pool_mode: mode })); + assert.match(html, new RegExp(mode), `${mode} must be named`); + assert.doesNotMatch(html, /No share credit accrues between blocks/, + `${mode} must not carry solo's description`); + assert.doesNotMatch(html, /credited at a fixed rate per unit of difficulty/, + `${mode} must not carry the PPS description either`); + assert.match(html, /split across the shares that produced it/, + `${mode} must say how a block is actually divided`); + } +}); + +/* The rail is the one thing the two pplns modes do not share, and it decides + * what a stratum username is -- so it is the fact a miner most needs. */ +test('the strip names the rail a pplns balance is paid over', async () => { + assert.match(await strip(makeDb({ pool_mode: 'pplns-btc' })), /Bitcoin L1/); + assert.match(await strip(makeDb({ pool_mode: 'pplns-thunder' })), /Thunder/); +}); + +/* accrues means "a balance builds up between payouts", which is true of PPS + * and both pplns modes and false only of solo. Reporting it false for pplns + * would describe a pool that owes its miners nothing. */ +test('accrues is true for every mode that credits pps_credits', () => { + assert.equal(poolMeta(makeDb({ pool_mode: 'pps-classic' })).accrues, true); + assert.equal(poolMeta(makeDb({ pool_mode: 'pplns-thunder' })).accrues, true); + assert.equal(poolMeta(makeDb({ pool_mode: 'pplns-btc' })).accrues, true); + assert.equal(poolMeta(makeDb({ pool_mode: 'solo' })).accrues, false); +}); diff --git a/dashboard/test/pplns-audit.test.js b/dashboard/test/pplns-audit.test.js new file mode 100644 index 0000000..866a5eb --- /dev/null +++ b/dashboard/test/pplns-audit.test.js @@ -0,0 +1,201 @@ +/* The worker page's audit, under PPLNS. + * + * The audit answers "why is this number what it is?", and it was written for + * PPS: it re-derives a balance by summing shares.credited_sats, because PPS + * prices a share the moment it arrives and stores that price on the row. + * + * PPLNS prices a share in hindsight, out of a block actually found. Every + * share therefore carries credited_sats = 0, the sum came to zero against a + * real balance, and the page rendered + * + * ⚠ Off by 4,950,000,000 sats (49.50000000 BTC) + * ... Ask the operator to confirm the rate history. + * + * to every miner on a PPLNS pool — telling them the pool's books were short by + * their entire balance, on the one page whose whole purpose is being + * checkable. Not a display nit: a false accusation against the operator. + * + * The fix is not to silence it but to give PPLNS the derivation that actually + * produced the number: for each matured, distributed block, the window it was + * split across and this worker's proportion of it — recomputed from the raw + * shares and blocks_found rows rather than read back from what the payout path + * wrote. + */ +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 { worker, fmtHashrate } from '../lib/stats.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 NOW = Math.floor(Date.now() / 1000); +const handleFor = db => ({ get: () => db }); + +const THUNDER_ADDR = '2sYBNmMJMMZHi6xasMcCPgNiYJ1z'; + +/* A pool in `mode` with the given workers, and optionally one matured block + * distributed across whatever shares precede it. */ +function makeDb({ mode = 'pplns-thunder', fee_bps = 100, workers = [], block = null } = {}) { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'sp-pplns-audit-')), '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) + VALUES (1, 'regtest', 'node', '/sp/', 'bcrt1qop', 'bcrt1qpool', + ?, ?, 'derived', 0, 0, 0, 1.0, 5000000000, 1, NULL, ?)`) + .run(mode, fee_bps, NOW); + + const insWorker = db.prepare(`INSERT INTO workers (id, name, first_seen, last_seen, payout_address) + VALUES (?, ?, ?, ?, ?)`); + /* PPS stores a price on every share. PPLNS stores none — that is the whole + * difference, and writing zeros here is what makes these fixtures honest. */ + const insShare = db.prepare(`INSERT INTO shares + (worker_id, ts, difficulty, is_block, block_hash, credited_sats, rate_used) + VALUES (?, ?, ?, 0, NULL, 0, 0)`); + + let ts = NOW - 1000; + for (const w of workers) { + insWorker.run(w.id, w.name, NOW - 2000, NOW, w.address || THUNDER_ADDR); + } + /* Shares are inserted in the order given, because the window walks + * BACKWARDS from the block's own share by row id. Order is the point. */ + for (const w of workers) { + for (let i = 0; i < (w.shares || 0); ++i) insShare.run(w.id, ts++, w.difficulty); + } + + if (block) { + db.prepare(`INSERT INTO shares (worker_id, ts, difficulty, is_block, block_hash, credited_sats, rate_used) + VALUES (?, ?, 0, 1, ?, 0, 0)`).run(block.finder_id, ts++, block.hash); + db.prepare(`INSERT INTO blocks_found + (ts, height, hash, finder_id, finder_address, reward_sats, fee_sats, + status, confirmations, pplns_window_diff, pplns_distributed) + VALUES (?, ?, ?, ?, 'bcrt1qpool', ?, ?, 'confirmed', 150, ?, 1)`) + .run(NOW - 50, block.height, block.hash, block.finder_id, + block.reward_sats, block.fee_sats, block.window_diff); + } + for (const w of workers) { + if (w.accrued != null) { + db.prepare(`INSERT INTO pps_credits (worker_id, accrued_sats, paid_sats, last_updated) + VALUES (?, ?, 0, ?)`).run(w.id, w.accrued, NOW); + } + } + return db; +} + +const render = (db, name) => { + const w = worker(handleFor(db), name); + return ejs.renderFile(path.join(VIEWS, 'worker.ejs'), + { ...fmt.all, ...w, name, fmtHashrate, pool: null, health: null }, + { views: [VIEWS] }); +}; + +/* One block, one miner, the whole window: 5,000,000,000 gross at 100 bps + * leaves 4,950,000,000, and alice holds all of it. */ +const SOLE_MINER = { + workers: [{ id: 1, name: 'alice', difficulty: 5.0, shares: 10, accrued: 4950000000 }], + block: { height: 800100, hash: 'blk_one', finder_id: 1, + reward_sats: 4950000000, fee_sats: 50000000, window_diff: 50.0 }, +}; + +test('a pplns pool is not told its books are short by the whole balance', async () => { + const html = await render(makeDb(SOLE_MINER), 'alice'); + assert.doesNotMatch(html, /⚠ Off by/, + 'the page must not report a discrepancy that does not exist'); + assert.doesNotMatch(html, /Ask the operator to confirm the rate history/, + 'and must not send the miner to challenge the operator over it'); +}); + +test('the balance is re-derived from the window that produced it', async () => { + const db = makeDb(SOLE_MINER); + const { pplns_audit, pps_audit } = worker(handleFor(db), 'alice'); + assert.equal(pplns_audit.block_count, 1); + /* gross 5e9, fee 1% -> payable 4.95e9; alice holds 50 of 50 difficulty. */ + assert.equal(pplns_audit.blocks[0].payable, 4950000000); + assert.equal(pplns_audit.blocks[0].my_diff, 50); + assert.equal(pplns_audit.blocks[0].win_diff, 50); + assert.equal(pplns_audit.credited_total, 4950000000); + assert.equal(pplns_audit.credited_total, pps_audit.accrued, + 'the recomputation must reproduce the stored balance exactly'); + assert.equal(pps_audit.matches, true); +}); + +test('two miners split a block in proportion to the work they did', async () => { + /* alice 30, bob 20, of a 50-difficulty window: 60/40 of 4,950,000,000. */ + const db = makeDb({ + workers: [ + { id: 1, name: 'alice', difficulty: 5.0, shares: 6, accrued: 2970000000 }, + { id: 2, name: 'bob', difficulty: 5.0, shares: 4, accrued: 1980000000 }, + ], + block: { height: 800200, hash: 'blk_two', finder_id: 2, + reward_sats: 4950000000, fee_sats: 50000000, window_diff: 50.0 }, + }); + const a = worker(handleFor(db), 'alice').pplns_audit; + const b = worker(handleFor(db), 'bob').pplns_audit; + assert.equal(a.blocks[0].my_diff, 30); + assert.equal(b.blocks[0].my_diff, 20); + assert.equal(a.credited_total, 2970000000); + assert.equal(b.credited_total, 1980000000); + /* The finder gets no premium: bob found it and is paid for his work, not + * for the finding. */ + assert.ok(a.credited_total > b.credited_total, + 'the miner with more work in the window is paid more, finder or not'); + assert.equal(a.credited_total + b.credited_total, 4950000000, + 'and between them they get the whole payable amount'); +}); + +test('work done before the window is worth nothing', async () => { + /* carol mined 1000 difficulty, all of it outside a 50-difficulty window. + * This is the case a naive "sum every share" query gets wrong, and the + * reason the audit has to mirror the distributor's walk rather than + * approximate it. */ + const db = makeDb({ + workers: [ + { id: 3, name: 'carol', difficulty: 25.0, shares: 40, accrued: 0 }, + { id: 1, name: 'alice', difficulty: 5.0, shares: 10, accrued: 4950000000 }, + ], + block: { height: 800300, hash: 'blk_three', finder_id: 1, + reward_sats: 4950000000, fee_sats: 50000000, window_diff: 50.0 }, + }); + const carol = worker(handleFor(db), 'carol').pplns_audit; + assert.equal(carol.blocks[0].my_diff, 0, 'carol is outside the window'); + assert.equal(carol.credited_total, 0, 'and is paid nothing, however much she mined'); + const alice = worker(handleFor(db), 'alice').pplns_audit; + assert.equal(alice.credited_total, 4950000000); +}); + +test('a pps-classic pool still gets the per-share audit', async () => { + /* The PPS path must be untouched: it re-derives from credited_sats, which + * is exactly right for a mode that prices a share on arrival. */ + const db = makeDb({ mode: 'pps-classic', + workers: [{ id: 1, name: 'alice', difficulty: 5.0, shares: 4, accrued: 0 }] }); + db.prepare('UPDATE shares SET credited_sats = 100, rate_used = 20').run(); + db.prepare('UPDATE pps_credits SET accrued_sats = 400 WHERE worker_id = 1').run(); + const { pplns_audit, pps_audit } = worker(handleFor(db), 'alice'); + assert.equal(pplns_audit, null, 'no pplns audit on a pps pool'); + assert.equal(pps_audit.accrued_computed, 400); + assert.equal(pps_audit.matches, true); + const html = await render(db, 'alice'); + assert.match(html, /Σ FLOOR\(diff × rate\)/, 'the PPS derivation is still shown'); +}); + +test('a pplns pool that has found nothing yet says so, rather than nothing', async () => { + const db = makeDb({ + workers: [{ id: 1, name: 'alice', difficulty: 5.0, shares: 10, accrued: 0 }], + }); + const { pplns_audit } = worker(handleFor(db), 'alice'); + assert.equal(pplns_audit.block_count, 0); + const html = await render(db, 'alice'); + assert.match(html, /No matured block has been distributed yet/); +}); diff --git a/dashboard/views/partial/pool-identity.ejs b/dashboard/views/partial/pool-identity.ejs index e5bc73e..cc89f5b 100644 --- a/dashboard/views/partial/pool-identity.ejs +++ b/dashboard/views/partial/pool-identity.ejs @@ -38,6 +38,15 @@ <% if (_mode === 'pps-classic') { %> <%= _mode %> + <% } else if (_mode === 'pplns-thunder' || _mode === 'pplns-btc') { %> + <%# Not solo and not PPS, and saying either would be a lie about + where the money is. Blocks pay the pool wallet, as in PPS — + but nothing is credited when a share arrives. 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. The two modes differ only in the rail + the balance is finally paid over. %> + <%= _mode %> <% } else if (_mode) { %> <%= _mode %> <% } else { %> diff --git a/dashboard/views/worker.ejs b/dashboard/views/worker.ejs index b8bca9e..82fe5ea 100644 --- a/dashboard/views/worker.ejs +++ b/dashboard/views/worker.ejs @@ -54,6 +54,87 @@ if (n > 1) {

Audit — why is this number what it is?

+ <% if (typeof pplns_audit !== 'undefined' && pplns_audit) { %> + <%# PPLNS: nothing is credited when a share arrives, so the + per-share re-derivation below is structurally empty and used to + report the pool as off by the miner's entire balance. This is + the derivation that actually produced the number. %> +

+ Nothing is credited when a share arrives. Each block the pool + finds is held until it has matured 100 + confirmations — a coinbase cannot be spent before then + — and is then divided among the shares that produced it: the + pool walks back from the block's own share until their + difficulty fills the window, and each miner takes its + proportion of the reward plus the fees, net of the + operator fee. Every number below is recomputed here from the + raw shares and blocks_found rows, not + read back from whatever the payout path recorded. +

+ <% if (pplns_audit.block_count === 0) { %> +

No matured block has been distributed yet. + Under PPLNS a balance only appears once the pool has found a + block and it has aged 100 deep.

+ <% } else { %> +
+ + + + + + + <% for (const b of pplns_audit.blocks) { %> + + + + + + + + + + <% } %> + +
BlockReward + feesAfter operator feeYour difficultyWindowYour shareCredited
<%= fmtN(b.height) %><%= fmtSats(b.gross) %><%= fmtSats(b.payable) %><%= b.my_diff.toFixed(6) %><%= b.win_diff.toFixed(6) %><%= b.share_pct.toFixed(4) %>%<%= fmtSats(b.credited) %>
+
+
+
<%= fmtN(pplns_audit.block_count) %>
+
<%= fmtSats(pplns_audit.credited_total) %>
+
<%= fmtSats(pps_audit.accrued) %>
+
+ <% if (pplns_audit.credited_total === pps_audit.accrued) { %> + ✓ Yes — the split reproduces your balance + <% } else { %> + ⚠ Off by <%= fmtSats(Math.abs(pps_audit.accrued - pplns_audit.credited_total)) %> + <% } %> +
+
+ <% if (pplns_audit.credited_total !== pps_audit.accrued) { %> +

+ The most likely cause is not a discrepancy in the ledger. + This recomputation uses the pool's current + operator fee (<%= (pplns_audit.fee_bps / 100).toFixed(2) %>%), + because nothing records what it was per block — so if the + operator has ever changed it, blocks distributed under the + old fee will not reproduce. + <% if (pplns_audit.truncated) { %> + Only the most recent <%= fmtN(pplns_audit.block_count) %> + blocks are shown, so older credits are also missing from + the sum. + <% } %> + Your stored balance is authoritative for payouts either way. +

+ <% } %> + <% } %> +

+ There is no "rate" here, and that is the point: PPLNS never + promises a price for a share in advance, so the pool can never + owe more than a block it has actually been paid for. Work that + did not end up in the window of a block is not paid — which is + the trade that removes the operator's reserve, and with it the + risk of the pool failing owing you money. +

+ <% } else { %>

Every accepted share credits your worker FLOOR(share_difficulty × <%= fmtN(pps_audit.rate) %>) @@ -121,6 +202,7 @@ if (n > 1) { <% } %>

<% } %> + <% } %>

For an even deeper trail (per-share running total, daily breakdown), the operator has an admin-only page at diff --git a/docs/simplepool.html b/docs/simplepool.html index 398603e..8616fd5 100644 --- a/docs/simplepool.html +++ b/docs/simplepool.html @@ -4,7 +4,7 @@ simplepool — how it works - +