Blocknet mining pool server in Rust (stratum + API + payouts).
Windows setup guide · Connect to bntpool.com · Mining locally but pool hashrate is zero?
The repo ships four runtime binaries from a Cargo workspace:
blocknet-pool-api: API/UI process onlyblocknet-pool-stratum: Stratum + payouts + maintenance process onlyblocknet-pool-monitor: on-host monitoring sampler + Prometheus endpointblocknet-pool-recoveryd: privileged local recovery agent for daemon cutover/sync/rebuild workflows
For production, prefer the split services so API/UI deploys do not drop Stratum connections.
Workspace layout:
apps/: service-specific app packagescrates/: shared library packages with explicit dependency boundariesfrontend/: embedded React/Vite UI for the API binary
Exchange payout memos are opt-in, with separate accounting and a configurable higher payout minimum. New memo mining is disabled by default.
The payout minimum and regular interval remain configurable; this change does not alter deployed settings. Sweeps honor the configured recipient and amount caps, with at most 64 transactions and a three-minute budget checked between sends. Capacity or temporary send failures can schedule a five-minute continuation; regular sweep times stay anchored instead of drifting by processing time. Unknown broadcast outcomes retain their existing idempotency state and are reconciled before retrying. Operator recipient/amount caps do not themselves trigger a continuation.
Wallet inventories with at least 128 outputs worth 0.01 BNT or more are marked
fragmented. Ordinary single-input payouts may include up to three additional
small inputs, within the existing reserve budget. Dust is excluded, and fragmented
wallets are not split further. Planning uses complete recent payout transactions.
The daemon's optional GET /api/wallet/outputs?status=unspent filter reduces
output transfer; older daemons that ignore the query remain compatible.
The persisted runtime payouts snapshot includes queued_recipient_count,
continuation_required, blocked_address_count, and blocked_address_amount
(atomic units). Blocked balances remain credited and are excluded from sends.
For a read-only mainnet report, run python3 scripts/audit_payout_balances.py;
use --include-addresses only when an operator needs the destinations. The script
uses SSH, local PostgreSQL read-only queries, and libsodium key validation.
Invalid-address claims require ownership review and a validated replacement
address before a separately authorized ledger correction. Never silently
redirect, erase, or retry those funds against an invalid key.
Database-backed tests require BLOCKNET_POOL_TEST_POSTGRES_URL; without it,
those tests return early without exercising PostgreSQL. Use a disposable local
PostgreSQL database with permission to create schemas. Each test creates its own
schema so the suite can run concurrently; discard the database afterward to
remove the test schemas.
BLOCKNET_POOL_TEST_POSTGRES_URL='host=localhost user=postgres dbname=pool_test' \
cargo test -p pool-runtime -- --test-threads=4
cargo clippy -p pool-runtime -p blocknet-pool-stratum-app --all-targets -- -D warningsThe replay tests exercise the real Argon2 implementation and require several GiB of available memory.
From the local repo root:
./scripts/deploy_bntpool.shWhat it does:
- builds frontend bundle locally (unless
--skip-ui-build) - builds the split release binaries on Linux:
- set
BNTPOOL_REMOTE_BUILD=1to build onbntpoolafter source sync - or set
BNTPOOL_LOCAL_BUILD_IMAGEwithDOCKER_DEFAULT_PLATFORM=linux/amd64to build locally in Docker
- set
- rsyncs pool source to
bntpool:/opt/blocknet/blocknet-pool - uploads locally built binaries, or uses host-built binaries when
BNTPOOL_REMOTE_BUILD=1 - verifies deployed binary architecture before restarting services
- writes
/opt/blocknet/blocknet-pool/deploy-info.txt - restarts only the changed service(s):
blocknet-pool-api.serviceblocknet-pool-stratum.serviceblocknet-pool-monitor.service
- frontend-only changes still restart
blocknet-pool-api.servicebecause the UI bundle is embedded into that binary - tails recent logs and runs
scripts/verify_bntpool_deploy.sh
From macOS, do not run a plain local binary deploy. Use the remote build path:
BNTPOOL_REMOTE_BUILD=1 ./scripts/deploy_bntpool.shRun the production smoke verifier without deploying:
./scripts/verify_bntpool_deploy.shThe pool now ships a dedicated on-host monitor and repo-managed monitoring assets:
blocknet-pool-monitor.service: probes API, Stratum, Postgres, daemon, wallet, share freshness, and payout queue state every10s- The monitor uses the same
api_keyas protected API routes when it samples API performance telemetry. - DB-backed heartbeats and incidents drive
/api/status - Prometheus + Alertmanager + node exporter + blackbox exporter configs live under
deploy/monitoring/ - Cloudflare Worker assets for outside-in public HTTP probes live under
deploy/cloudflare/monitor-worker/
Provision the on-host monitoring packages and configs on bntpool with:
./scripts/provision_bntpool_monitoring.shThat script installs Prometheus, Alertmanager, node exporter, blackbox exporter, and the local Discord relay. Grafana provisioning files are included in-repo and can be installed separately if Grafana is present on the host.
Growth mode is an informational operating state in Prometheus and Alertmanager; it does not send Discord notifications. Capacity, verification coverage, backlog and service-health alerts continue to notify Discord normally.
Template renewal errors remain counted, but notify only while template recovery is overdue. A recent successful template fetch or renewal suppresses the historical error alerts immediately. Unknown template IDs can follow a daemon restart or switch before their leases expire; they become critical only when recovery stays overdue. Missing freshness telemetry does not suppress a recorded failure. General refresh-stall and block-submission-failure alerts remain independent.
Validate the rules and recovery scenarios before provisioning:
cd deploy/monitoring/prometheus
promtool check rules blocknet-pool.rules.yml
promtool test rules template-lease.test.ymlWhen you need a fresh blocknet-core daemon binary for the pool host, build it
locally from this repo using the sibling daemon checkout instead of compiling on
bntpool. The build and deploy scripts source the daemon pool branch from the
sibling ../blocknet-core repo through a temporary git worktree, so they do
not require switching your current local checkout first. If that branch/ref is
missing or stale locally, fetch it before building.
From blocknet-pool/:
./scripts/build_blocknet_daemon.shThat writes the daemon artifact to build/blocknet-core-linux-amd64.
To stage it on the server without restarting the daemon yet:
./scripts/build_blocknet_daemon.sh --upload bntpoolFor the full repeatable deploy path, including the managed blocknetd@primary
and blocknetd@standby units, release directory rotation, symlink switch,
rolling restarts, and wallet/mining readiness verification:
./scripts/deploy_blocknet_daemon_bntpool.shblocknetd@primary is also the public seed node seed.bntpool.com: it runs
with --seed, so it serves its peer ID on TCP 28081 next to P2P on 28080 (both
open in ufw). Its peer ID comes from /var/lib/blocknet/data/identity.key; the
seed list depends on that file, so back it up and never delete it. The standby
listens for P2P on 28090, which stays closed, so it never takes the seed ports.
The deploy upgrades the currently inactive instance first. It waits for its
wallet to load and catch up, agreement with the serving daemon's canonical chain,
and a successful template fetch and renewal. Recoveryd must already be configured
with the wallet secret and managed proxy/cookie routing. The helper also needs
python3 and psql on the host and a fresh Stratum runtime snapshot in Postgres.
Before switching traffic, it pauses payouts and waits for a payout tick completed
after the pause, so an existing batch can finish. It uses recoveryd's transactional
proxy/cookie cutover, verifies the pool has refreshed its template, and only then
restarts the original active instance. It returns traffic to that original wallet
before restoring the previous payout pause state, preserving its unconfirmed-send
state. API and Stratum services are not restarted. An old instance-local template
can still produce a renewal alert during cutover; these alerts are not suppressed.
A block found while the daemon is unreachable is resubmitted for up to five
minutes, falling back to the full block when the daemon no longer knows the
template, so a cutover or daemon restart does not lose it
(blocknet_pool_monitor_block_submit_retries_total,
blocknet_pool_monitor_block_submit_recovered_total).
Each readiness/drain gate defaults to 900 seconds, configurable with
BNTPOOL_DAEMON_READY_TIMEOUT. A failure before cutover leaves the original daemon
serving and restores the previous release pointer. A failure once cutover starts
leaves payouts paused and stops further restarts; inspect routing and wallet
readiness and return to the original wallet before resuming payouts.
Check the serving daemon, routing, recovery service, and runtime snapshot without deploying or changing service state:
ssh bntpool 'sudo -n python3 - --check' < scripts/rolling_daemon_deploy.py
python3 -m unittest discover -s scripts -p 'test_rolling_daemon_deploy.py'By default the build script reads source from ../blocknet-core, derives the required Go
version from that repo's go.mod, resolves branch pool from the daemon repo,
builds through the daemon repo's Dockerfile, writes local artifact metadata next
to the binary, and uploads to /opt/blocknet/blocknet-core/blocknet.new. The
full deploy script names releases with the branch prefix, for example
pool-<sha>, and records build-info.txt in each release directory on the
server.
npm --prefix frontend ci
npm --prefix frontend run build
cargo build --release -p blocknet-pool-api-app --bin blocknet-pool-api
cargo build --release -p blocknet-pool-stratum-app --bin blocknet-pool-stratum
cargo build --release -p blocknet-pool-monitor-app --bin blocknet-pool-monitor
cargo build --release -p blocknet-pool-recoveryd-app --bin blocknet-pool-recoveryd
cp config.example.json config.json
# edit config.json and set database_url, api_key, monitor_ingest_secret, daemon_cookie_path, and pool addresses
# optional: create .env next to config.json with BLOCKNET_WALLET_PASSWORD=...
cargo run --release -p blocknet-pool-api-app --bin blocknet-pool-api
cargo run --release -p blocknet-pool-stratum-app --bin blocknet-pool-stratum
cargo run --release -p blocknet-pool-monitor-app --bin blocknet-pool-monitor
# run the API and Stratum binaries in separate terminals
# run the monitor binary in a third terminal if you want the DB-backed status page/metrics loopCustom config:
cargo run --release -p blocknet-pool-api-app --bin blocknet-pool-api -- --config /path/to/config.json
cargo run --release -p blocknet-pool-stratum-app --bin blocknet-pool-stratum -- --config /path/to/config.json
cargo run --release -p blocknet-pool-monitor-app --bin blocknet-pool-monitor -- --config /path/to/config.json
cargo run --release -p blocknet-pool-recoveryd-app --bin blocknet-pool-recoveryd -- --config /path/to/config.jsonWeb UI is built with React + TypeScript + Vite from frontend/.
Local dev:
cd frontend
npm install
npm run devBuild the embedded API bundle:
cd frontend
npm ci
npm run buildBuild output is written to frontend/dist/ and embedded into the API binary during API builds. A fresh clone must build the frontend before cargo build or cargo run for blocknet-pool-api.
The embedded assets are served at:
GET /(index)GET /ui-assets/app.jsGET /ui-assets/app.css
Because the UI bundle is embedded into the API binary, frontend-only changes still require rebuilding and restarting the API service.
The pool authenticates to the daemon with daemon_cookie_path in config.json.
When a daemon request returns 401 unauthorized, the pool refreshes the token from the configured cookie path once and retries.
The pool uses Postgres. Set database_url in config.json, for example:
{
"database_url": "postgres://user:password@127.0.0.1:5432/blocknet_pool"
}database_url is required. database_pool_size controls Postgres connection fan-out
(default 4).
- Stratum now defaults to loopback bind (
stratum_host=127.0.0.1). Setstratum_hostexplicitly to expose it. - The API server is plaintext HTTP and should stay bound to loopback behind nginx or another TLS terminator.
- If Stratum is exposed publicly, place it behind a TLS terminator.
- API/UI server
- Stratum server
- Template/job manager
- Validation engine (bounded queues)
- Persistent storage (Postgres)
- Payout processor
- DB/meta-backed live runtime snapshot for split-service API status
Public endpoints (no API key required):
GET /api/infoGET /api/checkpointsGET /checkpoints.datGET /api/statsGET /api/stats/historyGET /api/stats/insightsGET /api/luckGET /api/statusGET /api/blocksGET /api/payouts/recentGET /api/miner/{address}GET /api/miner/{address}/balanceGET /api/miner/{address}/hashrate
/checkpoints.dat serves the latest daemon-generated checkpoint file. By
default the API reads <daemon_data_dir>/checkpoints.dat; set
checkpoints_path in config.json to override that path.
Protected endpoints (API key required):
GET /api/minersGET /api/healthGET /api/daemon/logs/stream
api_key is required at API and monitor startup. monitor_ingest_secret is required for the signed Cloudflare monitor ingest endpoint.
Accepted header:
x-api-key: <api_key>
Paged/filterable list endpoints:
- Shared query params:
limit,offset. GET /api/miners:search,sort.GET /api/blocks:status.
Daemon log stream details:
- Admin UI includes a live daemon log viewer tab.
- Stream endpoint:
GET /api/daemon/logs/stream?tail=200. - Log source fallback order:
journalctlfor the active recovery daemon, then the managed primary and standby unitstail -F <daemon_data_dir>/debug.log
GET /(front page): the pool fee, Stratum address and a miner lookup for first visits; once a miner has been looked up, the hero shows that miner's hashrate and progress to its own payout minimum (exchange deposits use the higher memo minimum). Current Round lives on/luck.- Multi-tab WebUI includes:
- pool + onboarding info (
/api/info) - API key auth UX for protected routes
- miner lookup
- public blocks, payouts, luck, and status pages
- admin miners, balances, shares, rewards, recovery, and logs tabs
- live trend charts
- operator health panel (
/api/health) - live daemon logs panel (
/api/daemon/logs/stream)
- pool + onboarding info (
- Login requires protocol v2 with declared capabilities.
- The login
addressalso accepts Blocknet ID shortnames (@nameor$name, case-insensitive). Stratum verifies the signed mapping fromhttps://blocknet.id, then applies normal address/network validation. Jobs, shares, balances, and payouts use the resolved full address, fixed for that connection. A new login resolves the name again; a name change never redirects an existing balance. Resolver failures reject login. Full addresses require no resolver access. The stats page also supports shortname lookups. - Login rejects malformed payout addresses early (base58 + checksum-compatible Blocknet stealth address validation)
- Login requires protocol v2 +
submit_claimed_hash; submits withoutclaimed_hashare rejected. - Per-connection submit rate limiting is enabled.
refill(capabilitynonce_refill_v1, advertised at login, never required) gives a connection another nonce range for a job it holds, without reconnecting. Request{"id":N,"method":"refill","params":{"job_id":"<held job>"}}; the answer is{"id":N,"status":"ok","result":{...}}with the same fields as ajobnotification: a newjob_idand a fresh, non-overlapping nonce range for the same template and share target. Nojobnotification is sent for it. The connection's other jobs stay valid; a new job for the same template (vardiff or rebind) supersedes the job and all its refills together with the usual grace, and a new template retires them all withstale_submit_grace. Errors:stale job(not a live job of this connection on the current template),refill rate limited(stratum_refill_rateper second, burststratum_refill_burst; defaults 4 and 8),refill limit reached(stratum_refill_max_assignmentslive jobs per connection per template, default 256, counting the job itself, or a pool-wide bound on refills), andnot logged inbefore login.proxy_workers_v1(advertised at login, never required): a connection that logs in with it may add"worker":"<name>"to asubmit, and that share is credited to that worker instead of the connection's own, so a farm proxy's rigs show up individually. Names are cleaned like login worker names, and each connection may use up to 4,096 distinct names; further names count as the connection's worker.- Queue pressure returns
server busy, retry(no inline bypass) - Per-connection vardiff retargeting is enabled by default to target a small number of shares per window (
vardiff_*config keys) - Vardiff difficulty is cached per
address+workerand reused on reconnect/restart when the hint is fresh (1h TTL), reducing post-restart ramp-up. - Default vardiff profile assumes a weak baseline miner and aims for ~10 shares / 5 minutes (
initial_share_difficulty=60,vardiff_target_shares=10) - Template refresh identity uses stable tip fields (
height,network_target,prev_hash) to avoid daemon template churn while still refreshing on meaningful tip/template transitions. - Assignment submits on a previous template are accepted only inside a short grace window (
stale_submit_grace, default8s) based on when the share was received. - Daemon SSE tip events mark templates stale from
new_blockhash+height. - Timestamp-only
new_blockchanges do not trigger refreshes; only hash/height changes can trigger staleness. - Same-height hash-change refresh is enabled by default (
refresh_on_same_height=true) so the pool reacts immediately to same-height reorgs. Set it tofalseonly if an operator intentionally wants to coalesce those events. - Daemons that advertise
template_expires_at_unix_msreceive a cheap liveness renewal every 30 seconds (and never later than half-life) without replacing miner work. A rejected or malformed renewal queues a forced template replacement. - Generation-aware daemons attach
mempool_generationto status and block-template responses. A same-tip generation change is debounced for 10 seconds and then forces one content refresh so newly arrived transactions enter miner work without restoring constant template churn. - Every mining template has a hard two-minute content age cap, even when the mempool is unchanged. This bounds on-chain timestamp drift while preserving renewable leases and avoiding the old two-second refresh churn.
- A successful SSE connection/reconnect forces a same-tip template rotation because daemon template IDs are process-local. Against older daemons without renewal support, the pool bounds polling-only restart detection with the same 30-second rotation cadence.
A miner selects solo mode per connection by logging in as solo:<address>, solo:@name
or solo:$name (prefix in any letter case). Everything else about the connection is
unchanged: same port, gateway, vardiff, verification, memos and payouts.
- Solo shares are stored with
shares.solo = trueand never enter a PPLNS window, a pending-reward preview or a PPLNS trust count. In the minute projections they land in thesoloandsolo_rejectedstatus buckets, so hashrate and pool-wide luck still count solo work while PPLNS readers skip it. - A block found by a solo connection is stored with
blocks.solo = true. Once it confirms, its whole reward lesspool_fee_pctis credited to the finder, then paid through the normal payout path. PPLNS miners earn nothing from it. - A solo connection's jobs are assigned to the owner
solo:<address>, so work issued to one mode is rejected (job not assigned to this miner) if submitted through the other, even from the same address. - After a solo login the pool sends a
solo_miningnotification that explains the payout and the pool fee. Solo connections do not receivepool_block_solvedbroadcasts, and a solo block is not announced to PPLNS miners. - Pool hashrate, round effort and luck are pool-wide: they include solo work, and solo blocks end a round like any other block.
- The API and stats page accept
solo:lookups and show the miner's address.GET /api/blocksitems andGET /api/luckrounds carrysolo,GET /api/miner/{address}addssolo_hashrateand asoloflag on each worker (any solo share in the hashrate window), and pending-estimate block rows carrysolo. The blocks and luck tables show a Type column (Pool or Solo, with an icon) before Status, and the front page's recent blocks show the same marker; a miner with solo work also gets it in pending estimates and the Workers table. - Get Started has a "Mine solo" section with the
solo:command, shown only when the latest seine release (latest_miner_versioninGET /api/info) is v0.3.1 or newer, because older seine versions reject the prefix. The rewards card explains the Solo type.
- Payout batches target at least 15 recipients when enough miners are eligible and wallet liquidity permits. Smaller queues still pay on the scheduled sweep; they do not wait for a full batch.
payout_intervaldefaults to1handmin_payout_amountdefaults to0.1BNT. A longer interval combines more earnings per miner into each payment, reducing repeat payments at the cost of a longer wait. Existing production configuration overrides these defaults and is preserved by the deploy script.payouts_enabledtoggles payout sending globally.payout_pause_filepauses payouts when the file exists.pplns_window_durationcontrols the time-based PPLNS lookback window (default6h).payout_provisional_cap_multipliercaps aged provisional difficulty relative to verified difficulty so undersampled miners receive reduced provisional credit instead of being fully excluded.- Keep
sample_rateandmin_sample_everycomfortably above the payout policy's full-credit target so honest miners do not sit under the cap for long. verification_policy(ratioby default) selects how verification is spent.countgives each large address aboutcount_target_samplessamples percount_window(never belowcount_coverage_floor) and also credits provisional shares that have a clean sample withincount_sample_radius, once the window holdscount_payout_min_samples. SeeVERIFY_COUNT_SAMPLING.md. The policy,count_payout_min_samplesandcount_sample_radiusneed a restart.- Optional caps:
payout_max_recipients_per_tickpayout_max_total_per_tickpayout_max_per_recipient
- Retention worker runs hourly, keeping detailed shares for 90 days and payout rows for 365 days.
- Old rows are rolled up into summary tables before prune:
share_daily_summariespayout_daily_summaries
get_total_share_countand total rejected share metrics include rolled-up share summaries.
- Added CI smoke harness:
scripts/ci_e2e_smoke.sh(mock daemon + real pool process + Stratum/API probe).
The pool expects a running Blocknet daemon API with mining + wallet routes enabled.
At minimum:
GET /api/statusGET /api/mining/blocktemplatePOST /api/mining/submitblockGET /api/block/{id}GET /api/wallet/addressGET /api/wallet/balancePOST /api/wallet/sendPOST /api/wallet/loadPOST /api/wallet/unlock
- This repository no longer uses Go; runtime is Rust-only.
The API checks the latest stable release of zpalmtree/seine on GitHub at startup and every 15 minutes. Miner requests use the cached version, so GitHub latency never delays a lookup. Failed checks retry after one minute and keep the last successful version in memory. On a fresh start, update notices stay hidden until a check succeeds. The optional latest_miner_version config field supplies a startup fallback; leave it empty for automatic discovery without a fixed fallback. Drafts, prereleases, malformed tags and failed HTTP responses cannot replace the cached release. Existing miner response caches may take another 10 seconds to expire after an update.