diff --git a/.github/scripts/apt-install.sh b/.github/scripts/apt-install.sh new file mode 100755 index 0000000..0a6c51f --- /dev/null +++ b/.github/scripts/apt-install.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# +# Install apt packages in CI without refreshing the whole package index. +# +# `apt-get update` on a GitHub runner refreshes SIX repositories — the Ubuntu +# archive plus Microsoft, azure-cli, Google and Chrome — none of which carry +# anything simplepool builds against, and it makes the job depend on all of +# them being reachable. On 2026-08-18 the Azure mirror `Ign`'d every entry, +# apt fell back to archive.ubuntu.com, and `build-test` sat on +# `Get:5 .../noble-security InRelease` for 29 minutes until the run was +# cancelled. It never reached package download at all. +# +# The runner image ships current lists for the Ubuntu archive — a plain +# `apt-get update` reports `Hit:` on the base suite — so the index already on +# disk is enough to install from. Packages that are already present (most of +# these, on a GitHub image) cost nothing. +# +# The one case the shipped index cannot serve is a package superseded by a +# security update whose old .deb has left the pool, which 404s. That is the +# only reason the fallback below exists, and every network wait in it is +# bounded so it cannot repeat the stall it was written to prevent. +# +# Usage: .github/scripts/apt-install.sh ... +# +set -euo pipefail + +[ $# -gt 0 ] || { echo "apt-install.sh: no packages given" >&2; exit 2; } + +export DEBIAN_FRONTEND=noninteractive + +# Bound every fetch: without a timeout a stalled mirror holds the connection +# open until the job's own limit kills it, which is the failure mode here. +APT_OPTS=( + -o Acquire::Retries=3 + -o Acquire::http::Timeout=20 + -o Acquire::https::Timeout=20 +) + +apt_install() { + sudo -E apt-get install -y --no-install-recommends "${APT_OPTS[@]}" "$@" +} + +echo "==> installing without an index refresh: $*" +if apt_install "$@"; then + exit 0 +fi + +echo "::warning::apt-get install failed against the image's package index." \ + "Refreshing it once, then retrying. If this becomes routine, the runner" \ + "image's lists have drifted and this script's assumption needs revisiting." + +# `sudo timeout` rather than `timeout sudo`, so the kill lands on apt-get +# itself instead of on sudo, which may or may not forward the signal. +sudo -E timeout 180 apt-get update "${APT_OPTS[@]}" \ + || echo "::warning::index refresh did not finish in 180s; retrying the install regardless" + +apt_install "$@" diff --git a/.github/workflows/check_build.yaml b/.github/workflows/check_build.yaml index ae8e6cd..26ebb1a 100644 --- a/.github/workflows/check_build.yaml +++ b/.github/workflows/check_build.yaml @@ -13,6 +13,10 @@ on: jobs: build-test: runs-on: ubuntu-latest + # This job had no limit and stalled for 29 minutes inside `apt-get update` + # before a human cancelled it. A build-and-unit-test of a 13-file C project + # has no business taking longer than this. + timeout-minutes: 15 # Match build_docker.yaml: CI runs only in the LayerTwo-Labs org repo, so # pushing to a personal repo doesn't spend Actions minutes. Remove this line # if you *do* want build/test CI to run on your own fork too. @@ -22,13 +26,14 @@ jobs: uses: actions/checkout@v4 - name: Install build dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential \ - libsqlite3-dev \ - libcurl4-openssl-dev \ - libhiredis-dev + timeout-minutes: 5 + # No `apt-get update` — see .github/scripts/apt-install.sh for why. + run: > + .github/scripts/apt-install.sh + build-essential + libsqlite3-dev + libcurl4-openssl-dev + libhiredis-dev - name: Build run: make -j"$(nproc)" diff --git a/.github/workflows/integration_tests.yaml b/.github/workflows/integration_tests.yaml index d88d6e4..a79d539 100644 --- a/.github/workflows/integration_tests.yaml +++ b/.github/workflows/integration_tests.yaml @@ -29,17 +29,18 @@ jobs: uses: actions/checkout@v4 - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential \ - libsqlite3-dev \ - libcurl4-openssl-dev \ - libhiredis-dev \ - unzip \ - jq \ - sqlite3 \ - netcat-openbsd + timeout-minutes: 5 + # No `apt-get update` — see .github/scripts/apt-install.sh for why. + run: > + .github/scripts/apt-install.sh + build-essential + libsqlite3-dev + libcurl4-openssl-dev + libhiredis-dev + unzip + jq + sqlite3 + netcat-openbsd - name: Build run: make -j"$(nproc)" @@ -106,13 +107,16 @@ jobs: uses: actions/checkout@v4 - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - unzip \ - jq \ - sqlite3 \ - netcat-openbsd + timeout-minutes: 5 + # Every one of these is already on the GitHub runner image; the call + # is kept so the job states its own dependencies rather than relying + # on the image silently continuing to carry them. + run: > + .github/scripts/apt-install.sh + unzip + jq + sqlite3 + netcat-openbsd - name: Run payout regtest test run: bash tests/test_payout_regtest.sh diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..bd2576b --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,173 @@ +# Cut a simplepool release: build the binary for each supported architecture, +# wrap it in a tarball, and publish the lot as a GitHub Release. +# +# The tarball is what `scripts/install.sh --from-release` downloads, so this +# workflow is the thing standing behind the one-line install. It is also the +# only place the published artifacts are produced — a maintainer never uploads +# a hand-built binary, because a hand-built binary has no attested link back +# to a commit. +# +# Trigger: +# git tag v0.2.0 && git push origin v0.2.0 +# +# The tag must match VERSION in the Makefile. That is checked, not assumed: +# the version is compiled into the binary and reported by `--version`, so a +# mismatch would ship a release whose own binary disagrees with its name. +# +# workflow_dispatch builds the tarballs and uploads them as workflow +# artifacts without creating a Release — a dry run of the whole path. +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + # Same guard as the other workflows: releases are cut from the canonical + # org repo, so a personal fork doesn't spend Actions minutes or publish + # artifacts under its own name. + if: github.repository_owner == 'LayerTwo-Labs' + strategy: + fail-fast: false + matrix: + include: + # 22.04 sets the glibc floor at 2.35, so the binary also runs on + # 24.04 and on Debian 12. Building on the newest runner instead + # would silently exclude every older box. + - runner: ubuntu-22.04 + arch: amd64 + # Free ARM runners are available to public repositories. If this + # label is ever unavailable, delete this entry — fail-fast is off, + # so the amd64 release still goes out. + - runner: ubuntu-22.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # release.sh runs `git archive HEAD` and records the commit, so it + # needs real history rather than a detached blob. + fetch-depth: 0 + + - name: Install build dependencies + timeout-minutes: 5 + # No `apt-get update` — see .github/scripts/apt-install.sh for why. + run: > + .github/scripts/apt-install.sh + build-essential + libsqlite3-dev + libcurl4-openssl-dev + libhiredis-dev + + - name: Check the tag matches the Makefile VERSION + if: startsWith(github.ref, 'refs/tags/v') + run: | + tag="${GITHUB_REF_NAME#v}" + makefile_version="$(sed -n 's/^VERSION[[:space:]]*:=[[:space:]]*//p' Makefile | head -1)" + if [ "$tag" != "$makefile_version" ]; then + echo "::error::tag $GITHUB_REF_NAME does not match Makefile VERSION=$makefile_version." \ + "The version is compiled into the binary, so releasing this would ship" \ + "artifacts whose own --version disagrees with the release name." \ + "Bump VERSION in the Makefile (in a PR), then re-tag." + exit 1 + fi + + - name: Build tarball + run: scripts/release.sh --arch ${{ matrix.arch }} --out dist + + - name: Smoke-test the tarball + # A tarball that doesn't unpack into a runnable binary is worse than + # no release at all, because the failure lands on an operator running + # a one-liner on a fresh box. + run: | + set -euo pipefail + work="$(mktemp -d)" + tar -xzf dist/*.tar.gz -C "$work" + root="$(find "$work" -maxdepth 1 -mindepth 1 -type d)" + test -f "$root/RELEASE" + test -f "$root/schema.sql" + test -f "$root/scripts/install.sh" + test -x "$root/scripts/simplepoolctl" + test -f "$root/deploy/systemd/simplepool.service" + test -d "$root/dashboard" && test -d "$root/payout" + # Only meaningful on the native runner; an arm64 binary cannot be + # executed on an amd64 host and vice versa. + "$root/build/simplepool" --version + cat "$root/RELEASE" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: simplepool-${{ matrix.arch }} + path: dist/* + if-no-files-found: error + + publish: + needs: build + # `always()` so a single architecture failing to build still lets the + # other one ship, rather than losing the release entirely. + if: always() && startsWith(github.ref, 'refs/tags/v') && github.repository_owner == 'LayerTwo-Labs' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: staging + pattern: simplepool-* + merge-multiple: true + + - name: Collect checksums + run: | + set -euo pipefail + cd staging + ls -la + # At least one architecture must have made it through. + ls *.tar.gz >/dev/null + # One SHA256SUMS covering every tarball, so an operator can verify a + # download with a single file regardless of which arch they took. + cat *.tar.gz.sha256 > SHA256SUMS + rm -f *.tar.gz.sha256 + cat SHA256SUMS + + - name: Create the release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + { + echo "## Install" + echo + echo '```sh' + echo "curl -fsSL https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${GITHUB_REF_NAME}/scripts/install.sh | sudo bash" + echo '```' + echo + echo "The installer downloads the tarball below, verifies it against \`SHA256SUMS\`," + echo "and interviews you for the rest. See [INSTALL.md](https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_REF_NAME}/INSTALL.md)." + echo + echo "## Verify a download by hand" + echo + echo '```sh' + echo "sha256sum -c --ignore-missing SHA256SUMS" + echo '```' + echo + echo "Binaries are built on Ubuntu 22.04 (glibc 2.35), so they also run on" + echo "Ubuntu 24.04 and Debian 12. Each tarball carries a \`RELEASE\` file and a" + echo "\`build/simplepool.build.json\` pinning the binary to this commit by sha256." + } > notes.md + gh release create "$GITHUB_REF_NAME" \ + --title "simplepool $version" \ + --notes-file notes.md \ + staging/* diff --git a/.gitignore b/.gitignore index eb22e26..b06c4b9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,10 @@ # another file containing bitcoind RPC credentials into a PUBLIC repo's # working tree, one `git add -A` away from being committed. /proxy.conf.bak.* +# Release tarballs built by scripts/release.sh. They contain a full copy of +# the tree plus a binary; nothing here belongs in the repo. +/dist/ +# macOS Finder droppings. They appear in any directory the user has opened, +# and a `git add -A` sweeps them into a public repo. +.DS_Store +**/.DS_Store diff --git a/INSTALL.md b/INSTALL.md index 8d1a7b7..62ef32b 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -100,7 +100,7 @@ sudo apt install -y nodejs ### 1. Clone and build ```sh -git clone https://github.com/rsantacroce/simplepool.git +git clone https://github.com/LayerTwo-Labs/simplepool.git cd simplepool make ``` @@ -149,11 +149,93 @@ against it, they'll work against forknet or mainnet-drivechain. ## Part B — production install on Ubuntu -Two paths: **the one-shot script** for a fresh Linux box, or the -**manual walkthrough** if you're integrating simplepool into an -existing setup. +Three paths, in the order most people want them: -### The one-shot deploy (fresh Ubuntu 24.04) +1. **The installer** — run one line on the box. Interviews you, then does + everything below. This is Part B.1. +2. **`deploy-to-server.sh`** — drive an already-installed box from your + workstation. For iterating on unreleased code. Part B.2. +3. **The manual walkthrough** — every step by hand, for integrating into an + existing setup. Part B.3. + +### B.1 — the installer (fresh Ubuntu / Debian box) + +```sh +curl -fsSL https://raw.githubusercontent.com/LayerTwo-Labs/simplepool/main/scripts/install.sh | sudo bash +``` + +It asks where to install, which pool mode, your bitcoind RPC and addresses, +the dashboard domain and admin password, and whether to set up nginx, TLS and +the firewall — then does the whole install and prints what miners should +connect to. Every answer is saved to `/etc/simplepool/install.env` and reused +as the default next time, so **re-running it is how you change your mind** +about any of them. + +#### Where the code comes from + +The first question is the one worth understanding: + +| | `release` (default) | `source` | +| --- | --- | --- | +| how | downloads the published tarball for this machine's architecture and verifies it against the release `SHA256SUMS` | `git clone` + `make` | +| needs | curl, the runtime shared libraries | a C toolchain, git, and a few minutes | +| gives you | exactly what CI built and tagged | any branch, any architecture | +| upgrade | `simplepoolctl upgrade` | re-run with `--from-source` | + +Both end with the same tree at `$ROOT` — the release tarball *is* a checkout +with a prebuilt `build/simplepool` and a `RELEASE` file in it. Everything +after that step (config, database, systemd, nginx) is one code path, so a +release box and a source box differ only in where the binary came from. + +Released binaries are built on Ubuntu 22.04, so they need **glibc 2.35 or +newer** — Ubuntu 22.04+, Debian 12+. On anything older, or on an +architecture with no published build, use `--from-source`. + +#### Non-interactive + +Every prompt has a flag, so the same script drives CI and re-runs: + +```sh +sudo ./scripts/install.sh --non-interactive --yes \ + --from-release \ + --root /home/simplepool --user simplepool \ + --mode pps-classic \ + --operator-address bc1q... --pool-btc-address bc1q... \ + --thunder-address \ + --bitcoind-url http://127.0.0.1:8332 \ + --bitcoind-user rpcuser --bitcoind-pass rpcpass \ + --hostname pool.example.com --tls --email you@example.com \ + --payout-interval-hours 24 +``` + +`sudo ./scripts/install.sh --help` lists them all. + +#### Afterwards: `simplepoolctl` + +The installer drops `simplepoolctl` into `/usr/local/bin`. It reads the same +`/etc/simplepool/install.env`, so it needs no configuration of its own: + +```sh +simplepoolctl status # services, ports, versions, ledger totals +simplepoolctl doctor # binary runs? bitcoind reachable? DB writable? +simplepoolctl logs payout -f # one service, or 'all' (the default) +simplepoolctl config # where every config file is, and what's in it +sudo simplepoolctl restart proxy +sudo simplepoolctl upgrade # next release (or rebuild, on a source box) +sudo simplepoolctl uninstall # --purge also deletes the ledger +``` + +`doctor` is the one to run when something is wrong: it checks the binary +actually executes on this machine, that `operator_address` (and +`pool_btc_address` in pps-classic) are set, that the schema is loaded and the +data directory is writable by the service user, that bitcoind answers +`getblockchaininfo` with the configured credentials, and that something is +listening on the stratum port. + +Once it is up, jump to **Part D** to review `proxy.conf` for your mode — the +installer has already written the keys it asked about. + +### B.2 — deploy-to-server.sh (from your workstation) `scripts/deploy-to-server.sh` handles installing deps, cloning, building, initializing SQLite, dropping systemd units, and setting up @@ -183,9 +265,9 @@ What it does (idempotent — re-run after every code change): After that runs cleanly, jump to **Part D** (configuring `proxy.conf` for your chosen mode) — everything else is already up. -### Manual walkthrough +### B.3 — manual walkthrough -Skip if you ran the deploy script. Otherwise: +Skip if you ran the installer or the deploy script. Otherwise: 1. **Create a service user + workdir**: ```sh @@ -197,7 +279,7 @@ Skip if you ran the deploy script. Otherwise: ```sh sudo -u simplepool -H bash -lc ' cd /home/simplepool && - git clone https://github.com/rsantacroce/simplepool.git . && + git clone https://github.com/LayerTwo-Labs/simplepool.git . && make -j$(nproc) ' ``` @@ -475,7 +557,12 @@ sudo tee /etc/systemd/system/simplepool-payout.service.d/local.conf <<'CONF' Environment=THUNDER_FROM_ADDRESS= # Below have defaults; override if you want: # Environment=PAYOUT_MIN_SATS=10000 -# Environment=PAYOUT_INTERVAL_MS=30000 +# Payout runs are a daily batch (24h). The settle clock is separate on +# purpose: a batch already broadcast is re-checked every 30s, because +# nobody in it is credited until a tick sees it in a Thunder block. +# Environment=PAYOUT_INTERVAL_MS=86400000 +# Environment=PAYOUT_SETTLE_INTERVAL_MS=30000 +# Environment=PAYOUT_RETRY_INTERVAL_MS=300000 # Environment=PAYOUT_MAX_PER_TICK=50 CONF sudo systemctl daemon-reload @@ -484,8 +571,8 @@ sudo journalctl -u simplepool-payout.service -f ``` The worker is idle when the Thunder reserve has no funds — it logs -`payout: reserve short — available=0 needed=N` every tick and skips -harmlessly. See the deposit runbook in +`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. For dry-run (see the exact payout it WOULD send without doing it), @@ -499,6 +586,7 @@ sudo -u simplepool -H bash -lc ' THUNDER_RPC_URL=http://127.0.0.1:6009 \ THUNDER_FROM_ADDRESS=any \ PAYOUT_INTERVAL_MS=2000 \ + PAYOUT_SETTLE_INTERVAL_MS=2000 \ node index.js ' ``` diff --git a/Makefile b/Makefile index 5b23794..3dcb2ff 100644 --- a/Makefile +++ b/Makefile @@ -53,7 +53,7 @@ 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.1.0 +VERSION := 0.2.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) diff --git a/OPERATOR_GUIDE.md b/OPERATOR_GUIDE.md index 9250528..648005d 100644 --- a/OPERATOR_GUIDE.md +++ b/OPERATOR_GUIDE.md @@ -23,6 +23,7 @@ tracked by git. | Which commit is running | `http://:8081/api/versions` | none | | Stratum (miner endpoint) | `stratum+tcp://:3334` | username = Thunder base58 address | | SSH | `root@` | `` | +| Everything from the shell | `simplepoolctl status` / `doctor` / `logs -f` | root for `restart`, `upgrade`, `uninstall` | The admin password is stashed at `/root/simplepool-admin-cred.txt` on the box (root-only). To rotate, edit @@ -158,6 +159,22 @@ Also check the **In-flight payouts** card — should be empty. Any row with a set `txid` means a payout crashed mid-flight and needs manual reconciliation. +From the shell, `simplepoolctl status` covers the same ground (services, +ports, worker count, blocks found, sats owed) and `simplepoolctl doctor` +checks the things that actually break: the binary runs here, bitcoind +answers, the DB is writable, something is listening on :3334. + +**On the payout cadence.** Payouts run as a **daily batch** — once every 24h +everyone over `PAYOUT_MIN_SATS` goes out in a single Thunder transaction. +So "nobody has been paid yet today" is the normal state for most of the day, +not a fault. What does *not* wait a day is settlement: once a batch is +broadcast the worker re-checks it every 30s until Thunder mines it, because +nobody in that batch is credited until then. To pay out early, use **Trigger +payout now** on the admin dashboard. To change the cadence, re-run the +installer with `--payout-interval-hours N`, or edit +`PAYOUT_INTERVAL_MS` in +`/etc/systemd/system/simplepool-payout.service.d/local.conf`. + ### 2. Deposit BTC into Thunder (when reserve is short) **Currently manual — no admin button yet.** The runbook: diff --git a/README.md b/README.md index 39a8ddf..498606f 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,22 @@ # simplepool -A small, single-binary **solo-mining stratum server** in pure C11. It accepts -miner connections on TCP `:3334`, builds block templates via `bitcoind`'s +A small, single-binary **stratum server** in pure C11. It accepts miner +connections on TCP `:3334`, builds block templates via `bitcoind`'s `getblocktemplate`, submits found blocks via `submitblock`, and records every accepted share into a local SQLite database. A separate Node.js dashboard reads that file for stats. -Created by **Roberto Santacroce** — -source: . +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. + +Created by **Roberto Santacroce**. +Canonical repository: . + +```sh +curl -fsSL https://raw.githubusercontent.com/LayerTwo-Labs/simplepool/main/scripts/install.sh | sudo bash +``` ## About simplepool @@ -28,6 +37,13 @@ source: . > 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 — +> 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 + This repository ships **both modes**, selected by `pool_mode` in `proxy.conf`: @@ -46,6 +62,12 @@ This repository ships **both modes**, selected by `pool_mode` in `s9__` is rejected, since Thunder itself doesn't recognize it at the byte level. + Payouts run as a **daily batch**: once every 24h everyone over + `PAYOUT_MIN_SATS` goes out in a single Thunder transaction. Settlement of + an already-broadcast batch runs on its own 30-second clock, because nobody + in a batch is credited until a tick observes it in a Thunder block. See + [`payout/README.md`](payout/README.md) for all three clocks. + > An earlier `pool_mode = pps` put a BIP300 drivechain deposit > directly in each coinbase, so the pool would never custody BTC. > Regtest and forknet both showed the enforcer does *not* credit @@ -64,20 +86,23 @@ tip changes and PPS credits to Redis pub/sub channels (`pool:shares`, dashboard and any downstream consumers. SQLite remains the source of truth; the publish is fire-and-forget. -It is a **solo pool with direct payouts**: every coinbase has two outputs — -the **miner who found the block gets the reward** (minus a small operator -fee), and the configured `operator_address` gets the rest (default 1% = -100 basis points, configurable via `fee_bps`). Each connected miner gets +**In `solo` mode** it is a solo pool with direct payouts: every coinbase has +two outputs — the **miner who found the block gets the reward** (minus a small +operator fee), and the configured `operator_address` gets the rest (default +1% = 100 basis points, configurable via `fee_bps`). Each connected miner gets its own coinbase rendered against the miner's own address; the merkle branches, prev-hash, ntime, etc. are shared. -There is **no PPS**, no inter-miner reward sharing, and no -difficulty-weighted accounting. If your miner finds the block, your -address gets ~99% of the subsidy + fees on-chain in the same coinbase -transaction; if it doesn't, nobody on this proxy gets anything for that -height. The `shares` and `workers` tables exist purely so the dashboard -can show a leaderboard, per-worker drilldown, and historical "blocks -found by the pool" view. +In that mode there is no inter-miner reward sharing and no difficulty-weighted +accounting. If your miner finds the block, your address gets ~99% of the +subsidy + fees on-chain in the same coinbase transaction; if it doesn't, +nobody on this proxy gets anything for that height. The `shares` and `workers` +tables exist so the dashboard can show a leaderboard, per-worker drilldown, +and historical "blocks found by the pool" view. + +**In `pps-classic` mode** that inverts: the coinbase pays the pool, every +accepted share credits a balance at a rate derived from the live block +template, and the pool — not the miner — carries the variance. ### A note on terminology: "share" vs "work" @@ -97,11 +122,12 @@ hold even though this is currently solo-mode: We surface it with an explanatory banner on the dashboard and with the project blurb above, rather than by renaming things. -**In this solo build**, a share is an accepted Proof-of-Work submission -below the connection's worker target. It is *not* a payout claim and -does not accrue a balance — it exists for hashrate estimation, -per-rig accountability, and as the data primitive the upcoming PPS -billing engine will consume. +**In `solo` mode**, a share is an accepted Proof-of-Work submission below the +connection's worker target. It is *not* a payout claim and does not accrue a +balance — it exists for hashrate estimation, per-rig accountability, and as +the data primitive the PPS billing path consumes. **In `pps-classic`** the +same row additionally carries `credited_sats` and the `rate_used` that +produced it, and *is* the unit of account. ### How the solo flow actually works @@ -202,29 +228,51 @@ address. Format: Examples: `bc1qabc…`, `bc1qabc….basement-rig`, `bcrt1q…test.alice`. -This is a **sibling project** to the Rust mining pool that lives elsewhere in -this same monorepo. The two share nothing in code or goals: the Rust pool is -a production-style PPS pool with payouts; `simplepool` is intentionally minimal -and exists for solo mining + observability only. +`simplepool` is deliberately small: one C binary for the hot path, one +read-only Node dashboard, one Node payout worker, and a SQLite file that is +the source of truth for all three. Nothing in the stratum path depends on the +dashboard or the payout worker being up — a billing outage must never stop the +pool accepting work or submitting blocks. -Status: **wired**. The main binary loads config, connects to bitcoind, -opens the SQLite store, builds an initial job from `getblocktemplate`, -serves stratum on the configured port, and watches for new tips on a -background thread. +## Install -## Build +On a fresh Ubuntu or Debian server: -Dependencies: `sqlite3`, `libcurl`, `pthread`, plus a C11 compiler. +```sh +curl -fsSL https://raw.githubusercontent.com/LayerTwo-Labs/simplepool/main/scripts/install.sh | sudo bash +``` + +That downloads the published build for the machine's architecture, checks it +against the release `SHA256SUMS`, then interviews you for the rest — pool mode, +bitcoind RPC, your operator address, dashboard domain, nginx and TLS — and +leaves a running pool behind nginx with a `simplepoolctl` command to drive it. +No compiler and no clone: `--from-source` if you want those instead. Answers +are saved, so re-running it is how you change your mind about any of them. + +```sh +simplepoolctl status # what's running, on which ports, at which version +simplepoolctl doctor # check the things that actually break in production +simplepoolctl logs -f # follow every service at once +simplepoolctl upgrade # move to the next release, then restart +simplepoolctl uninstall # remove the services (--purge also drops the data) +``` + +Full walkthrough, including the manual steps the script automates, is in +[INSTALL.md](INSTALL.md). To cut a release, see [RELEASING.md](RELEASING.md). + +## Build from source + +Dependencies: `sqlite3`, `libcurl`, `libhiredis`, `pthread`, plus a C11 compiler. macOS: ``` -brew install sqlite curl +brew install sqlite curl hiredis make ``` Debian / Ubuntu: ``` -sudo apt install build-essential libsqlite3-dev libcurl4-openssl-dev +sudo apt install build-essential libsqlite3-dev libcurl4-openssl-dev libhiredis-dev make ``` @@ -276,9 +324,11 @@ at a snapshot via `PROXY_DB_PATH` if you want — see ## Deploy to a server -There's a one-shot deploy script that brings a fresh Ubuntu 24.04 box -from nothing to fully serving stratum + dashboard behind nginx. It is -idempotent: re-run it after every code change. +[`scripts/install.sh`](scripts/install.sh) (see [Install](#install) above) is +the way to bring a box up from nothing. `scripts/deploy-to-server.sh` is the +other direction: it drives an *already installed* box from your workstation, +which is what you want while iterating on code that isn't released yet. It is +idempotent: re-run it after every change. ``` ./scripts/deploy-to-server.sh \ @@ -317,12 +367,16 @@ or use `stunnel`. ### Operations ``` -sudo systemctl status simplepool simplepool-dashboard nginx -sudo journalctl -u simplepool -f # stratum log -sudo journalctl -u simplepool-dashboard -f # dashboard log -sudo systemctl restart simplepool # after pulling new code +simplepoolctl status # services, ports, ledger totals +simplepoolctl logs proxy -f # stratum log +simplepoolctl logs dashboard -f # dashboard log +sudo simplepoolctl restart proxy # after changing proxy.conf ``` +`simplepoolctl` is a wrapper over systemd — the underlying commands +(`systemctl status simplepool`, `journalctl -u simplepool -f`) work exactly as +before, and are what it prints when something needs a closer look. + To pull edits made directly on a server back into a local checkout (so you can commit + push from here), use [`scripts/sync-from-server.sh`](scripts/sync-from-server.sh). @@ -399,8 +453,9 @@ The script: 5. Asserts that `workers` has at least one row, `workers.payout_address` is populated, and `rejects` has at least one row. -For the broader stack flow (Docker compose, Rust pool, dashboard) see -[`../docs/TESTING.md`](../docs/TESTING.md). +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). ## Layout @@ -419,45 +474,58 @@ src/ stratum.{c,h} # stratum v1 server 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) + version.{c,h} # build provenance compiled into the binary cjson/ # vendored cJSON (MIT) — see src/cjson/README.md -include/ # public headers (empty for now) -tests/ # unit tests + integration shell script +tests/ # unit tests + integration shell scripts deploy/ # systemd unit templates + nginx vhost templates -scripts/ # deploy + sync helpers +scripts/ + install.sh # bootstrap a fresh box (release download or source build) + simplepoolctl # status / logs / doctor / upgrade / uninstall + 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 ``` ## Roadmap -The solo build is intentionally minimal; the items below extend it -toward the full simplepool PPS pool without changing the share/block data -model that already lives in `schema.sql`. - -1. **Move persistence behind Redis.** Add a Redis-backed write path - alongside the SQLite store so the hot share queue isn't bound to a - single-writer file. SQLite stays as the durable archive; Redis - absorbs the high-frequency writes and makes the share stream - consumable by other services in real time. -2. **PPS billing as a separate, non-blocking service.** Run the - Pay-Per-Share build on its own port / instance. The billing engine - consumes the share stream (Redis) and settles payouts over - **Thunder**. Strict separation: a billing outage must never block - the stratum proxy from accepting work or submitting blocks. -3. **Miner registration for the PPS pool.** Endpoint + flow for miners - to register a payout address, a withdrawal threshold, and any - per-account settings the PPS engine needs. The solo build doesn't - need this — solo miners are identified by the address embedded in - the stratum username — but PPS does. -4. **Status and observability.** Expose Prometheus-style metrics +The share/block data model in `schema.sql` has not had to change as the pool +grew from solo-only to PPS, and the items below are not expected to change it +either. + +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. + +Still open: + +1. **Automatic BTC → Thunder deposits.** Today the operator presses a button + per deposit (see [`CLASSIC_PAYOUTS.md`](CLASSIC_PAYOUTS.md)). An + auto-batching worker needs no schema change — just a service that posts to + `/admin/deposit`. +2. **Status and observability.** Expose Prometheus-style metrics (`/metrics`), structured logs, and per-connection health for both the proxy and the billing service. The goal is for any miner to be able to audit their own contribution end to end without having to trust an opaque "pool dashboard." -5. **Richer dashboard metrics.** Build on the current overview / per- +3. **Richer dashboard metrics.** Build on the current overview / per- worker / blocks pages with per-rig hashrate variance, expected-vs- observed payouts, network-difficulty overlays, and historical charts that go beyond the rolling 24-hour window. -6. **Decouple the dashboard from the live database.** Have the +4. **Decouple the dashboard from the live database.** Have the dashboard read its own derived store (a Redis replica or a periodic materialised view) rather than the proxy's primary SQLite file. That keeps the dashboard's read pattern from ever touching the hot diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..b63ee6e --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,100 @@ +# Cutting a release + +A simplepool release is what stands behind the one-line install, so the +process exists to make one thing true: **every published artifact traces back +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 + +`VERSION` lives in the [Makefile](Makefile) and is compiled into the binary — +`simplepool --version` reports it, and so does `/api/versions` on the +dashboard. The release workflow **fails** if the tag and `VERSION` disagree, +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. + +```sh +git checkout -b release-0.2.0 +sed -i 's/^VERSION := .*/VERSION := 0.2.0/' Makefile +git commit -am "Release 0.2.0" +gh pr create --fill +``` + +Merge it. Everything below runs against `main`. + +## 2. Tag + +```sh +git checkout main && git pull +git tag v0.2.0 +git push origin v0.2.0 +``` + +That single push triggers two workflows: + +| workflow | what it publishes | +| --- | --- | +| [`release.yaml`](.github/workflows/release.yaml) | `simplepool-0.2.0-linux-{amd64,arm64}.tar.gz` + `SHA256SUMS`, attached to a GitHub Release | +| [`build_docker.yaml`](.github/workflows/build_docker.yaml) | `ghcr.io/layertwo-labs/simplepool{,-dashboard,-payout}:v0.2.0` | + +## 3. What the release job actually does + +Per architecture, on a native runner: + +1. Check the tag matches `VERSION`. +2. Run [`scripts/release.sh`](scripts/release.sh) — the same script you would + run by hand, so a locally built tarball and a released one are the same + recipe. It builds the binary, refuses to continue if the tree is dirty + (the binary would not match the source shipped beside it), assembles the + tarball with `git archive`, and writes a `RELEASE` file plus a + `build/simplepool.build.json` pinning the binary to the commit by sha256. +3. Unpack the tarball and run `build/simplepool --version` out of it. A + tarball that doesn't produce a runnable binary never becomes a release, + because the person who finds out otherwise is an operator running a + one-liner on a fresh box. + +Then a single job merges the per-arch checksums into one `SHA256SUMS` and +creates the Release. + +Binaries are built on **Ubuntu 22.04 (glibc 2.35)** so they also run on +Ubuntu 24.04 and Debian 12. That floor is recorded in each tarball's +`RELEASE` file. + +## 4. Verify the release exists and installs + +```sh +# what the installer will resolve to +curl -fsSL https://api.github.com/repos/LayerTwo-Labs/simplepool/releases/latest \ + | grep '"tag_name"' + +# what an operator runs +curl -fsSL https://raw.githubusercontent.com/LayerTwo-Labs/simplepool/main/scripts/install.sh \ + | sudo bash +``` + +On a box that is already installed, `simplepoolctl upgrade` moves it to the +new release and restarts the services. + +## Dry run without tagging + +`workflow_dispatch` on the Release workflow builds both tarballs and uploads +them as workflow artifacts **without** creating a Release — use it to check a +build before committing to a tag. + +```sh +gh workflow run release.yaml +``` + +## Hand-building a tarball + +```sh +scripts/release.sh # dist/simplepool--linux-.tar.gz +scripts/release.sh --help +``` + +Useful for testing the install path against a local file. It is not how +published artifacts are produced — those only ever come from CI. diff --git a/VERIFY.md b/VERIFY.md index 02ef9e1..efe4ba0 100644 --- a/VERIFY.md +++ b/VERIFY.md @@ -178,6 +178,7 @@ THUNDER_RPC_URL=http://127.0.0.1:6009 \ THUNDER_FROM_ADDRESS=any \ PAYOUT_MIN_SATS=10000 \ PAYOUT_INTERVAL_MS=2000 \ +PAYOUT_SETTLE_INTERVAL_MS=2000 \ node /Users/rob/projects/simplepool/payout/index.js ``` @@ -221,6 +222,7 @@ THUNDER_RPC_URL=http://127.0.0.1:16009 \ THUNDER_FROM_ADDRESS=any \ PAYOUT_MIN_SATS=10000 \ PAYOUT_INTERVAL_MS=2000 \ +PAYOUT_SETTLE_INTERVAL_MS=2000 \ node /Users/rob/projects/simplepool/payout/index.js ``` diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example index 4a1a857..58c9430 100644 --- a/deploy/docker/.env.example +++ b/deploy/docker/.env.example @@ -33,8 +33,17 @@ POOL_THUNDER_RESERVE_ADDRESS= THUNDER_FROM_ADDRESS= # Don't create payouts smaller than this (avoids fee-dominated txs). PAYOUT_MIN_SATS=10000 -# Reconciliation loop interval. -PAYOUT_INTERVAL_MS=30000 +# How often a payout run starts. Daily: everyone over PAYOUT_MIN_SATS goes +# out in one Thunder transaction, once every 24h. +PAYOUT_INTERVAL_MS=86400000 +# How often an already-broadcast batch is re-checked while it waits for a +# Thunder block. Nobody in it is credited until that happens, so this stays +# short regardless of the payout cadence above. +PAYOUT_SETTLE_INTERVAL_MS=30000 +# How long to wait after a tick that tried and got nowhere (transfer failed, +# or the reserve was short). Nothing was broadcast, so it retries sooner +# than the daily cadence. +PAYOUT_RETRY_INTERVAL_MS=300000 # Set to 1 to compute and log payouts without broadcasting them (safe first run). PAYOUT_DRY_RUN=0 diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 6a11c4e..ae8285a 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -82,7 +82,11 @@ services: THUNDER_RPC_URL: ${THUNDER_RPC_URL:-http://host.docker.internal:6009} THUNDER_FROM_ADDRESS: ${THUNDER_FROM_ADDRESS:-} PAYOUT_MIN_SATS: ${PAYOUT_MIN_SATS:-10000} - PAYOUT_INTERVAL_MS: ${PAYOUT_INTERVAL_MS:-30000} + # Daily batch. Settlement of an already-broadcast batch runs on its + # own short clock so a payout isn't left uncredited for 24h. + PAYOUT_INTERVAL_MS: ${PAYOUT_INTERVAL_MS:-86400000} + PAYOUT_SETTLE_INTERVAL_MS: ${PAYOUT_SETTLE_INTERVAL_MS:-30000} + PAYOUT_RETRY_INTERVAL_MS: ${PAYOUT_RETRY_INTERVAL_MS:-300000} PAYOUT_DRY_RUN: ${PAYOUT_DRY_RUN:-0} # Admin HTTP surface — dashboard reaches it at PAYOUT_ADMIN_URL. # Bind to 0.0.0.0 (default) so the dashboard container can reach diff --git a/deploy/systemd/simplepool-payout.service b/deploy/systemd/simplepool-payout.service index 6b09894..97f60f8 100644 --- a/deploy/systemd/simplepool-payout.service +++ b/deploy/systemd/simplepool-payout.service @@ -17,7 +17,16 @@ Environment=PAYOUT_DB_PATH=@ROOT@/data/shares.db Environment=THUNDER_RPC_URL=http://127.0.0.1:6009 # Environment=THUNDER_FROM_ADDRESS= Environment=PAYOUT_MIN_SATS=10000 -Environment=PAYOUT_INTERVAL_MS=30000 +# Payout runs are a daily batch: once every 24h everyone over +# PAYOUT_MIN_SATS goes out in a single Thunder transaction. +Environment=PAYOUT_INTERVAL_MS=86400000 +# ...but a batch already broadcast is re-checked on its own, much shorter +# clock. Nobody in a batch is credited until a tick sees it confirmed in a +# Thunder block, so this must NOT follow the daily cadence. +Environment=PAYOUT_SETTLE_INTERVAL_MS=30000 +# And a tick that tried and got nowhere (transfer failed, or the reserve +# could not cover what is owed) comes back on this one rather than tomorrow. +Environment=PAYOUT_RETRY_INTERVAL_MS=300000 # Admin HTTP surface — used by the dashboard's "Trigger payout now" # button. Loopback-only. Set port=0 to disable. Environment=PAYOUT_ADMIN_BIND=127.0.0.1 diff --git a/docs/simplepool.html b/docs/simplepool.html new file mode 100644 index 0000000..e48cf4d --- /dev/null +++ b/docs/simplepool.html @@ -0,0 +1,1510 @@ + + + + + +simplepool — how it works + + + + + +
+
+
Bitcoin mining infrastructure
+

