Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
071bf7f
Make room for pplns: split the flag pool_mode was overloading
rsantacroce Aug 25, 2026
02e1933
pplns: split a matured block across the window that produced it
rsantacroce Aug 25, 2026
8fc6ce1
pplns-btc: pay on L1 by asking the enforcer's wallet
rsantacroce Aug 25, 2026
ba7cda5
Merge main into the pplns branch
rsantacroce Sep 7, 2026
ad2256b
Let the test suite build and run on macOS
rsantacroce Sep 7, 2026
dc62e38
Keep schema.sql and store.c describing the same database
rsantacroce Sep 7, 2026
27f8b94
Stop the dashboard describing a pplns pool as solo
rsantacroce Sep 7, 2026
8f95232
Run the confirmation pass on every new tip, and prove pplns end to end
rsantacroce Sep 7, 2026
0a70554
Distribute on the getblockhash path too, not just the templates one
rsantacroce Sep 7, 2026
336a2fd
Make the pplns-btc payout rail actually pay, and prove it on L1
rsantacroce Sep 7, 2026
c33590b
Pay every due address in one L1 transaction, and prove it with three …
rsantacroce Sep 7, 2026
7c40541
Say that the templates table is load-bearing, not display-only
rsantacroce Sep 7, 2026
998b4a5
Don't fail every payout because the wallet was never encrypted
rsantacroce Sep 7, 2026
712767e
Document the pplns modes, in the three docs that said there were two
rsantacroce Sep 7, 2026
5372c43
Stop the audit page accusing the operator on a pplns pool
rsantacroce Sep 7, 2026
6f96496
Add `make coverage`, so the number can be looked up rather than guessed
rsantacroce Sep 7, 2026
2a8bee1
Make the confirmation pass unit-testable, and pin both bugs it produced
rsantacroce Sep 7, 2026
a917535
Cover config.c, the weakest testable spot at 33%
rsantacroce Sep 7, 2026
3d947fd
reconcile.c: include <strings.h> for strcasecmp
rsantacroce Sep 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions .github/workflows/integration_tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -48,16 +48,27 @@ 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
with:
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

Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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.<timestamp> beside proxy.conf on every
Expand Down
99 changes: 88 additions & 11 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)

Expand Down Expand Up @@ -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: <https://releases.drivechain.info/L2-S9-Thunder-latest-aarch64-apple-darwin.zip>
(no x86_64 Linux prebuilt as of this doc — build from source at
Expand Down Expand Up @@ -476,12 +491,47 @@ Miner username: `<their BTC address>[.<rig_label>]`. 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: `<their Thunder address>[.<rig_label>]`. 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` | `<their Thunder address>[.<rig_label>]` |
| `pplns-btc` | `<their Bitcoin address>[.<rig_label>]` |

Nothing is credited when a share arrives. A block that reaches **100
confirmations** is split across the shares that produced it, pro rata by
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`:
Expand Down Expand Up @@ -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
Expand All @@ -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=<same as the dashboard's POOL_THUNDER_RESERVE_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
Expand All @@ -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.
Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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
Expand Down
72 changes: 67 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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.
#
Expand Down Expand Up @@ -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' \) \
Expand All @@ -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)"
Loading
Loading