simplepool

+

+ A single-binary stratum server in pure C11. It hands work to your ASICs, + checks every submission itself, submits found blocks, and records the whole + thing in a SQLite file you are allowed to read. 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 fixed, derivable amount paid out over Thunder. +

+
+ C11 · no runtime dependencies beyond libc, sqlite3, libcurl, hiredis + stratum v1 on :3334 + SQLite (WAL) ledger + MIT +
+
+
+ +
+
+ + + +
+ + +
+

What it is

+

+ A stratum server, a share ledger, and a read-only dashboard. That is the + whole system. +

+ +

+ Miners open a TCP connection to port 3334 and speak stratum v1. + simplepool builds block templates from bitcoind's + getblocktemplate, hands each connection its own job, re-hashes + every submission it receives, and writes each accepted one into + data/shares.db. If a submission also clears the network target, + it goes straight back out via submitblock. +

+

+ There is no account system. There is no password — the stratum password + field is read and discarded. Your identity on the pool is the + payout address you authorize with, which means there is nothing to register, + nothing to log into, and nothing the operator can quietly change about who + you are. +

+ +
+
2payout modes
+
1binary, no daemon zoo
+
1writer to the ledger
+
0accounts to create
+
+ +

Why "share" and not "work unit"

+

+ In solo mode a share is not a claim on anything — the block reward goes to + whoever finds the block, and shares exist for hashrate estimation and + per-rig accountability. The word is kept anyway, deliberately: share + is the term every ASIC firmware, monitoring tool and pool dashboard already + uses, and the same column and table names carry through unchanged into + pps-classic, where shares genuinely are the unit of account. The meaning + shifts between modes; the vocabulary does not. +

+ +
+ The thing this project is actually about +

+ Auditing your own contribution to a mining pool is normally somewhere + between hard and impossible — you are handed a number and asked to trust + it. simplepool writes down enough per share that the number can be + re-derived from scratch by anyone holding a copy of the database, without + trusting the dashboard that reports it. Section 11 + is that argument in SQL. +

+
+
+ + +
+

The two modes

+

+ One config key — pool_mode — decides the shape of the coinbase, + what a stratum username must be, and whether any off-chain accounting + happens at all. +

+ +
+
+

pool_mode = solo the default

+

+ Every block is paid, on-chain, in its own coinbase, to the miner who + found it. Nothing is pooled. If your rig finds the block you get + essentially the whole subsidy plus fees; if it doesn't, nobody on this + pool earns anything at that height. +

+
+
Stratum username
your Bitcoin address, bc1q… or base58
+
Who gets paid
the finder, in the block's coinbase
+
When
immediately, with the block — no payout worker exists
+
Variance
all yours
+
Shares are
a record, not a balance
+
Needs
a bitcoind. Nothing else.
+
+
+ +
+

pool_mode = pps-classic

+

+ Every block's coinbase pays a pool-owned BTC wallet. Every accepted + share credits your balance at a rate derived from the live block + template, whether or not the pool found anything. The operator moves + accumulated BTC into a Thunder reserve, and a payout worker drains that + reserve to miners. +

+
+
Stratum username
a bare base58 Thunder address
+
Who gets paid
every miner, per share
+
When
daily batch, once your balance clears the minimum
+
Variance
the pool's
+
Shares are
the unit of account
+
Needs
bitcoind, the enforcer, a Thunder node
+
+
+
+ +
+ + + + + + + + + + + + + + + + +
 solopps-classic
Coinbase outputsminer's address + operator feepool_btc_address + operator fee
Per-connection coinbaseyes — each miner's cb1/cb2 pay that minerno — every miner's coinbase pays the pool
Off-chain accountingnonepps_credits
Pool custodies BTCneveryes, between mining and deposit
Payout assetBTC, on the mainchainBTC on Thunder, a BIP300 sidechain
Payout workernot installedsimplepool-payout.service
Miner's incomelumpy and rare, but completesmooth and proportional
Who eats bad luckthe minerthe pool operator
+
+ +
+ A third mode existed and was removed +

+ pool_mode = pps put a BIP300 drivechain deposit directly in + each coinbase, so the pool would never custody BTC at all. It does not + work. Regtest and a live forknet both showed the enforcer does not + credit coinbase outputs as deposits: the block confirms, and the + sidechain Ctip never moves — the reward is simply stranded. A canonical + deposit transaction has to spend real, mature, spendable UTXOs, and a + coinbase does not qualify. That is a consensus rule, not a bug, so the + mode was deleted rather than patched. pps-classic is what + every working drivechain pool converges on instead. +

+
+
+ + +
+

The stack

+

+ In solo mode everything to the right of bitcoind is optional. + In pps-classic the enforcer and a Thunder node join the picture, because + that is where miners actually get paid. +

+ +
+
+ + simplepool component topology + Miner ASICs connect over stratum to simplepool, which talks + to bitcoind for block templates and writes accepted shares into a SQLite file. + The dashboard and the Thunder payout worker read that same file; the payout + worker also talks to a Thunder node. + + + + + + + + + + Miner ASICs + stratum v1 + + + simplepool + :3334 + + + bitcoind + (+ enforcer) + + + + + work + GBT + submitblock + + + + shares.db + SQLite · WAL + + one writer + + + + dashboard + read-only · :8081 + + + payout worker + pps-classic only + + + Thunder node + sidechain + + + + + +
+
+ SQLite is the source of truth and simplepool is its only writer; everything + downstream reads. In pps-classic the operator also drives BTC → Thunder + deposits from the admin dashboard through the enforcer's wallet — the one + arrow left off the diagram, because it is a human pressing a button rather + than a running data path. +
+
+ +

+ Optionally, setting redis_url mirrors accepted shares, rejects, + blocks, tip changes and PPS credits onto Redis pub/sub channels + (pool:shares, pool:rejects, pool:blocks, + pool:tip, pool:credits). SQLite stays authoritative; + the publish is fire-and-forget and a Redis outage cannot cost you a share. +

+
+ + +
+

Life of a share

+

+ From plugging in an ASIC to a row in the ledger. Identical in both modes + except where noted. +

+ +
    +
  1. + miner → pool +

    mining.subscribe

    +

    + The pool allocates this connection a 4-byte extranonce1 and + replies with it. The value comes from an atomic counter XORed with the + current millisecond, so two rigs subscribing in the same nanosecond + cannot collide, and a rig reconnecting days later after the counter has + wrapped still gets something fresh. +

    +
  2. +
  3. + miner → pool +

    mining.authorize "<address>[.<rig>]"

    +

    + The username is parsed as an address and validated on the spot — + bech32 or base58check in solo mode, bare base58 Thunder in pps-classic. + An invalid address is rejected with a clear error and written to the + rejects table rather than silently accepted. The password is + discarded. +

    +
  4. +
  5. + pool → miner +

    mining.set_difficulty + mining.notify

    +

    + The connection gets a starting difficulty and the current job. In solo + mode the job's cb1/cb2 are rendered against + this miner's address, so two rigs on the same pool are working on + genuinely different coinbases. The merkle branches, previous hash, nbits + and ntime are shared. +

    +
  6. +
  7. + pool ↔ bitcoind +

    Tip watcher

    +

    + A background thread re-fetches getblocktemplate every + bitcoind_poll_interval_ms (default 30 s). On a new tip the + job is rebuilt and broadcast to every connection with + clean_jobs = true. +

    +
  8. +
  9. + miner → pool +

    mining.submit

    +

    + Carries job_id, the miner's extranonce2, + ntime, nonce, and the exact rolled version bits. + The pool does not take the miner's word for the hash: it reassembles the + coinbase from the cached cb1/cb2 and the two + extranonces, recomputes the merkle root, rebuilds the 80-byte header, and + double-SHA256s it itself. +

    +
  10. +
  11. + pool +

    Two comparisons, one hash

    +

    + The resulting hash is compared against the connection's worker + target and against the network target. Above the + worker target it is rejected as low difficulty and logged in + rejects. Below it, a row lands in shares. Below + the network target as well, it is also a block. +

    +
  12. +
  13. + pool → bitcoind +

    Block submission

    +

    + A block-shaped share is serialised in full and pushed via + submitblock, then recorded in blocks_found with + the height, hash, finder, reward and fee. The same submission counts as a + paid share and as a block — one hash, both thresholds. +

    +
  14. +
  15. + pool +

    Vardiff tick, then the write

    +

    + If the vardiff window has elapsed the connection is retargeted and gets a + fresh mining.set_difficulty. Writes are batched: shares queue + into a lock-free ring and a writer thread commits every + commit_window_ms (100 ms) or every + commit_max_shares (100), whichever comes first. +

    +
  16. +
+ +
+ One detail that trips people up +

+ A mining.set_difficulty does not invalidate the + job you are working on. The difficulty only changes the threshold each + submitted share is measured against; the current mining.notify + stays valid across it, and the pool does not force a re-notify. +

+
+
+ + +
+

Dividing the search space

+

+ The fairness guarantee simplepool makes is narrow and checkable: no two + connections are ever searching the same + (header, coinbase, nonce) triple. +

+ +

+ A block header is 80 bytes, and only three parts of it can vary while you + search: the 4-byte nonce, whichever version bits + the pool has permitted you to roll, and the merkle_root — which + you change indirectly, by changing the coinbase transaction. +

+ +

The 80-byte header

+
+
version4 B · rollable
+
prev_block_hash32 B · fixed
+
merkle_root32 B · via coinbase
+
ntime4 B
+
nbits4 B · network target
+
nonce4 B · the sweep
+
+ +

Where the extranonce lives

+

+ The coinbase scriptSig is assembled at share-check time and + carries both halves of the standard stratum split: +

+
+
height pushBIP34
+
coinbase_tage.g. /simplepool/
+
extranonce14 B · pool assigns
+
extranonce24 B · miner sweeps
+
+

+ assigned once per connection + yours to search +

+ +

+ Together those give each connection 264 distinct + coinbases before it would need to reconnect for a fresh + extranonce1 — effectively unbounded at any real hashrate. Each + extranonce2 value yields a distinct coinbase, therefore a + distinct coinbase txid, therefore a distinct merkle root, therefore a fresh + 232 nonce space to sweep. +

+ +

Version rolling

+

+ If a miner advertises support via mining.configure, the pool + negotiates a version-bit mask — currently 0x1fffe000, the 16 + bits from position 13 to 28. That multiplies the space behind a single + (extranonce1, extranonce2) pair by 216, so one + extranonce2 value covers 232 × 216 = + 248 ≈ 280 trillion headers. +

+

+ The pool never re-derives a rolled version on its own. The miner states the + exact version it hashed, the pool reconstructs that header and + re-hashes it, and any bit flipped outside the mask makes the submission + invalid. +

+ +

Two rigs, one address

+

+ Authorizing as bc1q….basement and bc1q….garage + gives you two connections, hence two different extranonce1 + values, hence no overlapping work — and two separate rows in + workers, so the leaderboard and the per-worker drilldown can + tell your boxes apart while the dashboard still rolls them up by address. +

+
+ + +
+

Difficulty & vardiff

+

+ Every share is measured against two thresholds. One decides whether it + counts; the other decides whether it is a block. +

+ +
+
+

Worker target

+

+ The difficulty the pool is currently holding this connection at, + announced with mining.set_difficulty. A hash at or below it + is an accepted share. It exists so your rig reports in at a sane rate + instead of once a decade. +

+
+
+

Network target

+

+ The real chain difficulty, straight from the block template. A hash at or + below it is a valid block. It is far below any sane worker target, so a + block-finding hash necessarily satisfies the share check too. +

+
+
+ +

Both are 256-bit big-endian numbers, and for a hash h:

+
share accepted  ⇔  h ≤ worker_target
+block found     ⇔  h ≤ network_target
+ +

What "difficulty 0.016" means

+

+ Bitcoin's pdiff-1 target is 0xffff × 2208. A share at + difficulty D is one whose hash is below + pdiff_1 / D, so given a worker target the difficulty recorded on + the share row is simply: +

+
difficulty = pdiff_1_target / worker_target
+ +

Worked example, from a real rig

+
worker_target = 0x000003e7fc18…            (5 leading hex zeros)
+              = 0x03e7fc18 × 2^204
+
+difficulty    = (0xffff × 2^208) / (0x03e7fc18 × 2^204)
+              = 65535 × 16 / 65407512
+              ≈ 0.01603
+

+ That is the number stored in shares.difficulty on every row this + connection produces, and — in pps-classic — the number your credit is + computed from. Hashrate follows from the share rate: +

+
shares_per_second = H / (D × 2^32)
+
+14 shares in a minute at D = 0.016
+  → 0.233 shares/s
+  → H = 0.233 × 0.016 × 2^32 ≈ 16 MH/s
+

+ The dashboard's hashrate column uses exactly this formula over a rolling + window (24 h by default), which is why it is an estimate with visible + variance rather than a reading off your ASIC. +

+ +

Vardiff

+

+ Each connection is retargeted to hold a chosen share rate — 12 shares per + minute by default, roughly one every five seconds. The knobs: +

+
+ + + + + + + + + +
KeyDefaultWhat it does
vardiff_enabled10 pins every connection to initial_diff
vardiff_target_spm12target shares per minute per connection
vardiff_window_sec30how often to retarget
vardiff_min / vardiff_max1 / 1e12clamps
initial_diff1what a connection starts at
+
+
+

+ Vardiff changes the reporting rate, not your expected earnings. Over any + window, difficulty × share count is what you contributed, and holding a rig + at a higher difficulty just means fewer, heavier shares carrying the same + total. +

+
+
+ + +
+

Solo mode

+

+ pool_mode = solo. The whole payout mechanism is the coinbase + transaction. There is no ledger of debts, because the pool never owes anyone + anything. +

+ +

The coinbase

+

+ Every connection gets a coinbase built against its own payout address, so + the block a given rig is hashing on already pays that rig if it lands: +

+
+
output 0 — the findersubsidy + fees, minus fee_bps
+
output 1 — operator_addressfee_bps of the reward · default 1%
+
output 2 — witness commitmentwhen segwit txs are present
+
+

+ With fee_bps = 0 the fee output disappears entirely and the + coinbase is a single payout to the miner. The same happens automatically when + the computed fee would land below the relay dust threshold (~546 sats): the + operator output is dropped rather than made unspendable, and the miner takes + the full reward. +

+ +

What you get, precisely

+
    +
  • Find a block → your address receives ~99% of subsidy + fees, on-chain, in that block, confirmed the moment the block is.
  • +
  • Don't find a block → nothing. Not a smaller amount; nothing. No other miner on the pool earns at that height either.
  • +
  • No inter-miner sharing, no difficulty-weighted accounting, no balance, no withdrawal, no minimum, no pool custody at any point.
  • +
+

+ The shares and workers tables still fill up. They + exist so the dashboard can show a leaderboard, a per-rig drilldown, and the + pool's block history — and so that the data model is already the one + pps-classic needs. A share here is evidence of work, not a claim. +

+ +

Username

+
<bitcoin_address>[.<rig_label>]
+
+bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4
+bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4.basement-rig
+bcrt1q….test.alice                            # regtest
+

+ The address is required and must be valid bech32 (P2WPKH) or base58check + (P2PKH / P2SH) — it is decoded at authorize time, and a typo is rejected + immediately rather than discovered when a block is found and paid to + nowhere. The optional rig_label is alphanumeric plus + _ and -. +

+
+ + +
+

pps-classic mode

+

+ pool_mode = pps-classic. Every accepted share earns a fixed + amount whether or not anyone finds a block. The pool takes the variance; the + miner gets a smooth income stream paid out over Thunder. +

+ +

The value flow, end to end

+
    +
  1. + on-chain +

    The coinbase pays the pool

    +

    + Ordinary output to pool_btc_address for the full + net-of-operator-fee reward, plus the operator fee output. No drivechain + magic — the pool briefly custodies BTC, which is the tradeoff that makes + the rest work at all. +

    +
  2. +
  3. + per share, automatic +

    Each accepted share credits pps_credits

    +

    + accrued_sats += floor(difficulty × rate), written by the C + proxy and by nothing else. Both the credit and the rate that produced it + are stamped onto the share's own row. +

    +
  4. +
  5. + operator, manual +

    BTC is deposited into the Thunder reserve

    +

    + From the admin dashboard: a real + CreateDepositTransaction through the enforcer's wallet, + spending accumulated pool UTXOs into OP_DRIVECHAIN + + OP_RETURN. This does move the Ctip. Each one is recorded in + the deposits table with the txid and the Ctip sequence + before and after. +

    +
  6. +
  7. + payout worker, daily +

    The reserve is drained to miners

    +

    + Everyone whose accrued − paid clears + PAYOUT_MIN_SATS is paid in a single batched Thunder + transaction, once every 24 hours. Section 10 is + the mechanism. +

    +
  8. +
+ +

The rate is derived, not configured

+

+ The obvious way to run PPS is to pick a sats-per-difficulty number and hold + it. simplepool deliberately doesn't: a fixed rate goes stale the moment + difficulty moves, and can quietly invert into paying miners more than each + share is worth. Instead the rate is recomputed from every block template: +

+
gross = coinbasevalue / network_difficulty     # fair value of one diff-1 share
+rate  = gross × (1 − fee_bps / 10000)         # what the pool actually pays
+
+credit_per_share = floor(difficulty × rate)   # truncated to whole sats
+

+ So the rate tracks both block value and difficulty automatically, and + fee_bps is the only fee knob in the system. Every rate the pool + publishes is appended to rate_history together with the template + inputs it came from, which is what makes check 2 + possible. +

+ +
+ Do not set pps_sats_per_diff +

+ It exists only as an escape hatch. A value there is used verbatim and is + treated as already net of fee — so it silently bypasses + fee_bps — and it cannot track difficulty. The proxy logs the + fee your pinned value actually implies and warns when that disagrees with + fee_bps by more than 25 bps. Leave it commented out. +

+
+ +

Username

+
<thunder_base58_address>[.<rig_label>]
+
+JPbJrEKEaA69dAADY2qfW7dfyYQ
+JPbJrEKEaA69dAADY2qfW7dfyYQ.shed-01
+
+ Bare base58 only +

+ The deposit-format wrapper s9_<base58>_<hex6> — + what format-deposit-address hands you — is + rejected at authorize time. Thunder's own OP_RETURN parser + does not recognise it at the byte level, so a miner who accrued a balance + against it would have accrued something unpayable. Failing at connect is + the kind alternative. +

+
+
+ + +
+

Where the fee lands

+

+ fee_bps is one number applied in up to two places, and whether + that is one deduction or two depends entirely on your addresses. +

+ +
+
+

In solo

+

+ One place only: the coinbase splits fee_bps to + operator_address and the rest to the finder. 100 bps = 1%, + capped at 1000 bps = 10%. +

+
+
+

In pps-classic

+

+ Two places: the coinbase splits fee_bps between + operator_address and pool_btc_address, + and the PPS rate is reduced by fee_bps before + anyone is credited. +

+
+
+ +

Which makes the choice of addresses a real economic decision:

+
+ + + + + + + + + + + + + + +
ArrangementEffectConsequence
operator_address == pool_btc_addressthe coinbase split is a no-op — the pool receives the whole block — and the fee is collected once, via the ratethe pool runs with a fee_bps margin over its expected payout. That margin is the buffer that absorbs bad luck.
they differthe operator takes the cut on-chain, per block, before the pool entity sees itthe pool entity runs at break-even in expectation with no buffer, while still carrying full PPS variance. A bad run becomes a shortfall.
+
+

+ Both are coherent; neither is a bug. Pick deliberately, and if you pick the + second one, know that you have separated who collects the fee from who + carries the risk. +

+
+ + +
+

Payouts over Thunder

+

+ pps-classic only. The design goal is narrow and unglamorous: + never pay twice, and never claim to have paid when you haven't. +

+ +

Once a day, in one transaction

+

+ Payouts run as a daily batch. Once every 24 hours, everyone + whose accrued − paid clears PAYOUT_MIN_SATS + (10 000 sats by default) goes out together in a single Thunder + transaction. +

+

+ Batching is not an optimisation, it is a requirement. Thunder only advances + when a mainchain block commits to it, and its wallet cannot spend the change + of an unconfirmed transaction — so paying N miners individually would cost N + sidechain blocks, and past a handful of miners the queue would drain slower + than it fills. The cost of batching is failure isolation: one bad address + fails the whole batch. That is an acceptable trade here, because every + recipient is an address the proxy already validated at authorize time, and a + failed batch credits nobody and strands nobody — the next run simply retries. +

+ +

Three clocks, not one

+

+ The daily cadence governs when a payout starts. It deliberately does + not govern what happens to a batch already in flight, because two of the + states a run can end in are ruined by a long wait: +

+
+ + + + + + + + + + + + +
After a run that…Next tickWhy
did nothing, or settled cleanlyPAYOUT_INTERVAL_MS — 24 hthe ordinary cadence
broadcast a batch, or is still waiting on onePAYOUT_SETTLE_INTERVAL_MS — 30 snobody in the batch is credited until a tick sees it in a Thunder block, and the stall-recovery nudge only fires from a tick
failed to broadcast, or found the reserve shortPAYOUT_RETRY_INTERVAL_MS — 5 mnothing was sent and nobody was credited, so the run did not happen — it is retried, not skipped to tomorrow
could not determine a settlement5 m, and loudlyterminal until a human reconciles it
+
+

+ To pay out early, the admin dashboard has a Trigger payout now + button. Restarting the worker also runs one immediately. +

+ +

paid means mined, not sent

+

+ pps_credits.paid_sats moves only when a transaction has actually + been observed in a block. Crediting at broadcast was tried and abandoned: a + transaction sitting in a mempool has discharged no debt, so counting it as + paid makes accrued − paid understate what the pool really owes — + measured at 265 BTC for over four hours on a test network — and leaves no way + back if the transaction never lands. +

+

+ Telling "confirmed" from "gone" is the hard part, because Thunder offers no + single durable answer. Two sources are consulted and only positive + evidence from either is accepted: get_transaction reporting a + block hash (authoritative but transient — it reads back as + null once the chain moves past it), and the wallet UTXO set + containing an outpoint bearing our txid (durable, because Thunder only + admits confirmed UTXOs). Absence is never read as confirmation, and never as + eviction either: "the node forgot it" and "it confirmed a while ago" look + identical from outside, and guessing wrong in one direction pays twice. So + unknown stays unknown, payouts halt, and a human is asked. +

+ +

The at-most-once protocol

+
    +
  1. + write-ahead +

    INSERT INTO payouts_in_flight

    +

    One row per worker in the batch, txid = ''. From this moment + listDue() skips those workers, so nothing can queue them twice.

    +
  2. +
  3. + network +

    Broadcast the batch

    +

    One Thunder transaction for everyone. On failure the rows are removed, + paid_sats is untouched, and the next run tries again.

    +
  4. +
  5. + local +

    Stamp the txid — and stop

    +

    The rows stay in flight. Nobody is credited here. A broadcast is not a + settlement.

    +
  6. +
  7. + a later tick +

    Confirmed → one atomic transaction

    +

    paid_sats += for every worker in the batch and the in-flight + rows are deleted, together, in a single SQLite transaction. It commits + whole or not at all — there is no partial credit across a batch.

    +
  8. +
+ +

+ The one genuinely ambiguous state is a crash between steps 1 and 2: a + broadcast that happened is indistinguishable from one that did not. Those + rows are reported by listStuck() at every start and left for an + operator to resolve, because the two possibilities demand opposite actions + and nothing on the machine can tell them apart. +

+
+ + +
+

Auditing every number

+

+ The point of the data model. These checks run against a copy of + shares.db and consult nothing live — no API, no dashboard, no + trust in the operator. +

+ +

What is written down per share

+

+ Each accepted share row carries the difficulty it was measured at, the rate + in force when it was accepted (rate_used), and the sats it was + credited (credited_sats). Storing the multiplicand alongside the + product is the whole trick: the credit can be re-derived years later without + knowing what the rate happened to be at the time, and without asking the + pool. +

+ +

Four queries

+
-- 1. Arithmetic. Every credited share must re-derive from the pair stored
+--    on its own row. Nothing current is consulted.
+SELECT COUNT(*) FROM shares
+ WHERE rate_used > 0
+   AND credited_sats <> CAST(difficulty * rate_used AS INTEGER);
+
+-- 2. Provenance. Every rate the pool published must follow from the template
+--    inputs recorded beside it. Catches a rate applied consistently but
+--    derived wrongly — which (1) cannot see.
+SELECT COUNT(*) FROM rate_history
+ WHERE ABS(rate_sats_per_diff
+       - (block_value_sats * 1.0 / network_difficulty)
+         * (1 - fee_bps / 10000.0)) > 1e-9;
+
+-- 3. Linkage. No share may be credited at a rate the pool never published.
+SELECT COUNT(*) FROM shares s
+ WHERE s.rate_used > 0
+   AND s.ts >= (SELECT MIN(ts) FROM rate_history)
+   AND NOT EXISTS (SELECT 1 FROM rate_history r
+                    WHERE r.rate_sats_per_diff = s.rate_used);
+
+-- 4. Solvency. What the pool mined must cover what it owes.
+SELECT (SELECT SUM(reward_sats) + SUM(fee_sats) FROM blocks_found)
+     - (SELECT SUM(credited_sats) FROM shares) AS margin_sats;
+ +

+ The first three must return 0. Query 4 should be positive, + and close to Σ difficulty × gross × fee_bps/10000 once luck is + accounted for — a negative result means the pool cannot pay out of what it + has earned, which is the number that actually matters. +

+

+ Exact equality in check 1 is the right test rather than a tolerance: the + proxy is built without -ffast-math, so SQLite reproduces the + same IEEE-754 multiply and truncation bit for bit. Shares accepted before + rate_used existed carry 0 and are excluded from checks 1 and 3 — + their credited_sats is still authoritative, there is simply no + stored multiplicand to check it against, and the audit page reports them as + unverifiable rather than as failures. +

+ +

Luck, quantified

+
SELECT ROUND((SELECT SUM(difficulty) FROM shares)
+             / (SELECT network_difficulty FROM pool_meta)) AS expected_blocks,
+       (SELECT COUNT(*) FROM blocks_found)                 AS actual_blocks;
+ +

Block-withholding audit

+

+ A miner can hash honestly, submit every share, and quietly discard the one + submission that happens to be a block — collecting PPS credit while + contributing nothing. payout/audit.js is a standalone read-only + CLI that looks for it: over a window, each worker's expected block count is + pool_blocks × (worker_diff / pool_diff), and + z = (expected − actual) / √expected. It flags a worker when + expected ≥ 5 and z ≥ 3 — about a 1-in-740 false + positive rate under honest Poisson sampling. No schema changes; safe to run + while the proxy is writing. +

+ +
+ Why it can be run by anyone +

+ SQLite runs in WAL mode with exactly one writer. Take a snapshot with + sqlite3 shares.db ".backup snap.db" — atomic, and safe while + the pool is writing — and every query above works on the copy. A plain + cp of a WAL database is not safe; use + .backup. +

+
+
+ + +
+

The data model

+

+ One SQLite file, data/shares.db, in WAL mode. The proxy is the + only writer; the dashboard and the audit tools only read. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TableWritten byWhat it holds
workersproxyone row per address[.rig] seen, with the payout address kept separately so the dashboard can roll up across rigs
sharesproxyone row per accepted share: worker, timestamp, difficulty, hash, is_block, and in pps-classic credited_sats + rate_used
rejectsproxyone row per rejected submission with the reason — bad address, stale job, low difficulty
blocks_foundproxyheight, hash, finder, finder address, reward_sats, fee_sats
rate_historyproxyevery PPS rate published, with the template inputs it was derived from — the basis of audit check 2
pool_metaproxythe effective rate and network difficulty. The dashboard reads the rate from here rather than from its own config, so an audit can never disagree with the process that did the crediting
templatesproxyone row per materially distinct block template, pruned by templates_retention_days
node_statusproxybackend height and tip, for the dashboard's node card
pps_creditsproxy and payout workeraccrued_sats (proxy only, monotonic) and paid_sats (payout worker only, monotonic). Owed = the difference
payouts_in_flightpayout workerthe write-ahead log that makes payouts at-most-once
payouts, tx_attemptspayout workersettled payouts, and every transaction attempt with its stage and raw bytes for forensics
depositsdashboardone row per operator-triggered BTC → Thunder deposit, with Ctip sequence before and after
+
+ +
+ Invariants held by code, not by constraints +

+ accrued_sats and paid_sats must both only ever + increase, and each has exactly one writer. A decrease in either means + somebody edited the database by hand — which is worth knowing, and is why + it is stated here rather than enforced by a trigger that would hide it. +

+
+
+ + +
+

Connect a miner

+

+ There is nothing to sign up for. Point the ASIC at the host and put your + address in the username field. +

+ +
+
+

Solo

+
URL       stratum+tcp://pool.example.com:3334
+Worker    bc1qw508d6…kv8f3t4.rig-01
+Password  (anything — it is discarded)
+
+
+

pps-classic

+
URL       stratum+tcp://pool.example.com:3334
+Worker    JPbJrEKEaA69dAADY2qfW7dfyYQ.rig-01
+Password  (anything — it is discarded)
+
+
+ +

+ Stratum is raw TCP, not HTTP, so it does not pass through the pool's nginx. + Miners connect straight to host:3334; only the dashboard is + behind the reverse proxy. If you need TLS on stratum itself, that is an + nginx stream {} block or stunnel, not something the + pool does for you. +

+ +

If a connection is refused

+
+ + + + + + + + + + + + +
SymptomCause
Authorize fails immediatelyThe username isn't a valid address for this mode — a BTC address on a pps-classic pool, a Thunder address on a solo pool, or the s9_…_… deposit wrapper. Check the rejects table for the reason.
Shares rejected as low difficultyNormal in small numbers. Persistent means the rig is ignoring mining.set_difficulty.
Shares rejected as stale shareThe job expired — a new tip arrived. Expected around block boundaries.
Connects, no workThe pool has no template: its bitcoind is unreachable or still syncing.
+
+
+ + +
+

Configuration

+

+ One key = value file, proxy.conf. The keys that + change behaviour rather than tuning it: +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyDefaultMeaning
pool_modesolosolo or pps-classic. Decides the coinbase shape and what a username must be.
operator_addressRequired. Receives the fee_bps cut. The proxy refuses to start without it.
fee_bps100Fee in basis points; 100 = 1%, hard cap 1000 = 10%. 0 drops the fee output entirely.
pool_btc_addresspps-classic only, and required there: the coinbase pays here.
pps_sats_per_diffunsetLeave it unset. See the warning in section 8.
listen_addr / listen_port0.0.0.0 / 3334Where stratum listens.
bitcoind_urlJSON-RPC endpoint for getblocktemplate / submitblock.
bitcoind_user / bitcoind_passOptional — omit both for an unauthenticated backend and the call goes out with no auth header. Cookie auth is not supported.
bitcoind_poll_interval_ms30000Template refresh. On a drivechain pool this is also the worst-case delay before a sidechain's BMM request can reach a job — lower it to 5000–10000 if sidechains need to merge-mine reliably.
coinbase_tag/simplepool/Short string baked into the coinbase scriptSig.
db_path./data/shares.dbThe ledger.
commit_window_ms / commit_max_shares100 / 100Write batching — commit on whichever comes first.
templates_retention_days30How much template history the dashboard keeps. 0 keeps everything.
redis_urlemptySet to mirror events onto Redis pub/sub. Empty disables it.
log_levelinfodebug logs every RPC request and raw response.
+
+

+ The payout worker is configured entirely by environment variables, not by + this file — PAYOUT_INTERVAL_MS, + PAYOUT_SETTLE_INTERVAL_MS, PAYOUT_MIN_SATS, + THUNDER_FROM_ADDRESS and friends. The Thunder reserve address is + deliberately not a proxy key: the coinbase never touches Thunder, so + only the dashboard and the payout worker have any business knowing it. +

+
+ + +
+

Running one

+

+ One line on a fresh Ubuntu or Debian server, then a single command for + everything after. +

+ +
curl -fsSL https://raw.githubusercontent.com/LayerTwo-Labs/simplepool/main/scripts/install.sh | sudo bash
+ +

+ The installer downloads the published build for the machine's architecture, + verifies it against the release SHA256SUMS, then asks for the + pool mode, your bitcoind RPC, your addresses, a dashboard domain and admin + password, and whether to set up nginx, TLS and the firewall. It writes + proxy.conf, loads the schema, installs three systemd units, and + tells you what miners should connect to. Every answer is saved, so re-running + it is how you change your mind about any of them. Pass + --from-source to clone and compile instead. +

+ +
simplepoolctl status            # services, ports, versions, ledger totals
+simplepoolctl doctor            # binary runs? bitcoind answers? DB writable?
+simplepoolctl logs payout -f    # one service, or all of them
+sudo simplepoolctl restart proxy
+sudo simplepoolctl upgrade      # next release, then restart
+sudo simplepoolctl uninstall    # --purge also deletes the ledger
+ +

What runs

+
+ + + + + + + +
UnitModeJob
simplepool.serviceboththe stratum proxy — the only thing that is strictly required
simplepool-dashboard.servicebothread-only public stats on :8081, plus /admin behind basic auth
simplepool-payout.servicepps-classicthe daily Thunder payout batch
+
+ +

Which commit is running

+

+ The build commit is compiled into the binary, so simplepool + --version reports what is actually executing rather than what + the source tree next to it currently says. A build from a tree with + uncommitted changes says so on its own line, because in that case the commit + printed above it does not describe the binary. The dashboard's + /api/versions answers the same question for the whole stack — + simplepool, the enforcer, Thunder, bitcoind — over plain HTTP with no auth. +

+
+ + +
+

What it can't do

+

+ Stated plainly, because a pool that only advertises its guarantees is + telling you half the story. +

+ +
    +
  • + It cannot prove a miner hashed anything. It can only check + the work it was shown. A rig that finds a block and drops it on the floor + looks identical to an unlucky one on any individual sample — which is why + the withholding audit is statistical, and why it needs + an expectation of at least five blocks before it will say anything. +
  • +
  • + In pps-classic the pool custodies BTC. Between mining a + block and depositing into Thunder, the reward sits in a pool-controlled + wallet. The design that avoided this — a drivechain deposit in the coinbase + — does not work at the consensus level. This is a real trust assumption and + it is not engineered away. +
  • +
  • + Deposits are manual. An operator presses a button per + deposit. If nobody does, the reserve runs dry and payouts skip with a + logged warning until someone notices. +
  • +
  • + An ambiguous crash needs a human. A crash between writing + the in-flight rows and broadcasting leaves a state where "it was sent" and + "it wasn't" are indistinguishable. The worker refuses to guess, halts, and + says so. +
  • +
  • + The Thunder payout fee is a flat 100 sats per batch for + now, pending observable fee dynamics on that chain. +
  • +
  • + Solo mode is solo. If your rig doesn't find the block, you + earn nothing for that height. That is the design, not a shortfall. +
  • +
+
+ +
+
+
+ +
+
+

+ simplepool — a solo and PPS Bitcoin mining pool in C11, by + Roberto Santacroce. MIT licensed. +

+

+ This page is a single self-contained HTML file with no scripts and no + external assets; save it, serve it, or print it. The authoritative + documents it summarises live in the repository: + README.md (overview), + INSTALL.md (installation), + NONCE_AND_SHARES.md (share and payout math), + CLASSIC_PAYOUTS.md (the pps-classic design and the + coinbase-deposit finding), + OPERATOR_GUIDE.md (day-to-day operations) and + VERIFY.md (the verification checklist). + Where this page and those disagree, they are right. +

+
+
+ + + diff --git a/payout/README.md b/payout/README.md index 05132e5..5931e01 100644 --- a/payout/README.md +++ b/payout/README.md @@ -36,7 +36,9 @@ PAYOUT_DRY_RUN=1 PAYOUT_DB_PATH=../data/shares.db \ | `THUNDER_RPC_URL` | yes | — | Thunder JSON-RPC endpoint, e.g. `http://127.0.0.1:6009` | | `THUNDER_FROM_ADDRESS` | yes | — | pool reserve address; must equal the dashboard's `POOL_THUNDER_RESERVE_ADDRESS` | | `THUNDER_RPC_USER` / `THUNDER_RPC_PASS` | no | — | basic-auth if your Thunder node has it (default Thunder build has none) | -| `PAYOUT_INTERVAL_MS` | no | 30000 | how often to scan | +| `PAYOUT_INTERVAL_MS` | no | 86400000 (24h) | how often a payout run starts — the batch cadence miners see | +| `PAYOUT_SETTLE_INTERVAL_MS` | no | 30000 | how often an already-broadcast batch is re-checked while it waits for a Thunder block | +| `PAYOUT_RETRY_INTERVAL_MS` | no | 300000 | how long to wait after a tick that tried and got nowhere (transfer failed / reserve short) | | `PAYOUT_MIN_SATS` | no | 10000 | skip workers below this owed balance | | `PAYOUT_MAX_PER_TICK` | no | 50 | cap workers paid per scan | | `PAYOUT_DRY_RUN` | no | — | `1` = log only | @@ -64,6 +66,30 @@ address fails the whole batch. That is the right trade — every recipient is an address the proxy validated at authorize time, and a failed batch credits nobody and strands nobody. +## Three clocks, not one + +Payouts run **once a day**. That is `PAYOUT_INTERVAL_MS`, and it is the only +cadence a miner ever sees: a single batched transaction every 24h paying +everyone over `PAYOUT_MIN_SATS`. + +The daily interval deliberately does not govern what happens to a batch that +has already gone out, because two of the states a tick can end in are ruined +by a long wait: + +| after a tick that… | next tick in | why | +| --- | --- | --- | +| did nothing, or settled a batch cleanly | `PAYOUT_INTERVAL_MS` (24h) | the ordinary daily cadence | +| broadcast a batch, or is still waiting on one | `PAYOUT_SETTLE_INTERVAL_MS` (30s) | nobody in the batch is credited until a tick sees it in a Thunder block, and the stall-recovery nudge only fires from a tick | +| failed to broadcast, or found the reserve short | `PAYOUT_RETRY_INTERVAL_MS` (5m) | nothing was sent and nobody was credited — the run did not happen, so it is retried rather than skipped to tomorrow | +| could not determine a settlement | `PAYOUT_RETRY_INTERVAL_MS` (5m) | terminal until an operator reconciles; re-logging it every 30s for a day buries everything else | + +`nextDelayMs()` in [lib/payout.js](lib/payout.js) is the whole decision, and +[test/cadence.test.js](test/cadence.test.js) pins each row of that table. + +To force a run without waiting for the next one, use the dashboard's +**Trigger payout now** button (or `POST /payout/run` on the worker's admin +HTTP surface) — restarting the service also ticks immediately. + ## `paid` means mined, not sent `pps_credits.paid_sats` moves only when a transaction has been observed in a diff --git a/payout/index.js b/payout/index.js index aac4c70..d7508e8 100644 --- a/payout/index.js +++ b/payout/index.js @@ -19,7 +19,7 @@ import { loadConfig } from './lib/config.js'; import { openDb } from './lib/db.js'; import { ThunderClient } from './lib/thunder.js'; -import { startLoop, reportStuck } from './lib/payout.js'; +import { startLoop, reportStuck, humanMs } from './lib/payout.js'; import { startAdminHttp } from './lib/admin-http.js'; const cfg = loadConfig(); @@ -33,8 +33,10 @@ const log = { log.info(`simplepool-payout starting (db=${cfg.dbPath} rpc=${cfg.rpcUrl}` + `${cfg.dryRun ? ' DRY-RUN' : ''})`); -log.info(` interval=${cfg.intervalMs}ms min_sats=${cfg.minSats} ` + - `max_per_tick=${cfg.maxPerTick}`); +log.info(` payout run every ${humanMs(cfg.intervalMs)} ` + + `(settle re-check ${humanMs(cfg.settleIntervalMs)}, ` + + `retry ${humanMs(cfg.retryIntervalMs)})`); +log.info(` min_sats=${cfg.minSats} max_per_tick=${cfg.maxPerTick}`); const db = openDb(cfg.dbPath); const thunder = new ThunderClient({ diff --git a/payout/lib/config.js b/payout/lib/config.js index 1f7248e..becd9a5 100644 --- a/payout/lib/config.js +++ b/payout/lib/config.js @@ -7,7 +7,24 @@ * http://127.0.0.1:6000) * * Optional: - * PAYOUT_INTERVAL_MS how often to scan for due payouts (default 30s) + * PAYOUT_INTERVAL_MS how often to start a payout run (default 24h). + * This is the batch cadence miners see: once a day + * everyone over PAYOUT_MIN_SATS goes out in one + * transaction. It deliberately does NOT govern what + * happens to a batch already broadcast — see + * PAYOUT_SETTLE_INTERVAL_MS. + * PAYOUT_SETTLE_INTERVAL_MS + * how often to re-check a broadcast batch that has + * not confirmed yet (default 30s). Nobody in a batch + * is credited until a tick sees it in a Thunder + * block, so this has to stay short even when the + * payout cadence is daily. + * PAYOUT_RETRY_INTERVAL_MS + * how long to wait after a tick that tried and got + * nowhere — transfer failed, or the reserve could + * not cover what is owed (default 5m). Nothing was + * broadcast and nobody was credited, so waiting a + * full day to try again would strand the queue. * PAYOUT_MIN_SATS skip workers below this owed balance (default 10000) * PAYOUT_MAX_PER_TICK cap workers paid per scan (default 50) to bound * tail latency and Thunder RPC load @@ -56,7 +73,11 @@ export function loadConfig() { rpcUser: process.env.THUNDER_RPC_USER || null, rpcPass: process.env.THUNDER_RPC_PASS || null, fromAddress: require_env('THUNDER_FROM_ADDRESS'), - intervalMs: parseInt(process.env.PAYOUT_INTERVAL_MS || '30000', 10), + /* Daily batch cadence. Settlement and retry run on their own, + * much shorter clocks — see nextDelayMs() in payout.js. */ + intervalMs: parseInt(process.env.PAYOUT_INTERVAL_MS || '86400000', 10), + settleIntervalMs: parseInt(process.env.PAYOUT_SETTLE_INTERVAL_MS || '30000', 10), + retryIntervalMs: parseInt(process.env.PAYOUT_RETRY_INTERVAL_MS || '300000', 10), minSats: BigInt(process.env.PAYOUT_MIN_SATS || '10000'), maxPerTick: parseInt(process.env.PAYOUT_MAX_PER_TICK || '50', 10), dryRun: process.env.PAYOUT_DRY_RUN === '1', diff --git a/payout/lib/payout.js b/payout/lib/payout.js index e4f5e6d..b662714 100644 --- a/payout/lib/payout.js +++ b/payout/lib/payout.js @@ -353,18 +353,95 @@ export function reportStuck(ctx, log, staleAfterSec = 300) { } } +/* How long to wait before the next tick, given what this one did. + * + * The payout run itself is a daily batch — that is the cadence miners see, + * and it is what `intervalMs` means. But two of the states a tick can end in + * must not wait a day, and both are invisible from the interval alone: + * + * - A batch was broadcast and has not confirmed. Nobody in it is credited + * until a later tick sees it in a Thunder block (see settlePending), and + * the stall-recovery nudge only fires from a tick. Re-checking on the + * daily clock would leave a real, already-sent payout uncredited for up + * to 24 hours and would let a missed BMM request sit unrecovered for the + * same. So an outstanding batch is re-checked on `settleIntervalMs`. + * + * - A tick tried and got nowhere: the transfer failed, or the reserve did + * not cover what is owed. Nothing was broadcast and nobody was credited, + * so this is not a completed run and the queue is still full. It comes + * back on `retryIntervalMs` rather than tomorrow — long enough not to + * spin on a stuck reserve, short enough that a transient RPC failure + * doesn't cost a day. + * + * An undetermined settlement is deliberately grouped with the retries: it is + * terminal until an operator reconciles it, and re-logging that at the + * settle cadence would be pure noise. + * + * Everything else — nothing due, or a batch that settled cleanly — waits the + * full interval. */ +export function nextDelayMs(cfg, res) { + if (res?.reason === 'undetermined') return cfg.retryIntervalMs; + if (res?.waiting_on || res?.txid) return cfg.settleIntervalMs; + if (res?.failed > 0 || res?.reserve_short) return cfg.retryIntervalMs; + return cfg.intervalMs; +} + +/* setTimeout keeps its delay in a signed 32-bit int. Anything larger wraps + * and the timer fires IMMEDIATELY — so a config asking for, say, monthly + * payouts would not slow the loop down, it would turn it into a spin that + * broadcasts on every tick. Long waits are therefore served in chunks. */ +const MAX_TIMEOUT_MS = 2_147_483_647; /* ~24.8 days */ + +/* One hop of a possibly-too-long wait: what to hand setTimeout now, and what + * is still owed afterwards. Pulled out so the clamp is testable without a + * timer. */ +export function timerStep(ms) { + return ms > MAX_TIMEOUT_MS + ? { wait: MAX_TIMEOUT_MS, remaining: ms - MAX_TIMEOUT_MS } + : { wait: ms, remaining: 0 }; +} + +export const humanMs = ms => + ms >= 3600000 ? `${+(ms / 3600000).toFixed(2)}h` + : ms >= 60000 ? `${+(ms / 60000).toFixed(2)}m` + : `${+(ms / 1000).toFixed(2)}s`; + export function startLoop(ctx, log) { let stopped = false; let timer = null; const tick = async () => { if (stopped) return; + let res = null; try { - await runOnce(ctx, log); + res = await runOnce(ctx, log); } catch (e) { log.error(`payout: unexpected error: ${e.stack || e.message}`); + /* An exception is not a completed run: come back on the retry + * clock rather than sleeping off the whole daily interval. */ + res = { failed: 1 }; } - if (!stopped) timer = setTimeout(tick, ctx.cfg.intervalMs); + if (!stopped) { + const delay = nextDelayMs(ctx.cfg, res); + /* Only worth a line when it isn't the ordinary cadence — that is + * exactly when an operator wondering "why hasn't it paid yet" + * needs to see which clock the worker is on. */ + if (delay !== ctx.cfg.intervalMs) { + log.info(`payout: next tick in ${humanMs(delay)}`); + } else { + log.debug?.(`payout: next run in ${humanMs(delay)}`); + } + arm(delay); + } + }; + + /* Schedule `tick` in at most MAX_TIMEOUT_MS hops, so a long interval is + * actually waited out rather than wrapping to zero. */ + const arm = (ms) => { + const { wait, remaining } = timerStep(ms); + timer = remaining > 0 + ? setTimeout(() => { if (!stopped) arm(remaining); }, wait) + : setTimeout(tick, wait); }; tick(); diff --git a/payout/test/cadence.test.js b/payout/test/cadence.test.js new file mode 100644 index 0000000..5e73931 --- /dev/null +++ b/payout/test/cadence.test.js @@ -0,0 +1,91 @@ +/* Which clock the loop comes back on. + * + * The payout run is a daily batch, so `intervalMs` is 24h — but a tick can + * end in states where waiting a day is wrong, and each one is invisible from + * the interval alone. The bug this guards against is the obvious one: set + * PAYOUT_INTERVAL_MS=24h, and a batch that was broadcast but has not + * confirmed sits uncredited until tomorrow, because settlePending() only runs + * from a tick. Same for a transfer that failed — nobody was paid and nobody + * was credited, and the queue just waits. + * + * nextDelayMs() is the whole decision, pulled out of the timer so it can be + * asserted without one. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { nextDelayMs, humanMs, timerStep } from '../lib/payout.js'; + +const cfg = { intervalMs: 86_400_000, settleIntervalMs: 30_000, retryIntervalMs: 300_000 }; + +test('a quiet tick waits the full daily interval', () => { + assert.equal(nextDelayMs(cfg, { attempted: 0, paid: 0, failed: 0, settled: 0 }), + cfg.intervalMs); +}); + +test('a batch that settled cleanly waits the full daily interval', () => { + assert.equal(nextDelayMs(cfg, { attempted: 0, paid: 0, failed: 0, settled: 3 }), + cfg.intervalMs); +}); + +test('a freshly broadcast batch comes back on the settle clock', () => { + /* runOnce returns the txid it just broadcast. Nobody in that batch is + * credited until a later tick sees it in a block. */ + const res = { attempted: 2, paid: 0, broadcast: 2, failed: 0, settled: 0, txid: 'ab12' }; + assert.equal(nextDelayMs(cfg, res), cfg.settleIntervalMs); +}); + +test('an unconfirmed batch comes back on the settle clock', () => { + const res = { attempted: 0, paid: 0, failed: 0, settled: 0, + waiting_on: 'ab12', reason: 'unconfirmed' }; + assert.equal(nextDelayMs(cfg, res), cfg.settleIntervalMs); +}); + +test('a failed transfer retries well before tomorrow', () => { + const res = { attempted: 2, paid: 0, failed: 2, settled: 0 }; + assert.equal(nextDelayMs(cfg, res), cfg.retryIntervalMs); +}); + +test('a short reserve retries well before tomorrow', () => { + const res = { attempted: 0, paid: 0, failed: 0, settled: 0, reserve_short: true }; + assert.equal(nextDelayMs(cfg, res), cfg.retryIntervalMs); +}); + +test('an undetermined settlement backs off to the retry clock, not the settle clock', () => { + /* Terminal until an operator reconciles it — re-logging the CANNOT + * DETERMINE error every 30s for a day would bury everything else. */ + const res = { attempted: 0, paid: 0, failed: 0, settled: 0, + waiting_on: 'ab12', reason: 'undetermined' }; + assert.equal(nextDelayMs(cfg, res), cfg.retryIntervalMs); +}); + +test('a thrown tick is treated as a failure, not as a completed run', () => { + /* startLoop synthesises { failed: 1 } from the catch block. */ + assert.equal(nextDelayMs(cfg, { failed: 1 }), cfg.retryIntervalMs); +}); + +test('humanMs renders each scale the operator actually sees', () => { + assert.equal(humanMs(30_000), '30s'); + assert.equal(humanMs(300_000), '5m'); + assert.equal(humanMs(86_400_000), '24h'); +}); + +test('an ordinary delay is handed to setTimeout whole', () => { + assert.deepEqual(timerStep(86_400_000), { wait: 86_400_000, remaining: 0 }); + assert.deepEqual(timerStep(30_000), { wait: 30_000, remaining: 0 }); +}); + +test('a delay past the 32-bit timer ceiling is served in hops', () => { + /* setTimeout keeps its delay in a signed 32-bit int, so anything larger + * wraps and fires immediately — a monthly cadence would become a spin + * that broadcasts every tick rather than once a month. */ + const MAX = 2_147_483_647; + const monthly = 30 * 24 * 3600 * 1000; // 2,592,000,000 > MAX + const first = timerStep(monthly); + assert.equal(first.wait, MAX); + assert.equal(first.remaining, monthly - MAX); + + // and the remainder finishes in one more hop, totalling the full wait + const second = timerStep(first.remaining); + assert.equal(second.remaining, 0); + assert.equal(first.wait + second.wait, monthly); +}); diff --git a/scripts/install.sh b/scripts/install.sh index fe85d86..6027044 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,18 +1,35 @@ #!/usr/bin/env bash # # simplepool installer — run this ON the Linux server you want the pool -# to live on. Interactive by default: it asks for the install directory, -# service user, pool mode (solo / pps-classic), bitcoind RPC, -# addresses, dashboard domain, admin password, nginx/TLS and firewall, -# shows a summary, then does the whole install. +# to live on. Interactive by default: it asks where to install, which pool +# mode (solo / pps-classic), your bitcoind RPC, addresses, dashboard domain, +# admin password, nginx/TLS and firewall, shows a summary, then does the +# whole install and tells you what miners should connect to. # -# Sibling script: scripts/deploy-to-server.sh drives an *already -# installed* box from your workstation (git pull + rebuild + restart). -# This one bootstraps a fresh box from nothing. +# One line on a fresh Ubuntu/Debian box: +# +# curl -fsSL https://raw.githubusercontent.com/LayerTwo-Labs/simplepool/main/scripts/install.sh | sudo bash +# +# Two ways to get the code, asked during the interview: +# +# release (default) download the published tarball for this machine's +# architecture, check it against the release SHA256SUMS, unpack it. +# No compiler, no git clone, no waiting on a build. +# source git clone + `make`. What you want to run an unreleased branch, +# or on an architecture with no published build. +# +# Either way the result is the same tree at $ROOT, so everything after that +# step — config, database, systemd, nginx — is one code path. +# +# Afterwards, `simplepoolctl` (installed to /usr/local/bin) is how you drive +# the box: status, logs, doctor, upgrade, uninstall. +# +# Sibling script: scripts/deploy-to-server.sh drives an *already installed* +# box from your workstation. This one bootstraps a fresh box from nothing. # # Usage: # sudo ./scripts/install.sh # ask me everything -# curl -fsSL /install.sh | sudo bash # standalone; clones the repo +# curl -fsSL /install.sh | sudo bash # standalone # # Non-interactive (CI / re-runs) — every prompt has a matching flag: # sudo ./scripts/install.sh --non-interactive --yes \ @@ -23,6 +40,9 @@ # --hostname pool.example.com --admin-user admin # # Flags: +# --from-release [] install a published release (default; omit the +# tag for the latest) +# --from-source git clone + make instead # --root install directory (default /home/simplepool) # --user service user (default simplepool) # --repo --branch source to clone/update (default upstream/main) @@ -36,6 +56,7 @@ # deposits + payout worker source) # --thunder-rpc-url default http://127.0.0.1:6009 # --pps-sats-per-diff default 1000 +# --payout-interval-hours how often payouts run (default 24) # --hostname dashboard domain (nginx vhost + TLS) # --dashboard-port default 8081 (loopback; nginx fronts it) # --admin-user default admin @@ -43,7 +64,7 @@ # --tls --email run certbot --nginx after the vhost lands # --no-dashboard --no-payout --no-nginx --no-firewall --no-deps # --enable-firewall `ufw enable` (OpenSSH is always allowed first) -# --run-tests run `make test` after the build +# --run-tests run `make test` after the build (source installs) # --non-interactive never prompt; use flags + saved answers # --yes skip the final confirmation # @@ -59,8 +80,46 @@ set -euo pipefail exit 1 } -SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" +# The headline install path is `curl ... | sudo bash`, where there is no file +# on disk to point at: BASH_SOURCE[0] is not a readable path, so re-execing +# ourselves and reading our own comment block for --help both have to be +# handled rather than assumed away. +if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then + SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" + PIPED=0 +else + SELF="" + PIPED=1 +fi + +usage() { + if [[ "$PIPED" == "0" ]]; then + sed -n '2,73p' "$SELF" | sed 's/^# \{0,1\}//' + else + # Piped: our source is gone from stdin, so there is nothing to read + # back. Point at the copy that definitely exists. + cat <<'USAGE' +simplepool installer. + + curl -fsSL /install.sh | sudo bash # interactive + curl -fsSL /install.sh | sudo bash -s -- --help # you are here + +Flags can be passed after `-s --`. The full list is in the script's header: + https://github.com/LayerTwo-Labs/simplepool/blob/main/scripts/install.sh +and the walkthrough is in INSTALL.md. +USAGE + fi +} + if [[ $EUID -ne 0 ]]; then + if [[ "$PIPED" == "1" ]]; then + # stdin is already consumed, so we cannot re-exec ourselves. Say what + # to type instead of failing somewhere deeper with a permissions error. + echo "install.sh needs root, and a piped script cannot re-run itself." >&2 + echo "Pipe into 'sudo bash', not 'bash':" >&2 + echo " curl -fsSL /install.sh | sudo bash" >&2 + exit 1 + fi command -v sudo >/dev/null 2>&1 || { echo "run as root (no sudo found)" >&2; exit 1; } echo "==> re-executing under sudo" exec sudo -E bash "$SELF" "$@" @@ -83,8 +142,18 @@ die() { echo "${RED}fatal: $*${OFF}" >&2; exit 1; } # ------------------------------------------------------------- defaults ----- ROOT="" SVC_USER="" -REPO_URL="https://github.com/rsantacroce/simplepool.git" +# The canonical repo. Releases, container images and CI all live here; the +# author's personal remote is a mirror and has no published releases, so +# defaulting to it would make --from-release fail on a fresh box. +REPO_SLUG="LayerTwo-Labs/simplepool" +REPO_URL="https://github.com/${REPO_SLUG}.git" BRANCH="main" +# release = download a published tarball; source = git clone + make. +# Left empty so the interview can pick a context-aware default (a checkout +# you are standing in means source; a piped one-liner means release) without +# overwriting a saved answer or a flag. +SOURCE="" +RELEASE_TAG="" # empty = whatever the latest release is MODE="" STRATUM_PORT="3334" BITCOIND_URL="" @@ -97,6 +166,7 @@ POOL_BTC_ADDRESS="" THUNDER_ADDRESS="" THUNDER_RPC_URL="http://127.0.0.1:6009" PPS_SATS_PER_DIFF="" # empty = derive from the block template (recommended) +PAYOUT_INTERVAL_HOURS="24" FQDN="" DASH_PORT="8081" PUBLIC_STRATUM_URL="" @@ -144,6 +214,17 @@ while [[ $# -gt 0 ]]; do --thunder-address) THUNDER_ADDRESS="$2"; shift 2 ;; --thunder-rpc-url) THUNDER_RPC_URL="$2"; shift 2 ;; --pps-sats-per-diff) PPS_SATS_PER_DIFF="$2"; shift 2 ;; + --payout-interval-hours) PAYOUT_INTERVAL_HOURS="$2"; shift 2 ;; + # --from-release takes an OPTIONAL tag, so the next argument is only + # consumed when it looks like one ("v0.2.0" or "0.2.0"). Anything + # else — another flag, or nothing at all — leaves the tag empty and + # resolves to the latest release. + --from-release) SOURCE="release" + if [[ "${2:-}" == v* || "${2:-}" =~ ^[0-9] ]]; then + RELEASE_TAG="$2"; shift 2 + else shift; fi ;; + --release-tag) SOURCE="release"; RELEASE_TAG="$2"; shift 2 ;; + --from-source) SOURCE="source"; shift ;; --hostname) FQDN="$2"; shift 2 ;; --dashboard-port) DASH_PORT="$2"; shift 2 ;; --admin-user) ADMIN_USER="$2"; shift 2 ;; @@ -159,7 +240,7 @@ while [[ $# -gt 0 ]]; do --run-tests) RUN_TESTS=1; shift ;; --non-interactive) INTERACTIVE=0; ASSUME_YES=1; shift ;; --yes|-y) ASSUME_YES=1; shift ;; - -h|--help) sed -n '2,51p' "$SELF" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) usage; exit 0 ;; *) die "unknown arg: $1 (try --help)" ;; esac done @@ -242,8 +323,14 @@ echo "${DIM}Answers are saved to $STATE_FILE and reused next time.${OFF}" # Where does the code live? If we're running from inside a checkout, that # checkout is the default; otherwise we'll clone. IN_CHECKOUT="" -CANDIDATE="$(cd "$(dirname "$SELF")/.." && pwd)" -[[ -f "$CANDIDATE/Makefile" && -f "$CANDIDATE/schema.sql" ]] && IN_CHECKOUT="$CANDIDATE" +if [[ "$PIPED" == "0" ]]; then + CANDIDATE="$(cd "$(dirname "$SELF")/.." && pwd)" + [[ -f "$CANDIDATE/Makefile" && -f "$CANDIDATE/schema.sql" ]] && IN_CHECKOUT="$CANDIDATE" +fi +# Running from a checkout you already have means you probably want that +# checkout built. Arriving via the one-liner means there is nothing to build +# from, so a published release is the only answer that works out of the box. +[[ -n "$SOURCE" ]] || SOURCE="$([[ -n "$IN_CHECKOUT" ]] && echo source || echo release)" echo echo "${BOLD}-- location --${OFF}" @@ -254,7 +341,15 @@ DEFAULT_USER="simplepool" [[ "$DEFAULT_USER" == "root" ]] && DEFAULT_USER="${SUDO_USER:-simplepool}" ask SVC_USER "service user (created if missing)" "$DEFAULT_USER" -if [[ ! -f "$ROOT/Makefile" ]]; then +echo +echo "${BOLD}-- where the code comes from --${OFF}" +ask_choice SOURCE "how should simplepool get onto this box?" \ + "release|download the published build for this machine — no compiler, no clone" \ + "source|git clone and compile from source (unreleased branches, other arches)" + +if [[ "$SOURCE" == "release" ]]; then + ask RELEASE_TAG "release tag (blank = the latest release)" "$RELEASE_TAG" +elif [[ ! -f "$ROOT/Makefile" ]]; then say "$ROOT has no checkout — it will be cloned" ask REPO_URL "git repository to clone" "$REPO_URL" ask BRANCH "branch" "$BRANCH" @@ -345,7 +440,13 @@ if [[ "$MODE" == "solo" ]]; then else [[ -z "$DO_PAYOUT" ]] && DO_PAYOUT=1 ask_yn DO_PAYOUT "install the Thunder payout worker?" - [[ "$DO_PAYOUT" == "1" ]] && ask THUNDER_RPC_URL "Thunder RPC url" "$THUNDER_RPC_URL" + if [[ "$DO_PAYOUT" == "1" ]]; then + ask THUNDER_RPC_URL "Thunder RPC url" "$THUNDER_RPC_URL" + # This is the batch cadence miners experience. It does not affect how + # quickly an already-broadcast payout is confirmed and credited — + # that runs on its own 30s clock inside the worker. + ask PAYOUT_INTERVAL_HOURS "how often should payouts run, in hours" "$PAYOUT_INTERVAL_HOURS" + fi fi [[ -z "$DO_PAYOUT" ]] && DO_PAYOUT=0 @@ -370,8 +471,17 @@ DB_PATH="$ROOT/data/shares.db" [[ "$MODE" == "pps-classic" && -z "$POOL_BTC_ADDRESS" ]] && \ warn "pps-classic without pool_btc_address — the proxy will refuse to start" -STEP_TOTAL=10 -[[ "$RUN_TESTS" == "1" ]] && STEP_TOTAL=$((STEP_TOTAL + 1)) +[[ "$SOURCE" =~ ^(release|source)$ ]] || die "invalid source: $SOURCE (release|source)" +[[ "$PAYOUT_INTERVAL_HOURS" =~ ^[0-9]+$ && "$PAYOUT_INTERVAL_HOURS" -ge 1 ]] || \ + die "--payout-interval-hours must be a whole number of hours >= 1 (got: $PAYOUT_INTERVAL_HOURS)" +PAYOUT_INTERVAL_MS=$(( PAYOUT_INTERVAL_HOURS * 3600 * 1000 )) + +STEP_TOTAL=11 +# Both source-selection paths cost exactly one step (download, or clone) plus +# one more (verify the prebuilt binary, or build it), so the base is the same +# either way. The C test suite is the only extra, and it needs a source tree +# to build from. +[[ "$RUN_TESTS" == "1" && "$SOURCE" == "source" ]] && STEP_TOTAL=$((STEP_TOTAL + 1)) [[ "$DO_NGINX" == "1" ]] && STEP_TOTAL=$((STEP_TOTAL + 1)) [[ "$DO_UFW" == "1" ]] && STEP_TOTAL=$((STEP_TOTAL + 1)) @@ -380,7 +490,11 @@ echo echo "${BOLD}-- summary --${OFF}" printf " %-22s %s\n" "install dir" "$ROOT" printf " %-22s %s\n" "service user" "$SVC_USER" -printf " %-22s %s\n" "branch" "${BRANCH:-}" +if [[ "$SOURCE" == "release" ]]; then + printf " %-22s %s\n" "source" "release ${RELEASE_TAG:-} from ${REPO_SLUG}" +else + printf " %-22s %s\n" "source" "build from ${BRANCH:-}" +fi printf " %-22s %s\n" "pool mode" "$MODE" printf " %-22s %s\n" "stratum" "0.0.0.0:$STRATUM_PORT" printf " %-22s %s\n" "bitcoind" "$BITCOIND_URL" @@ -391,7 +505,7 @@ printf " %-22s %s\n" "fee" "${FEE_BPS} bps" printf " %-22s %s\n" "database" "$DB_PATH" printf " %-22s %s\n" "dashboard" "$([[ $DO_DASH == 1 ]] && echo "yes (:$DASH_PORT${FQDN:+, $FQDN})" || echo no)" printf " %-22s %s\n" "nginx / TLS" "$([[ $DO_NGINX == 1 ]] && echo "yes$([[ $DO_TLS == 1 ]] && echo ' + certbot')" || echo no)" -printf " %-22s %s\n" "payout worker" "$([[ $DO_PAYOUT == 1 ]] && echo "yes ($THUNDER_RPC_URL)" || echo no)" +printf " %-22s %s\n" "payout worker" "$([[ $DO_PAYOUT == 1 ]] && echo "yes ($THUNDER_RPC_URL, every ${PAYOUT_INTERVAL_HOURS}h)" || echo no)" printf " %-22s %s\n" "firewall" "$([[ $DO_UFW == 1 ]] && echo "rules$([[ $ENABLE_UFW == 1 ]] && echo ' + enable')" || echo skip)" echo @@ -408,9 +522,13 @@ install -d -m 0755 "$STATE_DIR" { echo "# simplepool install answers — written by scripts/install.sh" echo "# contains secrets; root-only, 0600." - for v in ROOT SVC_USER REPO_URL BRANCH MODE STRATUM_PORT BITCOIND_URL \ + # simplepoolctl reads this file too — DB_PATH, SOURCE and RELEASE_TAG are + # here so it can report and upgrade without re-deriving any of them. + for v in ROOT SVC_USER REPO_SLUG REPO_URL BRANCH SOURCE RELEASE_TAG \ + MODE STRATUM_PORT BITCOIND_URL \ BITCOIND_USER BITCOIND_PASS OPERATOR_ADDRESS FEE_BPS COINBASE_TAG \ POOL_BTC_ADDRESS THUNDER_ADDRESS THUNDER_RPC_URL PPS_SATS_PER_DIFF \ + PAYOUT_INTERVAL_HOURS DB_PATH \ FQDN DASH_PORT PUBLIC_STRATUM_URL ADMIN_USER ADMIN_PASSWORD EMAIL \ DO_TLS DO_DASH DO_PAYOUT DO_NGINX DO_UFW DO_DEPS RUN_TESTS; do printf '%s=%q\n' "$v" "${!v-}" @@ -484,17 +602,24 @@ if [[ "$DO_DEPS" == "1" ]]; then apt) export DEBIAN_FRONTEND=noninteractive apt-get update -q - apt-get install -yq \ - build-essential git curl unzip ca-certificates python3 \ - libsqlite3-dev libcurl4-openssl-dev libhiredis-dev sqlite3 \ - openssl ufw + # A release install skips the compiler and git, but still needs + # the shared libraries the binary links against. It asks for the + # -dev packages rather than the runtime ones because those are + # the names that stay put across distro releases: libhiredis-dev + # is libhiredis-dev everywhere, while the runtime package is + # libhiredis0.14 on 22.04 and libhiredis1.1.0 on 24.04. + PKGS=(curl ca-certificates tar gzip python3 openssl ufw sqlite3 + libsqlite3-dev libcurl4-openssl-dev libhiredis-dev) + [[ "$SOURCE" == "source" ]] && PKGS+=(build-essential git unzip) + apt-get install -yq "${PKGS[@]}" [[ "$DO_NGINX" == "1" ]] && apt-get install -yq nginx [[ "$DO_TLS" == "1" ]] && apt-get install -yq certbot python3-certbot-nginx ;; dnf) - dnf install -y \ - gcc gcc-c++ make git curl unzip ca-certificates python3 \ - sqlite-devel libcurl-devel hiredis-devel sqlite openssl + PKGS=(curl ca-certificates tar gzip python3 openssl sqlite + sqlite-devel libcurl-devel hiredis-devel) + [[ "$SOURCE" == "source" ]] && PKGS+=(gcc gcc-c++ make git unzip) + dnf install -y "${PKGS[@]}" [[ "$DO_NGINX" == "1" ]] && dnf install -y nginx [[ "$DO_TLS" == "1" ]] && dnf install -y certbot python3-certbot-nginx ;; @@ -540,41 +665,147 @@ fi SVC_HOME="$(getent passwd "$SVC_USER" | cut -d: -f6)" [[ -d "$SVC_HOME" ]] || SVC_HOME="$ROOT" -# ============================ 4. source code ================================ -step "source code in $ROOT" -# git refuses to operate on a tree owned by another user ("dubious -# ownership"), so take ownership before touching it — a previous run as -# root may well have left root-owned objects here. -[[ -d "$ROOT" ]] && chown -R "$SVC_USER:$SVC_USER" "$ROOT" -if [[ -d "$ROOT/.git" ]]; then - if [[ -n "$BRANCH" ]]; then - say "fetching origin/$BRANCH" - as_user git -C "$ROOT" fetch origin --prune - as_user git -C "$ROOT" checkout "$BRANCH" - as_user git -C "$ROOT" reset --hard "origin/$BRANCH" - else - say "working tree left as-is" +# ============================ 4. get the code =============================== +# Which prebuilt tarball this machine can run. +release_arch() { + local m + m="$(dpkg --print-architecture 2>/dev/null || uname -m)" + case "$m" in + amd64|x86_64) echo amd64 ;; + arm64|aarch64) echo arm64 ;; + *) die "no published build for $m — re-run with --from-source to compile here" ;; + esac +} + +# Ask GitHub what the newest release is. Kept separate from the download so a +# tag the operator typed is never silently replaced by a different one. +latest_release_tag() { + curl -fsSL --max-time 20 "https://api.github.com/repos/${REPO_SLUG}/releases/latest" 2>/dev/null \ + | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1 +} + +fetch_release() { + local arch tag ver base tmp tarball + arch="$(release_arch)" + + tag="$RELEASE_TAG" + if [[ -z "$tag" ]]; then + say "asking github for the latest release of ${REPO_SLUG}" + tag="$(latest_release_tag || true)" + [[ -n "$tag" ]] || die "could not find a published release for ${REPO_SLUG}. + If none has been cut yet, build from source instead: --from-source" fi -elif [[ -f "$ROOT/Makefile" ]]; then - say "non-git checkout — left as-is" -else + RELEASE_TAG="$tag" + ver="${tag#v}" + base="https://github.com/${REPO_SLUG}/releases/download/${tag}" + tarball="simplepool-${ver}-linux-${arch}.tar.gz" + + tmp="$(mktemp -d)" + # A partial download must not be left behind looking like a good one. + # EXIT rather than RETURN, because die() exits outright and a RETURN trap + # would never fire. The path is expanded NOW, not at exit: $tmp is local + # to this function and would be empty by the time the trap runs, making + # the cleanup `rm -rf ""` — which fails, and would take the script's exit + # status down with it on an otherwise successful install. + trap "rm -rf '$tmp'" EXIT + + say "downloading $tarball ($tag)" + curl -fL --progress-bar --max-time 600 -o "$tmp/$tarball" "$base/$tarball" \ + || die "could not download $base/$tarball + Check that release $tag has a build for $arch, or use --from-source." + + # The checksum is not decoration: this binary is about to run as a service + # with the pool's payout addresses in its config, and it arrived over the + # network. A release without SHA256SUMS is treated as a failure, not as a + # reason to skip the check. + say "verifying against SHA256SUMS" + curl -fsSL --max-time 60 -o "$tmp/SHA256SUMS" "$base/SHA256SUMS" \ + || die "release $tag has no SHA256SUMS — refusing to install an unverified binary" + grep -q " \{1,2\}${tarball}\$" "$tmp/SHA256SUMS" \ + || die "SHA256SUMS for $tag does not mention $tarball — refusing to install" + ( cd "$tmp" && sha256sum -c --ignore-missing SHA256SUMS ) \ + || die "checksum mismatch on $tarball — the download is corrupt or tampered with" + + tar -xzf "$tmp/$tarball" -C "$tmp" + local unpacked + unpacked="$tmp/simplepool-${ver}-linux-${arch}" + [[ -f "$unpacked/Makefile" && -x "$unpacked/build/simplepool" ]] \ + || die "$tarball did not unpack into the expected layout" + install -d -o "$SVC_USER" -g "$SVC_USER" "$ROOT" - say "cloning $REPO_URL ($BRANCH)" - as_user git clone --branch "$BRANCH" "$REPO_URL" "$ROOT" + # Copy rather than replace: data/, proxy.conf and the *.bak files are not + # in the tarball (they are gitignored), so an overlay upgrades the code + # and leaves the ledger and the operator's config exactly where they are. + cp -a "$unpacked/." "$ROOT/" + say "unpacked $tag into $ROOT" + sed 's/^/ /' "$ROOT/RELEASE" 2>/dev/null || true +} + +if [[ "$SOURCE" == "release" ]]; then + step "download release" + # Overlaying a tarball onto a checkout produces a tree that is neither: + # git reports every file as modified and the next `git pull` fights the + # release. Say so instead of creating it. + [[ -d "$ROOT/.git" ]] && die "$ROOT is a git checkout, and a release tarball would be laid on top of it. + Pick one: --from-source (build this checkout) + --root (install the release somewhere else)" + fetch_release +else + step "source code in $ROOT" + # git refuses to operate on a tree owned by another user ("dubious + # ownership"), so take ownership before touching it — a previous run as + # root may well have left root-owned objects here. + [[ -d "$ROOT" ]] && chown -R "$SVC_USER:$SVC_USER" "$ROOT" + if [[ -d "$ROOT/.git" ]]; then + if [[ -n "$BRANCH" ]]; then + say "fetching origin/$BRANCH" + as_user git -C "$ROOT" fetch origin --prune + as_user git -C "$ROOT" checkout "$BRANCH" + as_user git -C "$ROOT" reset --hard "origin/$BRANCH" + else + say "working tree left as-is" + fi + elif [[ -f "$ROOT/Makefile" ]]; then + say "non-git checkout — left as-is" + else + install -d -o "$SVC_USER" -g "$SVC_USER" "$ROOT" + say "cloning $REPO_URL ($BRANCH)" + as_user git clone --branch "$BRANCH" "$REPO_URL" "$ROOT" + fi fi -[[ -f "$ROOT/Makefile" && -f "$ROOT/schema.sql" ]] || die "$ROOT does not look like a simplepool checkout" +[[ -f "$ROOT/Makefile" && -f "$ROOT/schema.sql" ]] || die "$ROOT does not look like a simplepool tree" # An earlier root-run build leaves root-owned objects behind; fix the tree. chown -R "$SVC_USER:$SVC_USER" "$ROOT" -# ============================== 5. build ==================================== -step "build the C proxy" -as_user make -C "$ROOT" -j"$(nproc)" -[[ -x "$ROOT/build/simplepool" ]] || die "build produced no binary at $ROOT/build/simplepool" -say "built $ROOT/build/simplepool ($(stat -c %s "$ROOT/build/simplepool") bytes)" +# ============================== 5. the binary =============================== +if [[ "$SOURCE" == "release" ]]; then + step "check the prebuilt binary runs here" + # The one thing a prebuilt binary can fail at on a specific box is + # dynamic linking — a too-old glibc, or a missing libhiredis. Finding + # that out now beats finding it out from a crash-looping unit. + if ! "$ROOT/build/simplepool" --version >/dev/null 2>&1; then + warn "$("$ROOT/build/simplepool" --version 2>&1 | head -3)" + # `|| true`: grep finding nothing must not pre-empt the die() below, + # which is the message that actually tells the operator what to do. + [[ -n "$(command -v ldd)" ]] && { ldd "$ROOT/build/simplepool" 2>&1 | grep -i "not found" | sed 's/^/ /' || true; } + die "the prebuilt binary will not run on this machine. + Usually an unsatisfied shared library or a glibc older than the build host's. + Re-run with --from-source to compile against this system instead." + fi + say "$("$ROOT/build/simplepool" --version 2>/dev/null | head -1)" +else + step "build the C proxy" + as_user make -C "$ROOT" -j"$(nproc)" + [[ -x "$ROOT/build/simplepool" ]] || die "build produced no binary at $ROOT/build/simplepool" + say "built $ROOT/build/simplepool ($(stat -c %s "$ROOT/build/simplepool") bytes)" + # A source install has no RELEASE file, and a stale one left over from a + # previous release install would misreport what is running. + rm -f "$ROOT/RELEASE" -if [[ "$RUN_TESTS" == "1" ]]; then - step "C test suite" - as_user make -C "$ROOT" test + if [[ "$RUN_TESTS" == "1" ]]; then + step "C test suite" + as_user make -C "$ROOT" test + fi fi # =========================== 6. node modules ================================ @@ -754,6 +985,13 @@ if [[ "$DO_PAYOUT" == "1" ]]; then echo "Environment=PAYOUT_DB_PATH=${DB_PATH}" echo "Environment=THUNDER_RPC_URL=${THUNDER_RPC_URL}" echo "Environment=THUNDER_FROM_ADDRESS=${THUNDER_ADDRESS}" + echo "# Payout runs are a batch: every ${PAYOUT_INTERVAL_HOURS}h everyone over" + echo "# PAYOUT_MIN_SATS goes out in one Thunder transaction." + echo "Environment=PAYOUT_INTERVAL_MS=${PAYOUT_INTERVAL_MS}" + echo "# Settlement is a separate, much shorter clock on purpose: nobody in a" + echo "# broadcast batch is credited until a tick sees it confirmed in a Thunder" + echo "# block, so this must not follow the cadence above. Left at the worker's" + echo "# defaults (30s / 5m) unless you have a reason to move them." } > "$PDROPIN" chmod 0644 "$PDROPIN" if [[ -z "$THUNDER_ADDRESS" ]]; then @@ -826,7 +1064,19 @@ if [[ "$DO_UFW" == "1" ]]; then say "${DASH_PORT}/tcp deliberately NOT opened — nginx fronts the dashboard" fi -# ============================ 12. start services ============================ +# ========================== 12. simplepoolctl =============================== +step "simplepoolctl" +# The operator's entry point from here on. It reads $STATE_FILE for +# everything about this box, so installing it is a copy, not a configuration. +if [[ -f "$ROOT/scripts/simplepoolctl" ]]; then + install -m 0755 "$ROOT/scripts/simplepoolctl" /usr/local/bin/simplepoolctl + say "installed /usr/local/bin/simplepoolctl" + say "${DIM}status | logs | doctor | upgrade | uninstall${OFF}" +else + warn "$ROOT/scripts/simplepoolctl not found — skipping (older tree?)" +fi + +# ============================ 13. start services ============================ step "enable + start services" # A unit that crash-loops must not abort the install — the status block # below is more useful than a bare `set -e` exit. @@ -900,6 +1150,18 @@ if [[ "$DO_DASH" == "1" ]]; then fi say "config: $ROOT/proxy.conf" say "database: $DB_PATH" -say "logs: journalctl -u simplepool -f" +if [[ "$SOURCE" == "release" ]]; then + say "installed: release ${RELEASE_TAG} (${ROOT}/RELEASE)" +else + say "installed: built from source (${BRANCH:-working tree})" +fi +[[ "$DO_PAYOUT" == "1" ]] && say "payouts: every ${PAYOUT_INTERVAL_HOURS}h" +echo +say "${BOLD}next:${OFF}" +say " simplepoolctl status what is running, on which ports" +say " simplepoolctl doctor check the things that break in production" +say " simplepoolctl logs -f follow every service at once" +say " simplepoolctl upgrade move to the next release" +echo say "re-run this script any time — it reuses your answers from $STATE_FILE" echo diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..211082d --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# +# Build a simplepool release tarball. +# +# The tarball is a git checkout with a prebuilt binary dropped into it — +# not a trimmed-down runtime bundle. That is deliberate: `scripts/install.sh`, +# the systemd templates, the nginx vhost, `schema.sql` and the dashboard all +# already expect a checkout at $ROOT, so shipping the same shape means the +# release path and the build-from-source path converge after one step instead +# of forking into two sets of layout assumptions. The only difference an +# operator can observe is that `build/simplepool` arrived prebuilt and there +# is a RELEASE file next to it. +# +# Contents: +# RELEASE version / commit / arch / build time +# build/simplepool the binary, built here +# build/simplepool.build.json provenance, pinned to the binary by sha256 +# src, dashboard, payout, deploy, docs, scripts +# +# Usage: +# scripts/release.sh # version from the Makefile +# scripts/release.sh --version 0.2.0 # override +# scripts/release.sh --out /tmp/dist +# scripts/release.sh --allow-dirty # build from an uncommitted tree +# +# Writes /simplepool--linux-.tar.gz and a matching +# .sha256 file. CI calls this exact script (see .github/workflows/release.yaml) +# so a hand-cut tarball and a released one are byte-for-byte the same recipe. +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +VERSION="" +OUT="$REPO_ROOT/dist" +ARCH="" +REF="HEAD" +ALLOW_DIRTY=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) VERSION="$2"; shift 2 ;; + --out) OUT="$2"; shift 2 ;; + --arch) ARCH="$2"; shift 2 ;; + --ref) REF="$2"; shift 2 ;; + --allow-dirty) ALLOW_DIRTY=1; shift ;; + -h|--help) sed -n '2,28p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "release.sh: unknown arg: $1" >&2; exit 2 ;; + esac +done + +git rev-parse --git-dir >/dev/null 2>&1 || { + echo "release.sh: not a git checkout — the tarball is assembled with git archive" >&2 + exit 1 +} + +# The version the binary will report, so the tarball cannot claim one thing +# while `simplepool --version` says another. +[[ -n "$VERSION" ]] || VERSION="$(sed -n 's/^VERSION[[:space:]]*:=[[:space:]]*//p' Makefile | head -1)" +[[ -n "$VERSION" ]] || { echo "release.sh: could not read VERSION from the Makefile" >&2; exit 1; } + +# The binary is compiled from the working tree; the source beside it comes +# from `git archive $REF`. On a dirty tree those are two different programs, +# and the tarball would ship a binary that provably does not correspond to the +# source shipped with it — the exact drift build/simplepool.build.json exists +# to make visible. Refuse rather than record it and hope someone reads the +# JSON. CI is always clean, so this only ever fires for a hand-run. +if [[ -n "$(git status --porcelain --untracked-files=no)" && "$ALLOW_DIRTY" != "1" ]]; then + echo "release.sh: the working tree has uncommitted changes." >&2 + echo " The binary would be built from them while the source in the tarball comes" >&2 + echo " from $REF, so the two would not match. Commit first, or pass --allow-dirty" >&2 + echo " if you are deliberately building a throwaway." >&2 + git status --short --untracked-files=no >&2 + exit 1 +fi + +if [[ -z "$ARCH" ]]; then + case "$(uname -m)" in + x86_64|amd64) ARCH=amd64 ;; + aarch64|arm64) ARCH=arm64 ;; + *) echo "release.sh: unsupported machine $(uname -m) — pass --arch" >&2; exit 1 ;; + esac +fi + +NAME="simplepool-${VERSION}-linux-${ARCH}" +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT + +echo "==> building $NAME" + +# Build first: no point assembling a tarball around a binary that doesn't +# compile. -j is safe here; the Makefile's version header is generated under +# an explicit prerequisite. +make clean >/dev/null +make -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 2)" +[[ -x build/simplepool ]] || { echo "release.sh: no binary at build/simplepool" >&2; exit 1; } + +# The binary states its own commit; if it can't run here, it can't run for an +# operator either, and shipping it would just move the failure downstream. +./build/simplepool --version >/dev/null || { + echo "release.sh: build/simplepool --version failed — refusing to ship it" >&2; exit 1; } + +# git archive rather than a copy of the working tree: only tracked files, no +# stray build output, no local proxy.conf with somebody's RPC password in it. +echo "==> staging tracked files from $REF" +git archive --format=tar --prefix="$NAME/" "$REF" | tar -x -C "$STAGE" + +install -d "$STAGE/$NAME/build" +install -m 0755 build/simplepool "$STAGE/$NAME/build/simplepool" +scripts/record-build.sh simplepool "$REPO_ROOT" build/simplepool \ + --out "$STAGE/$NAME/build/simplepool.build.json" >/dev/null + +COMMIT="$(git rev-parse "$REF")" +BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +# glibc is the real portability floor for a dynamically linked C binary, and +# it is the one thing an operator cannot discover from the file name. State it. +GLIBC="$(ldd --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+$' || echo unknown)" + +cat > "$STAGE/$NAME/RELEASE" </dev/null 2>&1; then + (cd "$OUT" && sha256sum "$NAME.tar.gz" > "$NAME.tar.gz.sha256") +else + (cd "$OUT" && shasum -a 256 "$NAME.tar.gz" > "$NAME.tar.gz.sha256") +fi + +echo "==> $TARBALL" +echo " version $VERSION commit ${COMMIT:0:7} arch $ARCH glibc >= $GLIBC" +echo " $(cat "$OUT/$NAME.tar.gz.sha256")" diff --git a/scripts/simplepoolctl b/scripts/simplepoolctl new file mode 100755 index 0000000..84ee9fb --- /dev/null +++ b/scripts/simplepoolctl @@ -0,0 +1,424 @@ +#!/usr/bin/env bash +# +# simplepoolctl — day-to-day control of an installed simplepool. +# +# Installed to /usr/local/bin by scripts/install.sh. Everything it needs to +# know about this box lives in /etc/simplepool/install.env, which the +# installer wrote: where the tree is, which services exist, which ports, and +# whether this box tracks releases or builds from source. That file is the +# reason this can be a wrapper rather than another place to configure things. +# +# Commands: +# status what is running, on which ports, at which version +# logs [svc] [-f] [-n N] journalctl for one service or all of them +# start|stop|restart [svc] +# version versions of everything, including what is released +# config where the config files are (and what is in them) +# doctor check the things that actually break in production +# upgrade [] fetch and install a newer version, then restart +# uninstall [--purge] remove the services (--purge also removes the data) +# +# svc is one of: proxy | dashboard | payout | all (default: all) +# +set -uo pipefail + +STATE_DIR=/etc/simplepool +STATE_FILE="$STATE_DIR/install.env" + +BOLD=$'\033[1m'; DIM=$'\033[2m'; RED=$'\033[31m'; GRN=$'\033[32m'; YEL=$'\033[33m'; OFF=$'\033[0m' +[[ -t 1 ]] || { BOLD=""; DIM=""; RED=""; GRN=""; YEL=""; OFF=""; } + +say() { echo " $*"; } +head_() { echo; echo "${BOLD}$*${OFF}"; } +warn() { echo "${YEL} warning: $*${OFF}" >&2; } +die() { echo "${RED}simplepoolctl: $*${OFF}" >&2; exit 1; } + +usage() { sed -n '2,25p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; } + +[[ -r "$STATE_FILE" ]] || die "no install found ($STATE_FILE is missing or unreadable). + Run the installer first, or re-run this with sudo if simplepool IS installed." +# shellcheck disable=SC1090 +source "$STATE_FILE" + +ROOT="${ROOT:-/home/simplepool}" +SVC_USER="${SVC_USER:-simplepool}" +DASH_PORT="${DASH_PORT:-8081}" +STRATUM_PORT="${STRATUM_PORT:-3334}" +MODE="${MODE:-solo}" +SOURCE="${SOURCE:-source}" +REPO_SLUG="${REPO_SLUG:-LayerTwo-Labs/simplepool}" +BIN="$ROOT/build/simplepool" +DB_PATH="${DB_PATH:-$ROOT/data/shares.db}" + +# Which units this install actually has. Asking systemd rather than trusting +# the saved answers, because a unit can be removed by hand and the answers +# file would never know. +unit_exists() { systemctl list-unit-files "$1" >/dev/null 2>&1 && \ + systemctl cat "$1" >/dev/null 2>&1; } + +UNITS=() +unit_exists simplepool.service && UNITS+=(simplepool.service) +unit_exists simplepool-dashboard.service && UNITS+=(simplepool-dashboard.service) +unit_exists simplepool-payout.service && UNITS+=(simplepool-payout.service) + +# svc name -> unit, so operators type `logs payout` rather than the full unit. +resolve() { + case "${1:-all}" in + proxy|simplepool) echo simplepool.service ;; + dash|dashboard) echo simplepool-dashboard.service ;; + payout) echo simplepool-payout.service ;; + all) [[ ${#UNITS[@]} -gt 0 ]] && printf '%s\n' "${UNITS[@]}" ;; + *) die "unknown service '$1' (proxy | dashboard | payout | all)" ;; + esac +} + +need_root() { + [[ $EUID -eq 0 ]] || die "'$1' needs root — try: sudo simplepoolctl $1" +} + +# ------------------------------------------------------------------ status -- +svc_state() { systemctl is-active "$1" 2>/dev/null || echo inactive; } + +cmd_status() { + head_ "services" + for u in "${UNITS[@]}"; do + s="$(svc_state "$u")" + # Uptime matters as much as state: a unit that is "active" but was + # started ten seconds ago is a crash loop, not a healthy service. + since="$(systemctl show -p ActiveEnterTimestamp --value "$u" 2>/dev/null)" + if [[ "$s" == "active" ]]; then + printf " %-28s ${GRN}%-10s${OFF} ${DIM}since %s${OFF}\n" "${u%.service}" "$s" "${since:-?}" + else + printf " %-28s ${RED}%-10s${OFF} ${DIM}journalctl -u %s -n 50${OFF}\n" "${u%.service}" "$s" "${u%.service}" + fi + done + [[ ${#UNITS[@]} -gt 0 ]] || say "none installed" + + head_ "listening" + if command -v ss >/dev/null 2>&1; then + local found + found="$(ss -ltn 2>/dev/null | grep -E ":(${STRATUM_PORT}|${DASH_PORT})\b" || true)" + if [[ -n "$found" ]]; then echo "$found" | sed 's/^/ /' + else warn "neither :${STRATUM_PORT} (stratum) nor :${DASH_PORT} (dashboard) is listening"; fi + else + say "ss not available" + fi + + if [[ " ${UNITS[*]} " == *" simplepool-dashboard.service "* ]]; then + head_ "dashboard" + if curl -fsS --max-time 5 "http://127.0.0.1:${DASH_PORT}/healthz" >/dev/null 2>&1; then + say "healthz ${GRN}ok${OFF} http://127.0.0.1:${DASH_PORT}/" + [[ -n "${FQDN:-}" ]] && say "public http$([[ "${DO_TLS:-0}" == 1 ]] && echo s)://${FQDN}/" + else + warn "healthz did not respond on :${DASH_PORT}" + fi + fi + + head_ "pool" + say "mode $MODE" + say "stratum stratum+tcp://${FQDN:-}:${STRATUM_PORT}" + case "$MODE" in + solo) say "stratum username [.]" ;; + *) say "stratum username [.]" ;; + esac + if [[ -r "$DB_PATH" ]] && command -v sqlite3 >/dev/null 2>&1; then + # Read-only URI: never let a status command touch the writer's file + # in a mode that could take a lock. + local q; q="file:${DB_PATH}?mode=ro" + say "workers $(sqlite3 "$q" 'SELECT count(*) FROM workers' 2>/dev/null || echo '?')" + say "shares (24h) $(sqlite3 "$q" "SELECT count(*) FROM shares WHERE ts > strftime('%s','now') - 86400" 2>/dev/null || echo '?')" + say "blocks found $(sqlite3 "$q" 'SELECT count(*) FROM blocks_found' 2>/dev/null || echo '?')" + if [[ "$MODE" == "pps-classic" ]]; then + say "owed (sats) $(sqlite3 "$q" 'SELECT COALESCE(SUM(accrued_sats - paid_sats),0) FROM pps_credits' 2>/dev/null || echo '?')" + fi + fi + echo +} + +# ----------------------------------------------------------------- version -- +cmd_version() { + head_ "installed" + if [[ -r "$ROOT/RELEASE" ]]; then + sed 's/^/ /' "$ROOT/RELEASE" + else + say "${DIM}no RELEASE file — this box was installed from source${OFF}" + fi + + head_ "binary" + if [[ -x "$BIN" ]]; then + "$BIN" --version 2>/dev/null | sed 's/^/ /' || warn "$BIN --version failed" + else + warn "no binary at $BIN" + fi + + if [[ "$SOURCE" == "source" ]] && [[ -d "$ROOT/.git" ]]; then + head_ "checkout" + say "branch $(git -C "$ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')" + say "commit $(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo '?')" + [[ -n "$(git -C "$ROOT" status --porcelain --untracked-files=no 2>/dev/null)" ]] && \ + warn "working tree has uncommitted changes — the binary may not match this commit" + fi + + head_ "available" + local latest + latest="$(latest_tag || true)" + if [[ -n "$latest" ]]; then + say "latest release $latest ${DIM}(simplepoolctl upgrade)${OFF}" + else + say "${DIM}could not reach github.com to check for a newer release${OFF}" + fi + echo +} + +# The full component picture (bitcoind, enforcer, Thunder) lives behind the +# dashboard's /api/versions, which already knows how to interrogate binaries +# that don't embed their own commit. No reason to reimplement it here. +latest_tag() { + curl -fsS --max-time 8 "https://api.github.com/repos/${REPO_SLUG}/releases/latest" 2>/dev/null \ + | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1 +} + +# -------------------------------------------------------------------- logs -- +cmd_logs() { + local svc="all" args=() + while [[ $# -gt 0 ]]; do + case "$1" in + proxy|simplepool|dash|dashboard|payout|all) svc="$1"; shift ;; + *) args+=("$1"); shift ;; + esac + done + [[ ${#args[@]} -gt 0 ]] || args=(-n 100) + local units=() u + while read -r u; do [[ -n "$u" ]] && units+=(-u "$u"); done < <(resolve "$svc") + [[ ${#units[@]} -gt 0 ]] || die "no services installed" + exec journalctl "${units[@]}" "${args[@]}" +} + +# --------------------------------------------------------- start/stop/etc. -- +cmd_lifecycle() { + local action="$1"; shift + need_root "$action" + local svc="${1:-all}" u + while read -r u; do + [[ -n "$u" ]] || continue + printf " %-28s " "${u%.service}" + if systemctl "$action" "$u"; then echo "${GRN}${action}ed${OFF}"; else echo "${RED}failed${OFF}"; fi + done < <(resolve "$svc") +} + +# ------------------------------------------------------------------ config -- +cmd_config() { + head_ "files" + say "proxy config $ROOT/proxy.conf" + say "install answers $STATE_FILE ${DIM}(root only — contains secrets)${OFF}" + say "database $DB_PATH" + [[ -f "$STATE_DIR/admin.cred" ]] && say "admin creds $STATE_DIR/admin.cred" + for d in /etc/systemd/system/simplepool-dashboard.service.d/local.conf \ + /etc/systemd/system/simplepool-payout.service.d/local.conf; do + [[ -f "$d" ]] && say "service env $d" + done + + head_ "effective proxy.conf" + if [[ -r "$ROOT/proxy.conf" ]]; then + # Passwords are redacted rather than omitted: an operator needs to see + # that the key is set without the value ending up in a paste. + grep -vE '^\s*(#|$)' "$ROOT/proxy.conf" \ + | sed -E 's/^([[:space:]]*(bitcoind_pass)[[:space:]]*=).*/\1 ****REDACTED****/' \ + | sed 's/^/ /' + else + warn "cannot read $ROOT/proxy.conf (try sudo)" + fi + echo + say "${DIM}edit with: sudo \$EDITOR $ROOT/proxy.conf && sudo simplepoolctl restart proxy${OFF}" + echo +} + +# ------------------------------------------------------------------ doctor -- +DOCTOR_FAILED=0 +check() { printf " %-42s " "$1"; } +pass() { echo "${GRN}ok${OFF}${1:+ ${DIM}$1${OFF}}"; } +fail() { echo "${RED}FAIL${OFF} $1"; DOCTOR_FAILED=1; } +soft() { echo "${YEL}warn${OFF} $1"; } + +cmd_doctor() { + head_ "install" + check "binary present and runnable" + if [[ -x "$BIN" ]] && "$BIN" --version >/dev/null 2>&1; then pass "$("$BIN" --version 2>/dev/null | head -1)" + else fail "$BIN missing or won't run — reinstall, or check its shared libraries with 'ldd $BIN'"; fi + + check "proxy.conf readable" + if [[ -r "$ROOT/proxy.conf" ]]; then pass + else fail "$ROOT/proxy.conf missing or unreadable by this user"; fi + + check "operator_address is set" + local op; op="$(sed -n 's/^[[:space:]]*operator_address[[:space:]]*=[[:space:]]*\([^#[:space:]]*\).*/\1/p' "$ROOT/proxy.conf" 2>/dev/null | head -1)" + if [[ -n "$op" && "$op" != *REPLACEME* ]]; then pass "$op" + else fail "unset or still the placeholder — the proxy refuses to start without it"; fi + + if [[ "$MODE" == "pps-classic" ]]; then + check "pool_btc_address is set (pps-classic)" + local pb; pb="$(sed -n 's/^[[:space:]]*pool_btc_address[[:space:]]*=[[:space:]]*\([^#[:space:]]*\).*/\1/p' "$ROOT/proxy.conf" 2>/dev/null | head -1)" + if [[ -n "$pb" && "$pb" != *REPLACE* ]]; then pass "$pb"; else fail "unset — the proxy refuses to start in pps-classic without it"; fi + + check "payout worker has a Thunder source address" + if grep -qs 'THUNDER_FROM_ADDRESS=[^[:space:]]' /etc/systemd/system/simplepool-payout.service.d/local.conf 2>/dev/null \ + || [[ -n "${THUNDER_ADDRESS:-}" ]]; then pass + else soft "THUNDER_FROM_ADDRESS is empty — the payout worker exits on start"; fi + fi + + head_ "database" + check "database exists" + if [[ -f "$DB_PATH" ]]; then pass "$(du -h "$DB_PATH" 2>/dev/null | cut -f1)"; else fail "$DB_PATH missing — re-run the installer"; fi + check "schema is initialised" + if command -v sqlite3 >/dev/null 2>&1; then + local n; n="$(sqlite3 "file:${DB_PATH}?mode=ro" "SELECT count(*) FROM sqlite_master WHERE type='table'" 2>/dev/null || echo 0)" + if [[ "${n:-0}" -ge 4 ]]; then pass "$n tables"; else fail "only ${n:-0} tables — load schema.sql"; fi + else soft "sqlite3 not installed; skipped"; fi + check "data directory writable by $SVC_USER" + if sudo -u "$SVC_USER" test -w "$(dirname "$DB_PATH")" 2>/dev/null; then pass + else fail "$(dirname "$DB_PATH") is not writable by $SVC_USER — the proxy cannot record shares"; fi + + head_ "backend" + local url; url="$(sed -n 's/^[[:space:]]*bitcoind_url[[:space:]]*=[[:space:]]*\([^#[:space:]]*\).*/\1/p' "$ROOT/proxy.conf" 2>/dev/null | head -1)" + check "bitcoind reachable" + if [[ -z "$url" ]]; then fail "bitcoind_url is not set in proxy.conf" + else + local user pass_ auth=() + user="$(sed -n 's/^[[:space:]]*bitcoind_user[[:space:]]*=[[:space:]]*\([^#[:space:]]*\).*/\1/p' "$ROOT/proxy.conf" 2>/dev/null | head -1)" + pass_="$(sed -n 's/^[[:space:]]*bitcoind_pass[[:space:]]*=[[:space:]]*\([^#[:space:]]*\).*/\1/p' "$ROOT/proxy.conf" 2>/dev/null | head -1)" + [[ -n "$user" ]] && auth=(--user "$user:$pass_") + # getblockchaininfo rather than getblocktemplate: it is cheap, and a + # template failure here would be indistinguishable from an unfunded + # or still-syncing node. + local out + out="$(curl -fsS --max-time 8 "${auth[@]}" --data-binary \ + '{"jsonrpc":"1.0","id":"doctor","method":"getblockchaininfo","params":[]}' \ + -H 'content-type: text/plain;' "$url" 2>&1)" + # The body is the test, not curl's exit status: a 200 carrying a + # JSON-RPC error is a failure too, and a failed curl leaves its + # message in $out, which cannot contain a result object either way. + if [[ "$out" == *'"result"'* ]]; then + local h; h="$(printf '%s' "$out" | sed -n 's/.*"blocks"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p')" + pass "height ${h:-?} at $url" + else + fail "no usable JSON-RPC answer from $url — check the node, the port, and bitcoind_user/pass" + fi + fi + + head_ "services" + for u in "${UNITS[@]}"; do + check "${u%.service}" + local s; s="$(svc_state "$u")" + if [[ "$s" == "active" ]]; then pass + elif [[ "$s" == "inactive" ]]; then soft "not running — 'sudo simplepoolctl start'" + else fail "$s — journalctl -u ${u%.service} -n 50"; fi + done + + check "stratum port ${STRATUM_PORT} accepting connections" + if command -v ss >/dev/null 2>&1 && ss -ltn 2>/dev/null | grep -qE ":${STRATUM_PORT}\b"; then pass + else fail "nothing is listening on :${STRATUM_PORT} — miners cannot connect"; fi + + head_ "disk" + check "free space on $(df -P "$ROOT" 2>/dev/null | awk 'NR==2{print $6}')" + local avail_kb; avail_kb="$(df -Pk "$ROOT" 2>/dev/null | awk 'NR==2{print $4}')" + if [[ -n "$avail_kb" && "$avail_kb" -gt 1048576 ]]; then pass "$(( avail_kb / 1024 )) MB" + elif [[ -n "$avail_kb" ]]; then soft "$(( avail_kb / 1024 )) MB left — the share ledger only grows" + else soft "could not read"; fi + + echo + if [[ "$DOCTOR_FAILED" == "1" ]]; then + echo "${RED}${BOLD}doctor: something above needs attention.${OFF}" + return 1 + fi + echo "${GRN}${BOLD}doctor: all checks passed.${OFF}" + echo +} + +# ----------------------------------------------------------------- upgrade -- +cmd_upgrade() { + need_root upgrade + local target="${1:-}" + local installer="$ROOT/scripts/install.sh" + [[ -f "$installer" ]] || die "no installer at $installer — reinstall from the one-liner in the README" + + # The installer is the single implementation of "put simplepool on a box". + # Upgrading by re-running it non-interactively against the saved answers + # means an upgrade cannot drift from a fresh install — there is no second + # code path to keep in sync. + if [[ "$SOURCE" == "release" ]]; then + local tag="${target:-$(latest_tag)}" + [[ -n "$tag" ]] || die "could not determine the latest release tag — pass one: simplepoolctl upgrade v0.2.0" + if [[ "$tag" == "${RELEASE_TAG:-}" && -z "$target" ]]; then + say "already on $tag — nothing to do (pass a tag to force)" + return 0 + fi + echo "${BOLD}==> upgrading ${RELEASE_TAG:-unknown} -> $tag${OFF}" + bash "$installer" --non-interactive --yes --from-release "$tag" + else + echo "${BOLD}==> rebuilding from ${BRANCH:-main}${OFF}" + bash "$installer" --non-interactive --yes --from-source ${target:+--branch "$target"} + fi +} + +# --------------------------------------------------------------- uninstall -- +cmd_uninstall() { + need_root uninstall + local purge=0 + [[ "${1:-}" == "--purge" ]] && purge=1 + + echo "${BOLD}This will stop and remove:${OFF}" + for u in "${UNITS[@]}"; do say "systemd unit $u"; done + say "command /usr/local/bin/simplepoolctl" + if [[ "$purge" == "1" ]]; then + echo "${RED}${BOLD}--purge also deletes, permanently:${OFF}" + say "${RED}$ROOT (the checkout AND proxy.conf)${OFF}" + say "${RED}$DB_PATH (every share, block and PPS credit ever recorded)${OFF}" + say "${RED}$STATE_DIR (install answers and admin credentials)${OFF}" + echo + say "${YEL}The ledger is the only record of what the pool owes its miners.${OFF}" + say "${YEL}Back it up first: sqlite3 $DB_PATH \".backup /somewhere/safe.db\"${OFF}" + else + say "${DIM}$ROOT and $DB_PATH are LEFT IN PLACE (use --purge to remove them)${OFF}" + fi + echo + local answer="" + read -r -p " type 'yes' to proceed: " answer /dev/null 2>&1 || true + rm -f "/etc/systemd/system/$u" + rm -rf "/etc/systemd/system/$u.d" + say "removed $u" + done + systemctl daemon-reload + + if [[ -n "${FQDN:-}" && -e "/etc/nginx/sites-enabled/${FQDN}" ]]; then + rm -f "/etc/nginx/sites-enabled/${FQDN}" + say "removed nginx vhost symlink for ${FQDN} (the vhost and any certbot TLS block are left in sites-available)" + nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1 || true + fi + + if [[ "$purge" == "1" ]]; then + rm -rf "$ROOT" "$STATE_DIR" + say "removed $ROOT and $STATE_DIR" + fi + rm -f /usr/local/bin/simplepoolctl + echo + say "${GRN}done.${OFF} The service user '$SVC_USER' was left in place — remove it with 'userdel' if you want it gone." + echo +} + +# -------------------------------------------------------------------- main -- +case "${1:-status}" in + status) shift || true; cmd_status "$@" ;; + version|--version) shift || true; cmd_version "$@" ;; + logs) shift; cmd_logs "$@" ;; + start|stop|restart) a="$1"; shift; cmd_lifecycle "$a" "$@" ;; + config) shift; cmd_config "$@" ;; + doctor) shift; cmd_doctor "$@" ;; + upgrade) shift; cmd_upgrade "$@" ;; + uninstall) shift; cmd_uninstall "$@" ;; + help|-h|--help) usage ;; + *) echo "simplepoolctl: unknown command '$1'" >&2; echo; usage; exit 2 ;; +esac