Pool v0: igneum-pool (spec 09 newline JSON, per-member templates with the member's vote key, vardiff, CPU warp-verified shares, PPLNS on the EVM side with a 1% default fee and dry run, stats API and page), the Hive hooks' pool:// mode, the private-network measurement harness
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
08e469ff65
commit
4329af5bcd
26 changed files with 10022 additions and 22 deletions
|
|
@ -31,14 +31,22 @@ v1": jobs `memsweep-pc2-pv1`, `memminer-pc2-pv1` and the S_p curve, one RTX 5090
|
|||
| Installation URL | `https://<host>/igneum-hive-<version>.tar.gz` (the archive from `make-hive-package.sh`, published on the download host) |
|
||||
| Miner name | `igneum` |
|
||||
| Wallet and worker template | `0x<40 hex>.%WORKER_NAME%`: the payout address is an EVM address you hold the key for; the part after the dot labels this rig's keys |
|
||||
| Pool URL | `grpc://<your node>:26610` (your own igneumd, solo mining), or `local` to run the bundled node on the rig |
|
||||
| Pool URL | `grpc://<your node>:26610` (your own igneumd, solo mining), `local` to run the bundled node on the rig, or `pool://<pool host>:4463` to mine to a pool (spec 09; `NODE=local` in the extra config adds a verifier node for voting) |
|
||||
| Pass | empty |
|
||||
| OVERRIDE | the devnet's consensus override as one JSON object on its own line, needed with `local`: `OVERRIDE={"difficulty_v2_activation_daa":33000,"proving_v0_activation_daa":84100,"fees_v1_activation_daa":210000,"finality_v3_activation_daa":135200}` is the four-field object 0.3.10 shipped; after the 0.3.11 switch it has nine fields, and the downloads page carries the live one. Set OVERRIDE from the downloads page when it changes. **A rig without it is refused**: its node runs on genesis parameters, prints another digest, and every devnet peer drops it (`h-run.sh` warns in the main log) |
|
||||
| Extra config arguments | `DEV_FEE=1 IDENTITIES=auto WORKER=auto VOTE=1` (one per line also works); `PEERS=a:26611,b:26611` for the bundled node; `EXTRA="..."` for more miner flags |
|
||||
| Extra config arguments | `DEV_FEE=1 IDENTITIES=auto WORKER=auto VOTE=1` (one per line also works); `PEERS=a:26611,b:26611` for the bundled node; `NODE=local` or `NODE=grpc://host:26610` for the verifier node in pool mode; `EXTRA="..."` for more miner flags |
|
||||
| IDENTITIES | vote keys per card: 8 for a card with 8 GB or more, else 2 (`IDENTITIES=auto` applies that rule, per card, from `nvidia-smi` or the amdgpu sysfs; 8 when neither answers; a number overrides it for every card). The rule is the app's (`app/igneum-app/src/detect.rs`) |
|
||||
|
||||
Solo mining: there is no pool. Each card mines block templates from the node and the block reward pays the wallet in
|
||||
the template (80% of each block to its finder, 20% to the proving pool; the protocol takes no fee for anyone).
|
||||
Solo mining: each card mines block templates from the node and the block reward pays the wallet in the template (80% of
|
||||
each block to its finder, 20% to the proving pool; the protocol takes no fee for anyone).
|
||||
|
||||
Pool mining (5 October 2026, `pool/README.md`): with `pool://host:4463` each card runs `igneum-miner ... --pool host:4463
|
||||
--evm-address <wallet> --worker-name <label>-gpuN`; the pool names this rig's own vote key in every header (one key per
|
||||
rig, `IDENTITIES` is ignored), pays the wallet by PPLNS on the EVM side, and the miner takes no software dev fee in pool
|
||||
mode (the pool's fee, printed in its welcome line, is the only fee). With `NODE=local` the bundled node runs beside the
|
||||
cards as the verifier: the miner checks the pool's seeds and templates against it, votes with the rig's key through it
|
||||
and submits found blocks there too. Without a node the rig hashes and does not vote. Untested on a Hive rig, like the rest
|
||||
of this package.
|
||||
|
||||
## The dev fee
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
#!/usr/bin/env bash
|
||||
# Igneum Miner for HiveOS: writes igneum.conf from the Flight Sheet. Sourced by Hive (so: return, never exit).
|
||||
# CUSTOM_URL the node the miner mines against: grpc://host:26610 (your own igneumd), or "local" to run the
|
||||
# bundled node on this rig (data under the miner folder, peers from PEERS or the public seed)
|
||||
# bundled node on this rig (data under the miner folder, peers from PEERS or the public seed);
|
||||
# or a POOL, pool://host:4463 (spec 09; the vote key stays this rig's): then NODE=local or
|
||||
# NODE=grpc://host:26610 in the extra config adds a verifier node for voting and seed checks,
|
||||
# and without one the rig hashes and does not vote (pool mode takes no software dev fee)
|
||||
# CUSTOM_TEMPLATE the payout address: 0x followed by 40 hex (an EVM address you hold the key for), optionally
|
||||
# followed by .workername (the label of this rig's keys; defaults to the Hive worker name)
|
||||
# CUSTOM_PASS unused
|
||||
|
|
@ -15,6 +18,7 @@
|
|||
# WORKER=auto auto | cuda | opencl (the GPU worker; auto = cuda on NVIDIA, opencl on AMD)
|
||||
# VOTE=1 sign finality checkpoints (0 = mine without voting)
|
||||
# PEERS=a:26611,b:26611 peers for the bundled node when CUSTOM_URL=local
|
||||
# NODE=local pool mode only: the verifier node (local = the bundled node, or grpc://host:26610)
|
||||
# EXTRA="..." appended to every igneum-miner command line
|
||||
# OVERRIDE={...} the devnet's consensus override, one JSON object on its own line (single or
|
||||
# double quotes around it are fine in the Hive UI); the bundled node (CUSTOM_URL=local)
|
||||
|
|
@ -25,7 +29,7 @@
|
|||
[[ -z "$CUSTOM_CONFIG_FILENAME" ]] && . "$(dirname "${BASH_SOURCE[0]}")/h-manifest.conf"
|
||||
|
||||
[[ -z "$CUSTOM_TEMPLATE" ]] && echo -e "${YELLOW:-}Igneum: the wallet (CUSTOM_TEMPLATE) is empty; it must be 0x followed by 40 hex${NOCOLOR:-}" && return 1
|
||||
[[ -z "$CUSTOM_URL" ]] && echo -e "${YELLOW:-}Igneum: the pool URL (CUSTOM_URL) is empty; grpc://host:26610 of your node, or local${NOCOLOR:-}" && return 1
|
||||
[[ -z "$CUSTOM_URL" ]] && echo -e "${YELLOW:-}Igneum: the pool URL (CUSTOM_URL) is empty; grpc://host:26610 of your node, local, or pool://host:4463${NOCOLOR:-}" && return 1
|
||||
|
||||
wallet="${CUSTOM_TEMPLATE%%.*}"
|
||||
label="${CUSTOM_TEMPLATE#*.}"
|
||||
|
|
@ -37,7 +41,7 @@ if ! [[ "$wallet" =~ ^0x[0-9a-fA-F]{40}$ ]]; then
|
|||
fi
|
||||
|
||||
# defaults, then the user's KEY=VALUE lines
|
||||
DEV_FEE=1; IDENTITIES=auto; WORKER=auto; VOTE=1; PEERS=""; EXTRA=""; OVERRIDE=""
|
||||
DEV_FEE=1; IDENTITIES=auto; WORKER=auto; VOTE=1; PEERS=""; EXTRA=""; OVERRIDE=""; NODE=""
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" || "$line" == \#* ]] && continue
|
||||
if [[ "$line" == OVERRIDE=* ]]; then # the whole line: JSON may carry spaces; quotes around it are stripped
|
||||
|
|
@ -48,7 +52,7 @@ while IFS= read -r line; do
|
|||
[[ -z "$kv" ]] && continue
|
||||
key="${kv%%=*}"; val="${kv#*=}"
|
||||
case "$key" in
|
||||
DEV_FEE|IDENTITIES|WORKER|VOTE|PEERS|EXTRA) printf -v "$key" '%s' "$val" ;;
|
||||
DEV_FEE|IDENTITIES|WORKER|VOTE|PEERS|EXTRA|NODE) printf -v "$key" '%s' "$val" ;;
|
||||
*) echo "Igneum: unknown setting '$key' ignored" ;;
|
||||
esac
|
||||
done < <(printf '%s\n' "$line" | tr ' ' '\n' | sed 's/^"//; s/"$//')
|
||||
|
|
@ -93,9 +97,16 @@ if [[ "$IDENTITIES" == "auto" ]]; then
|
|||
[[ $n == 0 ]] && ident_summary="no VRAM readable, 8 per card"
|
||||
fi
|
||||
|
||||
url="$CUSTOM_URL"
|
||||
[[ "$url" == "local" ]] && url="local"
|
||||
[[ "$url" != "local" && "$url" != grpc://* ]] && url="grpc://$url"
|
||||
url="$CUSTOM_URL"; pool_url=""
|
||||
if [[ "$url" == pool://* || "$url" == stratum+tcp://* ]]; then
|
||||
# pool mode: the Flight Sheet's pool URL is the pool; the node (if any) comes from NODE=
|
||||
pool_url="${url#pool://}"; pool_url="${pool_url#stratum+tcp://}"; pool_url="${pool_url%/}"
|
||||
url="${NODE:-none}"
|
||||
[[ "$url" != "local" && "$url" != "none" && "$url" != grpc://* ]] && url="grpc://$url"
|
||||
else
|
||||
[[ "$url" == "local" ]] && url="local"
|
||||
[[ "$url" != "local" && "$url" != grpc://* ]] && url="grpc://$url"
|
||||
fi
|
||||
|
||||
sq() { local v="${1//\'/\'\\\'\'}"; printf "'%s'" "$v"; } # single-quoted for sourcing: JSON and flags carry shell characters
|
||||
mkdir -p "$(dirname "$CUSTOM_CONFIG_FILENAME")"
|
||||
|
|
@ -111,5 +122,10 @@ VOTE=$VOTE
|
|||
PEERS=$PEERS
|
||||
EXTRA=$(sq "$EXTRA")
|
||||
OVERRIDE=$(sq "$OVERRIDE")
|
||||
POOL_URL=$pool_url
|
||||
CONF
|
||||
echo "Igneum: config written to $CUSTOM_CONFIG_FILENAME (node $url, wallet ${wallet:0:8}..., dev fee ${DEV_FEE}%, identities ${ident_summary:-$IDENTITIES per card}, override ${OVERRIDE:+set}${OVERRIDE:-NONE: a local node will be refused by devnet peers})"
|
||||
if [[ -n "$pool_url" ]]; then
|
||||
echo "Igneum: config written to $CUSTOM_CONFIG_FILENAME (POOL $pool_url, verifier node $url, wallet ${wallet:0:8}..., worker $label; no software dev fee in pool mode; override ${OVERRIDE:+set}${OVERRIDE:-NONE: a local verifier node will be refused by devnet peers})"
|
||||
else
|
||||
echo "Igneum: config written to $CUSTOM_CONFIG_FILENAME (node $url, wallet ${wallet:0:8}..., dev fee ${DEV_FEE}%, identities ${ident_summary:-$IDENTITIES per card}, override ${OVERRIDE:+set}${OVERRIDE:-NONE: a local node will be refused by devnet peers})"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ pids=()
|
|||
cleanup() { say "stopping"; for p in "${pids[@]}"; do kill "$p" 2>/dev/null; done; pkill -TERM -P $$ 2>/dev/null; sleep 2; for p in "${pids[@]}"; do kill -9 "$p" 2>/dev/null; done; pkill -KILL -P $$ 2>/dev/null; exit 0; }
|
||||
trap cleanup INT TERM
|
||||
|
||||
# 1. the node
|
||||
# 1. the node (in pool mode, NODE_URL is "none" unless the Flight Sheet asked for a verifier node with NODE=)
|
||||
POOL_URL="${POOL_URL:-}"
|
||||
if [[ "$NODE_URL" == "local" ]]; then
|
||||
NODE_URL="grpc://127.0.0.1:26610"
|
||||
peers=()
|
||||
|
|
@ -43,11 +44,13 @@ if [[ "$NODE_URL" == "local" ]]; then
|
|||
${override[@]+"${override[@]}"} --nodnsseed --disable-upnp --nologfiles --yes >> "$CUSTOM_LOG_BASENAME.node.log" 2>&1 &
|
||||
pids+=($!)
|
||||
fi
|
||||
say "node $NODE_URL; waiting for it to answer"
|
||||
for _ in $(seq 1 60); do
|
||||
"$BIN/igneum-miner" watch 1 "$NODE_URL" >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
if [[ "$NODE_URL" != "none" ]]; then
|
||||
say "node $NODE_URL; waiting for it to answer"
|
||||
for _ in $(seq 1 60); do
|
||||
"$BIN/igneum-miner" watch 1 "$NODE_URL" >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
fi
|
||||
if [[ -f "$CUSTOM_LOG_BASENAME.node.log" ]]; then
|
||||
# the switch lines and the digest the node printed at start, so the operator can compare the digest with the one
|
||||
# on the downloads page (another digest = refused by every peer)
|
||||
|
|
@ -55,6 +58,7 @@ if [[ -f "$CUSTOM_LOG_BASENAME.node.log" ]]; then
|
|||
while IFS= read -r l; do say "node: $l"; n=$((n + 1)); done < <(grep -E 'override params file|from the override file|Consensus params digest' "$CUSTOM_LOG_BASENAME.node.log" | head -12)
|
||||
[[ $n == 0 ]] && say "node: no digest line yet in $CUSTOM_LOG_BASENAME.node.log (an older node, or not started); check it by hand"
|
||||
fi
|
||||
[[ -n "$POOL_URL" ]] && say "pool mode: shares to $POOL_URL, payout $WALLET, worker $LABEL-gpuN, verifier node $NODE_URL (the vote key stays this rig's; no software dev fee)"
|
||||
|
||||
# 2. the GPUs: Hive's gpu-detect when present, else the driver tools
|
||||
nv=0; amd=0
|
||||
|
|
@ -68,7 +72,7 @@ say "GPUs: $nv NVIDIA, $amd AMD (worker setting: $WORKER)"
|
|||
[[ "$nv" == "0" && "$amd" == "0" ]] && say "no GPU found; nothing to mine with" && sleep 30 && exit 1
|
||||
|
||||
# 3. the hourly program pack from the node (the workers read it with --pack; the miner writes the next one to packs/prepare)
|
||||
export_pack() { rm -rf "$HERE/packs/devnet"; "$BIN/igneum-miner" export-pack "$NODE_URL" "$HERE/packs/devnet" >> "$MAIN" 2>&1; }
|
||||
export_pack() { [[ "$NODE_URL" == "none" ]] && return 0; rm -rf "$HERE/packs/devnet"; "$BIN/igneum-miner" export-pack "$NODE_URL" "$HERE/packs/devnet" >> "$MAIN" 2>&1; }
|
||||
export_pack || say "pack export failed; the miners retry"
|
||||
|
||||
# 4. one miner per GPU, restarted on exit (exit 42 = the program changed and the worker cannot prepare: re-export the pack)
|
||||
|
|
@ -77,10 +81,15 @@ run_gpu() {
|
|||
local log="$CUSTOM_LOG_BASENAME.gpu$idx.log"
|
||||
local args=(mine "$NODE_URL" 1 100000000 "$LABEL-gpu$idx" --worker "$BIN/$worker" --worker-args "--device $dev --pack packs/devnet"
|
||||
--prepare-packs packs/prepare --exit-on-seed-change --evm-address "$WALLET" --payout-label "$LABEL-gpu$idx" --status-secs 30)
|
||||
local identv="IDENTITIES_GPU$idx" ident="$IDENTITIES"; [[ -n "${!identv:-}" ]] && ident="${!identv}" # per-card from h-config.sh, else the fallback
|
||||
[[ "$ident" -gt 1 ]] && args+=(--identities "$ident")
|
||||
if [[ -n "$POOL_URL" ]]; then
|
||||
# pool mode: one vote key per operator (spec 9.6), the worker name labels the card, no software dev fee
|
||||
args+=(--pool "$POOL_URL" --worker-name "$LABEL-gpu$idx")
|
||||
else
|
||||
local identv="IDENTITIES_GPU$idx" ident="$IDENTITIES"; [[ -n "${!identv:-}" ]] && ident="${!identv}" # per-card from h-config.sh, else the fallback
|
||||
[[ "$ident" -gt 1 ]] && args+=(--identities "$ident")
|
||||
[[ "$DEV_FEE" != "1" ]] && args+=(--dev-fee "$DEV_FEE")
|
||||
fi
|
||||
[[ "$VOTE" == "0" ]] && args+=(--no-vote)
|
||||
[[ "$DEV_FEE" != "1" ]] && args+=(--dev-fee "$DEV_FEE")
|
||||
[[ -n "$EXTRA" ]] && args+=($EXTRA)
|
||||
while :; do
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) gpu$idx ($tag $dev): igneum-miner ${args[*]}" >> "$log"
|
||||
|
|
|
|||
2
pool/.gitignore
vendored
Normal file
2
pool/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
target/
|
||||
data/
|
||||
6462
pool/Cargo.lock
generated
Normal file
6462
pool/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
56
pool/Cargo.toml
Normal file
56
pool/Cargo.toml
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
[package]
|
||||
name = "igneum-pool"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Igneum mining pool v0: spec 09 pool protocol over newline JSON, vardiff, CPU warp-verified shares, PPLNS payouts on the EVM side, the stats API WhatToMine and MiningPoolStats read"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
# A standalone workspace. The node fork is read by path from ../vendor/igneum-node (the fork checkout, or a symlink
|
||||
# to the fork worktree that carries the pool-mode miner); igneum-pow is the repository's own crate.
|
||||
[workspace]
|
||||
|
||||
[[bin]]
|
||||
name = "igneum-pool"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
kaspa-grpc-client = { path = "../vendor/igneum-node/rpc/grpc/client" }
|
||||
kaspa-rpc-core = { path = "../vendor/igneum-node/rpc/core" }
|
||||
kaspa-consensus-core = { path = "../vendor/igneum-node/consensus/core" }
|
||||
kaspa-hashes = { path = "../vendor/igneum-node/crypto/hashes" }
|
||||
kaspa-addresses = { path = "../vendor/igneum-node/crypto/addresses" }
|
||||
kaspa-notify = { path = "../vendor/igneum-node/notify" }
|
||||
kaspa-pow = { path = "../vendor/igneum-node/consensus/pow", features = ["igneum-pow"] }
|
||||
igneum-pow = { path = "../igneum-pow" }
|
||||
tokio = { version = "1.42", features = ["rt-multi-thread", "macros", "time", "net", "io-util", "sync", "signal", "fs"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
hex = "0.4"
|
||||
rand = "0.8"
|
||||
async-channel = "2"
|
||||
k256 = { version = "0.13", features = ["ecdsa"] }
|
||||
sha3 = "0.10"
|
||||
alloy-consensus = { version = "2.5", default-features = false, features = ["k256", "std"] }
|
||||
alloy-primitives = { version = "1.7", default-features = false, features = ["std", "k256", "rlp"] }
|
||||
alloy-eips = { version = "2.5", default-features = false, features = ["std"] }
|
||||
alloy-signer = { version = "2.5", default-features = false }
|
||||
alloy-signer-local = { version = "2.5", default-features = false }
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
overflow-checks = true
|
||||
|
||||
# The share verifier fills a 256 MiB cache; unoptimised tests would take minutes
|
||||
[profile.dev.package.igneum-pow]
|
||||
opt-level = 3
|
||||
[profile.dev.package.kaspa-pow]
|
||||
opt-level = 2
|
||||
[profile.dev.package.kaspa-consensus-core]
|
||||
opt-level = 2
|
||||
[profile.dev.package.kaspa-hashes]
|
||||
opt-level = 2
|
||||
[profile.dev.package.kaspa-math]
|
||||
opt-level = 2
|
||||
[profile.dev.package.blake2b_simd]
|
||||
opt-level = 2
|
||||
162
pool/README.md
Normal file
162
pool/README.md
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
# igneum-pool: the Igneum mining pool, version 0
|
||||
|
||||
A public mining pool for Igneum (`docs/spec/09-pool-protocol.md`, `docs/plans/pool.md`). It talks to one or more
|
||||
`igneumd` nodes, builds a template per member with the member's own vote key in the header, hands out jobs over the
|
||||
spec 09 protocol (newline JSON over TCP), sets a share target per member so each sends about one share every 10 s,
|
||||
verifies every share on the CPU warp verifier the node uses, submits found blocks to every node, pays PPLNS from the
|
||||
pool's coinbase address on the EVM side with a configurable fee (default 1%), and serves the stats API that
|
||||
WhatToMine and MiningPoolStats read plus a page.
|
||||
|
||||
Built 5 October 2026 on branch `pool-v0`. Nothing is deployed. The numbers below come from the private fast-time
|
||||
network run in `docs/plans/pool.md`.
|
||||
|
||||
## Build
|
||||
|
||||
The crate reads the node fork by path from `../vendor/igneum-node` (the fork checkout, or a symlink to the fork
|
||||
worktree that carries the pool-mode miner, branch `pool-v0` of the fork) and `../igneum-pow` from this repository.
|
||||
|
||||
```
|
||||
cd pool
|
||||
cargo build --release # target/release/igneum-pool, about 2 min warm on the Mac
|
||||
cargo test --release # vardiff, PPLNS, share validation against the warp verifier, the API fixture
|
||||
```
|
||||
|
||||
The miner with pool mode: `cargo build --release -p igneum-miner` in the fork worktree (branch `pool-v0`).
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
igneum-pool --node grpc://127.0.0.1:26610 --evm-rpc http://127.0.0.1:26790 \
|
||||
--listen 0.0.0.0:4463 --http 127.0.0.1:4480 --data-dir /var/lib/igneum-pool \
|
||||
--network devnet --fee-percent 1 --min-payout 1 --pplns-window 2 --payout-interval-s 60 \
|
||||
--name "Igneum pool" --public-url pool.example.net:4463 [--dry-run]
|
||||
```
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `--node` | `grpc://127.0.0.1:26610` | gRPC of a node; repeat for more. The first builds templates and confirms blocks; every one receives found blocks |
|
||||
| `--evm-rpc` | `http://127.0.0.1:26790` (devnet), 26890 testnet, 8545 mainnet | the first node's eth_ JSON-RPC, for payouts |
|
||||
| `--listen` | `0.0.0.0:4463` (devnet), 4462 testnet, 4461 mainnet | where members connect (spec 9.3 ports) |
|
||||
| `--http` | `127.0.0.1:4480` | the stats API and the page; put a TLS terminator in front |
|
||||
| `--data-dir` | `data` | `state.json` (the ledger snapshot) and the payout key |
|
||||
| `--payout-key` | `<data-dir>/payout-key.json` | the pool's secp256k1 key, created on first start, mode 0600 |
|
||||
| `--fee-percent` | `1` | the pool fee, taken from each confirmed block's reward before the PPLNS split |
|
||||
| `--min-payout` | `1.0` IGN | a member's balance is paid once it reaches this |
|
||||
| `--pplns-window` | `2.0` blocks | the window, in blocks of expected work (shares weigh `2^-s`) |
|
||||
| `--payout-interval-s` | `60` | a payout round every so often, at most 16 transfers per round |
|
||||
| `--dry-run` | off | compute and record payouts, send nothing |
|
||||
| `--network` | `devnet` | devnet, testnet or mainnet: chain id (4463, 4462, 4461), default ports |
|
||||
| `--share-interval-s` | `10` | vardiff target: one share per member per interval |
|
||||
| `--min-shift`, `--max-shift` | `0`, `60` | bounds on the share shift; the saturation cap always applies |
|
||||
| `--stale-grace-ms` | `2000` | a share on a superseded job inside the grace still pays |
|
||||
| `--orphan-after-daa` | `120` | a pending block this far behind the virtual and not blue is an orphan |
|
||||
| `--verify-threads` | `2` | share checks running at once on the CPU |
|
||||
|
||||
At start the pool prints its payout address. That address is the pool's coinbase address: every template names it in
|
||||
the `IGNA` field, so the execution layer credits it 80% of every blue block the pool finds. The 20% proving share goes
|
||||
to the proving escrow by the chain's own rule (`consensus/core/src/igneum.rs`, `igneum/exec/src/executor.rs`); the
|
||||
pool never touches it and cannot.
|
||||
|
||||
## What the operator must secure
|
||||
|
||||
- **The payout key** (`payout-key.json`). It holds every reward the pool earns until the payout round sends it on.
|
||||
Back it up off the box before the first block. Anyone with the file can empty the pool. Keep the data directory
|
||||
at mode 0700, the file at 0600 (the pool sets 0600 on creation), and never put it in a repository or a log.
|
||||
- The pool's HTTP port behind a TLS terminator (Caddy or nginx) with the page on one hostname and the member port
|
||||
(4463) reachable; v0 speaks plain TCP to members, so the terminator does not cover shares yet (TLS for the member
|
||||
port is the v0 gap named in `docs/plans/pool.md`).
|
||||
- The node's RPC (`--rpclisten`) and EVM RPC (`--evm-rpclisten`) on loopback only. The pool talks to them locally.
|
||||
- `state.json`: the ledger. It is rewritten every 15 s; a lost file loses unpaid balances since the last payout, so
|
||||
back it up with the key.
|
||||
|
||||
## Deploy on one Hetzner box (the seed-node pattern)
|
||||
|
||||
The seed nodes (`infra/seed-nodes/README.md`) are Hetzner VMs with a fixed IPv4, `igneumd` as a systemd unit, RPC on
|
||||
loopback, built by the cross-compile on the Mac (`infra/cross/build-linux.sh`). A pool box is the same plus this
|
||||
binary and two more ports.
|
||||
|
||||
1. Create the VM as a seed would be created: `SEED_NAME=igneum-pool-1 SEED_TYPE=cpx31 ./create-seed.sh` from
|
||||
`infra/seed-nodes` (the firewall script opens 22 and 26611; add 4463 and 443). The seeds run on cx23 (2 vCPU, 4 GB,
|
||||
USD 6.49 a month net plus the IPv4, approximate from the seed plan); a pool wants a core for the node, a core for
|
||||
share checks (one share per 10 s per member, 0.44 ms each on an M5 Max core, so a server core at roughly half that
|
||||
rate covers thousands of members, approximate) and 8 GB for the node plus the pool's 256 MiB cache per day seed.
|
||||
A cpx31 (4 vCPU, 8 GB) is about USD 14 a month net (approximate, price list not re-read tonight).
|
||||
2. Provision the node exactly as `provision-seed.sh` does, with `--utxoindex` added and `--evm-rpclisten=127.0.0.1:26790`
|
||||
(the pool needs the EVM RPC for payouts). The node must be the devnet-v4 line or later (it reports `pow_epoch` with
|
||||
every template; the pool refuses to run against one that does not).
|
||||
3. Cross-compile `igneum-pool` with the same `cargo-zigbuild` target as the node (`x86_64-unknown-linux-gnu.2.36`)
|
||||
from `pool/`, copy it to `/opt/igneum/bin/igneum-pool`, and install the unit below. Re-stamp copied sources with
|
||||
`touch` before any build on the far side (the stale-build rule in CLAUDE.md); nothing is built on the server.
|
||||
4. `caddy` (or nginx) in front of `127.0.0.1:4480` for `https://pool.<domain>/`; the member port 4463 open in the
|
||||
Hetzner firewall.
|
||||
5. First start with `--dry-run` for a day, read `/api/blocks` and `/api/payments`, then restart without it.
|
||||
|
||||
```
|
||||
# /etc/systemd/system/igneum-pool.service
|
||||
[Unit]
|
||||
Description=Igneum mining pool
|
||||
After=network-online.target igneumd.service
|
||||
Requires=igneumd.service
|
||||
|
||||
[Service]
|
||||
User=igneum
|
||||
EnvironmentFile=/etc/igneum/pool.env
|
||||
ExecStart=/opt/igneum/bin/igneum-pool --node grpc://127.0.0.1:26610 --evm-rpc http://127.0.0.1:26790 \
|
||||
--listen 0.0.0.0:4462 --http 127.0.0.1:4480 --data-dir /var/lib/igneum-pool --network testnet \
|
||||
--fee-percent 1 --min-payout 1 --name "Igneum pool" --public-url pool.igneum.network:4462
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Costs, approximate: the VM (USD 6.49 to 14 a month net by type), the IPv4 (Hetzner's primary IPv4 price, a few
|
||||
dollars), outbound traffic inside Hetzner's included allowance at a few hundred members (a template is a few KB per
|
||||
member per second in mode A; 1,000 members at 10 KB/s each is 10 MB/s, which is where mode B or a template RPC that
|
||||
takes a list of keys comes in, O-9.4). Measure before quoting.
|
||||
|
||||
## Connect a miner
|
||||
|
||||
```
|
||||
igneum-miner mine grpc://127.0.0.1:26610 1 100000000 rig1 --pool pool.example.net:4463 \
|
||||
--evm-address 0xYOURADDRESS --worker-name rig1-gpu0 --worker ./igneum-worker-cuda --worker-args "--device 0 --pack packs/devnet"
|
||||
igneum-miner mine none 4 100000000 rig1 --pool pool.example.net:4463 --evm-address 0xYOURADDRESS --worker-name rig1-cpu
|
||||
```
|
||||
|
||||
The first argument is the member's own node (its verifier: seed checks, voting with its own key, a second road for
|
||||
found blocks) or `none` (hashes, does not vote). `--evm-address` is the payout address, `--worker-name` the label on the
|
||||
page. HiveOS: Flight Sheet pool URL `pool://pool.example.net:4463` (`packaging/hive/README.md`). In pool mode the miner
|
||||
software takes no dev fee; the pool's fee is in the welcome line the miner prints.
|
||||
|
||||
## The API
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `/api/stats` | `pool` (hashrate, miners, workers, shares, blocks 24 h and total, last block, fee, min payout, luck, effort, paid, share check cost) and `network` (chain id, difficulty, hashrate, DAA, reward) |
|
||||
| `/api/blocks?limit=N` | blocks found: hash, DAA, time, finder, status (pending, confirmed, orphan), reward, effort, the PPLNS split |
|
||||
| `/api/miners/<address>` | hashrate (10 min, 1 h, reported), shares, workers (with shift, proving flag, online), balance, paid, payments |
|
||||
| `/api/payments?limit=N` | every payout with its transaction hash and receipt status |
|
||||
| `/api/pool-stats` | the flat camelCase object pool dashboards poll (hashrate, miners, workers, blocks, lastBlock, fee, minPayout) |
|
||||
|
||||
The field lists are constants in `src/api.rs`; `cargo test` checks the fixtures in `tests/fixtures/` against them.
|
||||
Captured responses from the measured run are the fixtures.
|
||||
|
||||
## Files
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `src/main.rs` | start-up, tasks, the status line, shutdown snapshot |
|
||||
| `src/config.rs` | flags and defaults |
|
||||
| `src/protocol.rs` | the spec 09 messages, newline JSON |
|
||||
| `src/server.rs` | member connections: hello, authorize (proof of possession checked), shares, solutions, stats |
|
||||
| `src/node.rs` | templates per member, jobs, block submission, the blue-set walk that confirms or orphans, network numbers, vardiff ticks |
|
||||
| `src/verify.rs` | the share check on the warp verifier, the spec's codes |
|
||||
| `src/vardiff.rs` | the share shift per member |
|
||||
| `src/pplns.rs` | the window, the split, the distribution with the fee |
|
||||
| `src/payout.rs` | the payout key, EIP-1559 transfers through eth_ JSON-RPC, receipts, dry run |
|
||||
| `src/state.rs` | the ledger and its snapshot |
|
||||
| `src/api.rs` | the HTTP server, the routes, the field contracts |
|
||||
| `web/index.html` | the page |
|
||||
| `tools/measure.mjs` | the private-network measurement |
|
||||
279
pool/src/api.rs
Normal file
279
pool/src/api.rs
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
//! The stats API in the shape WhatToMine and MiningPoolStats read, and the pool's page. A small HTTP/1.1 server:
|
||||
//! GET only, JSON, `Connection: close`, CORS open. The field lists are the contract the fixture test asserts.
|
||||
|
||||
use crate::pool::Pool;
|
||||
use crate::state::{ign, unix_ms};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
pub const STATS_FIELDS: &[&str] = &["ok", "now", "pool", "network", "source"];
|
||||
pub const POOL_FIELDS: &[&str] = &[
|
||||
"name", "url", "address", "algorithm", "scheme", "fee_percent", "min_payout_ign", "pplns_window_blocks", "hashrate", "hashrate_unit", "hashrate_1h",
|
||||
"miners", "workers", "shares", "blocks_24h", "blocks_confirmed_24h", "blocks_orphaned_24h", "blocks_total", "blocks_confirmed_total", "blocks_orphaned_total",
|
||||
"last_block", "effort_current", "luck_24h", "paid_24h_ign", "paid_total_ign", "pending_balance_ign", "pool_fee_total_ign", "uptime_s", "share_check_ms", "dry_run",
|
||||
];
|
||||
pub const NETWORK_FIELDS: &[&str] =
|
||||
&["name", "chain_id", "difficulty", "hashrate", "hashrate_unit", "daa_score", "block_count", "blue_score", "block_reward_ign", "miner_reward_ign", "block_time_target_s", "synced", "node_version", "epoch_seed", "epoch_index", "updated_ms"];
|
||||
pub const BLOCK_FIELDS: &[&str] = &["hash", "daa_score", "blue_score", "time", "ts_ms", "finder", "worker", "status", "reward_ign", "fee_ign", "effort", "payees", "confirmed_ms", "nonce", "shift"];
|
||||
pub const MINER_FIELDS: &[&str] = &["ok", "address", "hashrate", "hashrate_1h", "hashrate_reported", "shares", "workers", "blocks", "balance_ign", "paid_ign", "payments", "last_share_ms", "first_seen_ms", "online", "proving"];
|
||||
pub const PAYMENT_FIELDS: &[&str] = &["time", "ts_ms", "address", "amount_ign", "tx_hash", "status", "dry_run", "nonce"];
|
||||
pub const HIVE_FIELDS: &[&str] = &["hashrate", "miners", "workers", "blocks", "lastBlock", "fee", "minPayout", "scheme", "symbol", "algo", "difficulty", "networkHashrate", "height"];
|
||||
|
||||
fn block_json(b: &crate::state::BlockRec) -> Value {
|
||||
json!({
|
||||
"hash": b.hash, "daa_score": b.daa_score, "blue_score": b.blue_score,
|
||||
"time": iso(b.found_ms), "ts_ms": b.found_ms, "finder": b.finder, "worker": b.worker, "status": b.status,
|
||||
"reward_ign": ign(b.reward_wei), "fee_ign": ign(b.fee_wei), "effort": round(b.effort, 4),
|
||||
"payees": b.payees.iter().map(|p| json!({"address": p.address, "fraction": round(p.fraction, 6)})).collect::<Vec<_>>(),
|
||||
"confirmed_ms": b.confirmed_ms, "nonce": b.nonce, "shift": b.shift,
|
||||
})
|
||||
}
|
||||
|
||||
fn payment_json(p: &crate::state::PaymentRec) -> Value {
|
||||
json!({ "time": iso(p.at_ms), "ts_ms": p.at_ms, "address": p.address, "amount_ign": ign(p.amount_wei), "tx_hash": p.tx_hash, "status": p.status, "dry_run": p.dry_run, "nonce": p.nonce })
|
||||
}
|
||||
|
||||
fn iso(ms: u64) -> String {
|
||||
// UTC, seconds; no chrono dependency
|
||||
let s = ms / 1000;
|
||||
let (days, rem) = (s / 86400, s % 86400);
|
||||
let (h, m, sec) = (rem / 3600, (rem % 3600) / 60, rem % 60);
|
||||
// civil from days (Howard Hinnant's algorithm)
|
||||
let z = days as i64 + 719468;
|
||||
let era = z.div_euclid(146097);
|
||||
let doe = z.rem_euclid(146097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if mo <= 2 { y + 1 } else { y };
|
||||
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{sec:02}Z")
|
||||
}
|
||||
|
||||
fn round(v: f64, places: i32) -> f64 {
|
||||
let f = 10f64.powi(places);
|
||||
(v * f).round() / f
|
||||
}
|
||||
|
||||
pub fn stats(pool: &Pool) -> Value {
|
||||
let net = pool.net.lock().unwrap().clone();
|
||||
let (miners, workers) = pool.online();
|
||||
let s = pool.state.lock().unwrap();
|
||||
let (f24, c24, o24) = s.blocks_in(86400);
|
||||
let last = s.blocks.iter().rev().find(|b| b.status != "orphan").or(s.blocks.last());
|
||||
let (n, mean, p50, p99, max) = s.check_cost();
|
||||
let pending: u128 = s.balances.values().sum();
|
||||
let paid_total: u128 = s.paid.values().sum();
|
||||
// luck over 24 h: expected blocks from the weight of accepted shares against confirmed blocks found
|
||||
let shares_24h_weight: f64 = s.blocks.iter().filter(|b| b.found_ms >= unix_ms().saturating_sub(86_400_000)).map(|b| b.effort).sum::<f64>() + s.pplns.since_block;
|
||||
let luck = if f24 > 0 && shares_24h_weight > 0.0 { f24 as f64 / shares_24h_weight } else { 0.0 };
|
||||
json!({
|
||||
"ok": true,
|
||||
"now": iso(unix_ms()),
|
||||
"pool": {
|
||||
"name": pool.cfg.name, "url": pool.cfg.public_url, "address": pool.pool_address_hex,
|
||||
"algorithm": "igneum",
|
||||
"scheme": "PPLNS", "fee_percent": pool.cfg.fee_percent, "min_payout_ign": pool.cfg.min_payout_ign, "pplns_window_blocks": pool.cfg.pplns_window_blocks,
|
||||
"hashrate": round(s.hashrate(600, None, None), 0), "hashrate_unit": "H/s", "hashrate_1h": round(s.hashrate(3600, None, None), 0),
|
||||
"miners": miners, "workers": workers,
|
||||
"shares": { "accepted": s.accepted, "stale": s.stale, "rejected": s.rejected, "window_weight_blocks": round(s.pplns.total_weight, 4), "window_shares": s.pplns.shares.len() },
|
||||
"blocks_24h": f24, "blocks_confirmed_24h": c24, "blocks_orphaned_24h": o24,
|
||||
"blocks_total": s.blocks_found, "blocks_confirmed_total": s.blocks_confirmed, "blocks_orphaned_total": s.blocks_orphaned,
|
||||
"last_block": last.map(block_json),
|
||||
"effort_current": round(s.pplns.since_block, 4), "luck_24h": round(luck, 4),
|
||||
"paid_24h_ign": ign(s.paid_in(86400)), "paid_total_ign": ign(paid_total), "pending_balance_ign": ign(pending), "pool_fee_total_ign": ign(s.pool_fee_wei),
|
||||
"uptime_s": pool.started.elapsed().as_secs(),
|
||||
"share_check_ms": { "count": n, "mean": round(mean, 3), "p50": round(p50, 3), "p99": round(p99, 3), "max": round(max, 3) },
|
||||
"dry_run": pool.cfg.dry_run,
|
||||
},
|
||||
"network": {
|
||||
"name": net.network, "chain_id": net.chain_id, "difficulty": net.difficulty, "hashrate": net.hashrate, "hashrate_unit": "H/s",
|
||||
"daa_score": net.daa_score, "block_count": net.block_count, "blue_score": net.blue_score,
|
||||
"block_reward_ign": net.block_reward_ign, "miner_reward_ign": net.miner_reward_ign, "block_time_target_s": 1,
|
||||
"synced": net.synced, "node_version": net.node_version, "epoch_seed": net.epoch_seed, "epoch_index": net.epoch_index, "updated_ms": net.updated_ms,
|
||||
},
|
||||
"source": "igneum-pool v0: shares verified on the CPU warp verifier; network numbers from the pool's node (getBlockDagInfo, estimateNetworkHashesPerSecond); reward by spec 2.5 at the node's DAA score",
|
||||
})
|
||||
}
|
||||
|
||||
pub fn blocks(pool: &Pool, limit: usize) -> Value {
|
||||
let s = pool.state.lock().unwrap();
|
||||
json!({ "ok": true, "blocks": s.blocks.iter().rev().take(limit).map(block_json).collect::<Vec<_>>() })
|
||||
}
|
||||
|
||||
pub fn payments(pool: &Pool, limit: usize) -> Value {
|
||||
let s = pool.state.lock().unwrap();
|
||||
json!({ "ok": true, "payments": s.payments.iter().rev().take(limit).map(payment_json).collect::<Vec<_>>() })
|
||||
}
|
||||
|
||||
pub fn miner(pool: &Pool, address: &str) -> Value {
|
||||
let address = address.to_lowercase();
|
||||
if address.len() != 42 || !address.starts_with("0x") || !address[2..].chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return json!({ "ok": false, "error": "address is 0x followed by 40 hex" });
|
||||
}
|
||||
let live: Vec<(String, f64, u32, bool, u32)> = pool
|
||||
.members_snapshot()
|
||||
.iter()
|
||||
.filter(|m| m.address == address)
|
||||
.map(|m| {
|
||||
let g = m.inner.lock().unwrap();
|
||||
(m.worker.clone(), g.reported_hashrate, g.vardiff.shift, g.proving, g.reported_workers)
|
||||
})
|
||||
.collect();
|
||||
let s = pool.state.lock().unwrap();
|
||||
let rec = s.miners.get(&address).cloned().unwrap_or_default();
|
||||
let workers: Vec<Value> = rec
|
||||
.workers
|
||||
.iter()
|
||||
.map(|(name, w)| {
|
||||
let l = live.iter().find(|x| &x.0 == name);
|
||||
json!({
|
||||
"name": name, "hashrate": round(s.hashrate(600, Some(&address), Some(name)), 0), "accepted": w.accepted, "stale": w.stale, "rejected": w.rejected, "blocks": w.blocks,
|
||||
"last_share_ms": w.last_share_ms, "online": l.is_some(), "shift": l.map(|x| x.2), "proving": l.map(|x| x.3).unwrap_or(false), "hashrate_reported": l.map(|x| x.1),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"ok": true, "address": address,
|
||||
"hashrate": round(s.hashrate(600, Some(&address), None), 0), "hashrate_1h": round(s.hashrate(3600, Some(&address), None), 0),
|
||||
"hashrate_reported": round(live.iter().map(|x| x.1).sum::<f64>(), 0),
|
||||
"shares": { "accepted": rec.accepted, "stale": rec.stale, "rejected": rec.rejected },
|
||||
"workers": workers, "blocks": rec.blocks,
|
||||
"balance_ign": ign(s.balances.get(&address).copied().unwrap_or(0)), "paid_ign": ign(s.paid.get(&address).copied().unwrap_or(0)),
|
||||
"payments": s.payments.iter().rev().filter(|p| p.address == address).take(50).map(payment_json).collect::<Vec<_>>(),
|
||||
"last_share_ms": rec.last_share_ms, "first_seen_ms": rec.first_seen_ms, "online": !live.is_empty(),
|
||||
"proving": live.iter().any(|x| x.3),
|
||||
})
|
||||
}
|
||||
|
||||
/// Hive-style pool stats: the flat camelCase object pool dashboards poll (approximate: Hive fixes no schema for a
|
||||
/// custom pool, so this mirrors the common shape of MiningPoolStats pool JSON).
|
||||
pub fn hive(pool: &Pool) -> Value {
|
||||
let v = stats(pool);
|
||||
let p = &v["pool"];
|
||||
let n = &v["network"];
|
||||
json!({
|
||||
"hashrate": p["hashrate"], "miners": p["miners"], "workers": p["workers"], "blocks": p["blocks_total"],
|
||||
"lastBlock": p["last_block"].get("ts_ms").cloned().unwrap_or(Value::Null), "fee": p["fee_percent"], "minPayout": p["min_payout_ign"],
|
||||
"scheme": "PPLNS", "symbol": "IGN", "algo": "igneum", "difficulty": n["difficulty"], "networkHashrate": n["hashrate"], "height": n["daa_score"],
|
||||
})
|
||||
}
|
||||
|
||||
fn page(pool: &Pool) -> String {
|
||||
include_str!("../web/index.html")
|
||||
.replace("__POOL_NAME__", &pool.cfg.name)
|
||||
.replace("__POOL_URL__", &pool.cfg.public_url)
|
||||
.replace("__POOL_ADDRESS__", &pool.pool_address_hex)
|
||||
.replace("__NETWORK__", &pool.cfg.network)
|
||||
.replace("__FEE__", &format!("{}", pool.cfg.fee_percent))
|
||||
.replace("__MIN_PAYOUT__", &format!("{}", pool.cfg.min_payout_ign))
|
||||
}
|
||||
|
||||
fn respond(status: &str, ctype: &str, body: &[u8]) -> Vec<u8> {
|
||||
let mut v = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nCache-Control: public, max-age=5\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
)
|
||||
.into_bytes();
|
||||
v.extend_from_slice(body);
|
||||
v
|
||||
}
|
||||
|
||||
pub fn route(pool: &Pool, path: &str) -> Vec<u8> {
|
||||
let (path, query) = path.split_once('?').unwrap_or((path, ""));
|
||||
let limit = query.split('&').find_map(|kv| kv.strip_prefix("limit=")).and_then(|v| v.parse::<usize>().ok()).unwrap_or(100).min(1000);
|
||||
let json = |v: Value| respond("200 OK", "application/json; charset=utf-8", v.to_string().as_bytes());
|
||||
match path {
|
||||
"/" | "/index.html" => respond("200 OK", "text/html; charset=utf-8", page(pool).as_bytes()),
|
||||
"/api/stats" => json(stats(pool)),
|
||||
"/api/blocks" => json(blocks(pool, limit)),
|
||||
"/api/payments" => json(payments(pool, limit)),
|
||||
"/api/pool-stats" | "/api/hive" => json(hive(pool)),
|
||||
"/health" => json(json!({"ok": true})),
|
||||
p if p.starts_with("/api/miners/") => json(miner(pool, &p["/api/miners/".len()..])),
|
||||
_ => respond("404 Not Found", "application/json; charset=utf-8", br#"{"ok":false,"error":"not found"}"#),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn serve(pool: Arc<Pool>) {
|
||||
let listener = tokio::net::TcpListener::bind(&pool.cfg.http).await.unwrap_or_else(|e| {
|
||||
eprintln!("cannot listen on {}: {e}", pool.cfg.http);
|
||||
std::process::exit(1)
|
||||
});
|
||||
println!("{} pool: stats API and page on http://{}/", crate::state::unix_ms(), pool.cfg.http);
|
||||
loop {
|
||||
let Ok((sock, _)) = listener.accept().await else { continue };
|
||||
let pool = pool.clone();
|
||||
tokio::spawn(async move {
|
||||
let (rd, mut wr) = sock.into_split();
|
||||
let mut lines = BufReader::new(rd).lines();
|
||||
let first = match tokio::time::timeout(Duration::from_secs(10), lines.next_line()).await {
|
||||
Ok(Ok(Some(l))) => l,
|
||||
_ => return,
|
||||
};
|
||||
// drain the headers
|
||||
while let Ok(Ok(Some(l))) = tokio::time::timeout(Duration::from_secs(2), lines.next_line()).await {
|
||||
if l.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut parts = first.split_whitespace();
|
||||
let method = parts.next().unwrap_or("");
|
||||
let path = parts.next().unwrap_or("/");
|
||||
let out = if method != "GET" && method != "HEAD" {
|
||||
respond("405 Method Not Allowed", "application/json; charset=utf-8", br#"{"ok":false,"error":"GET only"}"#)
|
||||
} else {
|
||||
route(&pool, path)
|
||||
};
|
||||
let _ = wr.write_all(&out).await;
|
||||
let _ = wr.shutdown().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn keys(v: &Value) -> Vec<String> {
|
||||
let mut k: Vec<String> = v.as_object().expect("object").keys().cloned().collect();
|
||||
k.sort();
|
||||
k
|
||||
}
|
||||
fn sorted(f: &[&str]) -> Vec<String> {
|
||||
let mut k: Vec<String> = f.iter().map(|s| s.to_string()).collect();
|
||||
k.sort();
|
||||
k
|
||||
}
|
||||
|
||||
/// The fixture (`tests/fixtures/stats.json`, a response captured from the private network run) has exactly the
|
||||
/// fields the contract names, and the contract matches what the handler builds.
|
||||
#[test]
|
||||
fn fixture_matches_the_field_contract() {
|
||||
let f: Value = serde_json::from_str(include_str!("../tests/fixtures/stats.json")).unwrap();
|
||||
assert_eq!(keys(&f), sorted(STATS_FIELDS));
|
||||
assert_eq!(keys(&f["pool"]), sorted(POOL_FIELDS));
|
||||
assert_eq!(keys(&f["network"]), sorted(NETWORK_FIELDS));
|
||||
assert_eq!(keys(&f["pool"]["last_block"]), sorted(BLOCK_FIELDS));
|
||||
let m: Value = serde_json::from_str(include_str!("../tests/fixtures/miner.json")).unwrap();
|
||||
assert_eq!(keys(&m), sorted(MINER_FIELDS));
|
||||
let p: Value = serde_json::from_str(include_str!("../tests/fixtures/payments.json")).unwrap();
|
||||
assert_eq!(keys(&p["payments"][0]), sorted(PAYMENT_FIELDS));
|
||||
let h: Value = serde_json::from_str(include_str!("../tests/fixtures/pool-stats.json")).unwrap();
|
||||
assert_eq!(keys(&h), sorted(HIVE_FIELDS));
|
||||
// the numbers WhatToMine's form asks for are present and typed
|
||||
assert!(f["pool"]["hashrate"].is_number() && f["pool"]["fee_percent"].is_number() && f["pool"]["min_payout_ign"].is_number());
|
||||
assert!(f["pool"]["miners"].is_number() && f["pool"]["workers"].is_number() && f["pool"]["blocks_24h"].is_number());
|
||||
assert!(f["network"]["difficulty"].is_number() && f["network"]["block_reward_ign"].is_number());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_dates_are_utc() {
|
||||
assert_eq!(iso(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(iso(1_790_985_600_000), "2026-10-03T00:00:00Z");
|
||||
}
|
||||
}
|
||||
141
pool/src/config.rs
Normal file
141
pool/src/config.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
//! Command line and defaults. No clap: the arguments are few and the miner parses by hand too.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
/// gRPC nodes; the first builds templates and confirms blocks, every one receives found blocks
|
||||
pub nodes: Vec<String>,
|
||||
/// The first node's EVM JSON-RPC (payouts, chain id)
|
||||
pub evm_rpc: String,
|
||||
/// Where members connect (spec 9.3: 4461 mainnet, 4462 testnet, 4463 devnet)
|
||||
pub listen: String,
|
||||
/// The stats API and the web page
|
||||
pub http: String,
|
||||
pub data_dir: PathBuf,
|
||||
/// The payout key file (JSON, created when missing, mode 0600); its address is the pool's coinbase address
|
||||
pub payout_key: PathBuf,
|
||||
pub fee_percent: f64,
|
||||
pub min_payout_ign: f64,
|
||||
pub pplns_window_blocks: f64,
|
||||
pub payout_interval_s: u64,
|
||||
pub dry_run: bool,
|
||||
pub network: String,
|
||||
pub share_interval_s: u64,
|
||||
pub min_shift: u32,
|
||||
pub max_shift: u32,
|
||||
pub stale_grace_ms: u64,
|
||||
/// A pending block older than this many DAA scores and not blue is an orphan
|
||||
pub orphan_after_daa: u64,
|
||||
pub name: String,
|
||||
/// The address members connect to, shown on the page (host:port)
|
||||
pub public_url: String,
|
||||
/// Shares sampled at shifts above this are still verified in v0 (spec sample_shift 8 is an allowance, not a duty)
|
||||
pub verify_threads: usize,
|
||||
pub snapshot_interval_s: u64,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn usage() -> ! {
|
||||
eprintln!(
|
||||
"usage: igneum-pool [--node grpc://127.0.0.1:26610]... [--evm-rpc http://127.0.0.1:26790] [--listen 0.0.0.0:4463]\n\
|
||||
\x20 [--http 127.0.0.1:4480] [--data-dir ./data] [--payout-key <file>] [--fee-percent 1] [--min-payout 1.0]\n\
|
||||
\x20 [--pplns-window 2.0] [--payout-interval-s 60] [--dry-run] [--network devnet|testnet|mainnet]\n\
|
||||
\x20 [--share-interval-s 10] [--min-shift 0] [--max-shift 60] [--stale-grace-ms 2000] [--orphan-after-daa 120]\n\
|
||||
\x20 [--name \"Igneum pool\"] [--public-url host:4463] [--verify-threads 2] [--snapshot-interval-s 15]"
|
||||
);
|
||||
std::process::exit(2)
|
||||
}
|
||||
|
||||
pub fn from_args(args: &[String]) -> Config {
|
||||
let mut c = Config {
|
||||
nodes: Vec::new(),
|
||||
evm_rpc: String::new(),
|
||||
listen: String::new(),
|
||||
http: "127.0.0.1:4480".into(),
|
||||
data_dir: PathBuf::from("data"),
|
||||
payout_key: PathBuf::new(),
|
||||
fee_percent: 1.0,
|
||||
min_payout_ign: 1.0,
|
||||
pplns_window_blocks: 2.0,
|
||||
payout_interval_s: 60,
|
||||
dry_run: false,
|
||||
network: "devnet".into(),
|
||||
share_interval_s: 10,
|
||||
min_shift: 0,
|
||||
max_shift: 60,
|
||||
stale_grace_ms: 2000,
|
||||
orphan_after_daa: 120,
|
||||
name: "Igneum pool".into(),
|
||||
public_url: String::new(),
|
||||
verify_threads: 2,
|
||||
snapshot_interval_s: 15,
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let a = args[i].as_str();
|
||||
let mut val = || -> String {
|
||||
i += 1;
|
||||
args.get(i).cloned().unwrap_or_else(|| Config::usage())
|
||||
};
|
||||
match a {
|
||||
"--node" => c.nodes.push(val()),
|
||||
"--evm-rpc" => c.evm_rpc = val(),
|
||||
"--listen" => c.listen = val(),
|
||||
"--http" => c.http = val(),
|
||||
"--data-dir" => c.data_dir = PathBuf::from(val()),
|
||||
"--payout-key" => c.payout_key = PathBuf::from(val()),
|
||||
"--fee-percent" => c.fee_percent = val().parse().unwrap_or_else(|_| Config::usage()),
|
||||
"--min-payout" => c.min_payout_ign = val().parse().unwrap_or_else(|_| Config::usage()),
|
||||
"--pplns-window" => c.pplns_window_blocks = val().parse().unwrap_or_else(|_| Config::usage()),
|
||||
"--payout-interval-s" => c.payout_interval_s = val().parse().unwrap_or_else(|_| Config::usage()),
|
||||
"--dry-run" => c.dry_run = true,
|
||||
"--network" => c.network = val(),
|
||||
"--share-interval-s" => c.share_interval_s = val().parse().unwrap_or_else(|_| Config::usage()),
|
||||
"--min-shift" => c.min_shift = val().parse().unwrap_or_else(|_| Config::usage()),
|
||||
"--max-shift" => c.max_shift = val().parse().unwrap_or_else(|_| Config::usage()),
|
||||
"--stale-grace-ms" => c.stale_grace_ms = val().parse().unwrap_or_else(|_| Config::usage()),
|
||||
"--orphan-after-daa" => c.orphan_after_daa = val().parse().unwrap_or_else(|_| Config::usage()),
|
||||
"--name" => c.name = val(),
|
||||
"--public-url" => c.public_url = val(),
|
||||
"--verify-threads" => c.verify_threads = val().parse::<usize>().unwrap_or_else(|_| Config::usage()).max(1),
|
||||
"--snapshot-interval-s" => c.snapshot_interval_s = val().parse().unwrap_or_else(|_| Config::usage()),
|
||||
"-h" | "--help" => Config::usage(),
|
||||
_ => Config::usage(),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if c.nodes.is_empty() {
|
||||
c.nodes.push("grpc://127.0.0.1:26610".into());
|
||||
}
|
||||
let (port, evm_port) = match c.network.as_str() {
|
||||
"mainnet" => (4461, 8545),
|
||||
"testnet" => (4462, 26890),
|
||||
_ => (4463, 26790),
|
||||
};
|
||||
if c.listen.is_empty() {
|
||||
c.listen = format!("0.0.0.0:{port}");
|
||||
}
|
||||
if c.evm_rpc.is_empty() {
|
||||
c.evm_rpc = format!("http://127.0.0.1:{evm_port}");
|
||||
}
|
||||
if c.payout_key.as_os_str().is_empty() {
|
||||
c.payout_key = c.data_dir.join("payout-key.json");
|
||||
}
|
||||
if c.public_url.is_empty() {
|
||||
c.public_url = c.listen.replace("0.0.0.0", "127.0.0.1");
|
||||
}
|
||||
if c.fee_percent < 0.0 || c.fee_percent > 100.0 || c.pplns_window_blocks <= 0.0 || c.min_shift > c.max_shift {
|
||||
Config::usage();
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
pub fn chain_id(&self) -> u64 {
|
||||
match self.network.as_str() {
|
||||
"mainnet" => 4461,
|
||||
"testnet" => 4462,
|
||||
_ => 4463,
|
||||
}
|
||||
}
|
||||
}
|
||||
183
pool/src/main.rs
Normal file
183
pool/src/main.rs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
//! igneum-pool: a public mining pool for Igneum, version 0 (docs/plans/pool.md, docs/spec/09-pool-protocol.md).
|
||||
//!
|
||||
//! What it does. Connects to one or more nodes, builds a template per member (the member's vote key in the header,
|
||||
//! its key reveal and the pool's payout address in the coinbase), hands jobs to members over the spec 09 protocol
|
||||
//! (newline JSON), sets a share target per member so each sends about one share per 10 s, verifies every share on the
|
||||
//! CPU warp verifier, submits found blocks to every node, pays PPLNS from the pool's coinbase address on the EVM side
|
||||
//! with a configurable fee (default 1%), and serves the stats API and a page.
|
||||
//!
|
||||
//! ```text
|
||||
//! igneum-pool --node grpc://127.0.0.1:26610 --evm-rpc http://127.0.0.1:26790 --listen 0.0.0.0:4463 --http 127.0.0.1:4480 \
|
||||
//! --data-dir /var/lib/igneum-pool --fee-percent 1 --min-payout 1 [--dry-run]
|
||||
//! ```
|
||||
|
||||
mod api;
|
||||
mod config;
|
||||
mod node;
|
||||
mod payout;
|
||||
mod pool;
|
||||
mod pplns;
|
||||
mod protocol;
|
||||
mod server;
|
||||
mod state;
|
||||
mod vardiff;
|
||||
mod verify;
|
||||
|
||||
use config::Config;
|
||||
use kaspa_addresses::{Address, Prefix, Version};
|
||||
use kaspa_grpc_client::GrpcClient;
|
||||
use kaspa_pow::igneum::IgneumEngine;
|
||||
use pool::{NetInfo, Pool};
|
||||
use state::{State, WEI_PER_IGN};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let cfg = Config::from_args(&args);
|
||||
std::fs::create_dir_all(&cfg.data_dir).unwrap_or_else(|e| {
|
||||
eprintln!("data dir {}: {e}", cfg.data_dir.display());
|
||||
std::process::exit(1)
|
||||
});
|
||||
let signer = payout::load_or_create_key(&cfg.payout_key).unwrap_or_else(|e| {
|
||||
eprintln!("payout key: {e}");
|
||||
std::process::exit(1)
|
||||
});
|
||||
let pool_address: [u8; 20] = signer.address().into_array();
|
||||
let pool_address_hex = format!("0x{}", hex::encode(pool_address));
|
||||
let evm = payout::EvmRpc::new(&cfg.evm_rpc).unwrap_or_else(|e| {
|
||||
eprintln!("--evm-rpc: {e}");
|
||||
std::process::exit(1)
|
||||
});
|
||||
// nodes
|
||||
let mut clients = Vec::new();
|
||||
for url in &cfg.nodes {
|
||||
match tokio::time::timeout(Duration::from_secs(10), GrpcClient::connect(url.clone())).await {
|
||||
Ok(Ok(c)) => clients.push(Arc::new(c)),
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("node {url}: {e}");
|
||||
std::process::exit(1)
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("node {url}: connect timed out");
|
||||
std::process::exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
let node = clients.remove(0);
|
||||
// the UTXO-side coinbase address: derived from the pool's EVM address; the execution layer pays the EVM side
|
||||
// (igneum/exec/src/executor.rs), the UTXO output is not spent by v0 (docs/plans/pool.md)
|
||||
let prefix = match cfg.network.as_str() {
|
||||
"mainnet" => Prefix::Mainnet,
|
||||
"testnet" => Prefix::Testnet,
|
||||
_ => Prefix::Devnet,
|
||||
};
|
||||
let pay_address = {
|
||||
let mut h = kaspa_hashes::BlockHash::new();
|
||||
use kaspa_hashes::HasherBase;
|
||||
h.update(b"igneum-pool-utxo-payout-v0").update(pool_address);
|
||||
Address::new(prefix, Version::PubKey, &h.finalize().as_bytes())
|
||||
};
|
||||
let state_path = cfg.data_dir.join("state.json");
|
||||
let pool = Arc::new(Pool {
|
||||
engine: Arc::new(IgneumEngine::new()),
|
||||
state: Mutex::new(State::load(&state_path, cfg.pplns_window_blocks)),
|
||||
members: Mutex::new(HashMap::new()),
|
||||
next_member_id: AtomicU64::new(0),
|
||||
next_template_id: AtomicU64::new(0),
|
||||
next_job_id: AtomicU64::new(0),
|
||||
node,
|
||||
extra_nodes: clients,
|
||||
pool_address,
|
||||
pool_address_hex: pool_address_hex.clone(),
|
||||
pay_address,
|
||||
net: Mutex::new(NetInfo::default()),
|
||||
want_templates: tokio::sync::Notify::new(),
|
||||
verify_permits: tokio::sync::Semaphore::new(cfg.verify_threads),
|
||||
started: Instant::now(),
|
||||
cfg: cfg.clone(),
|
||||
});
|
||||
let chain = evm.call("eth_chainId", serde_json::json!([])).await.ok().and_then(|v| v.as_str().map(|s| s.to_string()));
|
||||
println!(
|
||||
"igneum-pool v0: {} on {} (chain id {} per --network, node says {}), payout address {}, fee {}%, min payout {} IGN, PPLNS window {} blocks{}",
|
||||
cfg.name,
|
||||
cfg.network,
|
||||
cfg.chain_id(),
|
||||
chain.unwrap_or_else(|| "unreachable".into()),
|
||||
pool_address_hex,
|
||||
cfg.fee_percent,
|
||||
cfg.min_payout_ign,
|
||||
cfg.pplns_window_blocks,
|
||||
if cfg.dry_run { ", DRY RUN (payouts computed, not sent)" } else { "" }
|
||||
);
|
||||
let payer = Arc::new(payout::Payer { signer, rpc: evm, chain_id: cfg.chain_id(), dry_run: cfg.dry_run, min_payout_wei: (cfg.min_payout_ign * WEI_PER_IGN as f64) as u128 });
|
||||
|
||||
tokio::spawn(node::template_feed(pool.clone()));
|
||||
tokio::spawn(node::confirm_loop(pool.clone()));
|
||||
tokio::spawn(node::net_loop(pool.clone()));
|
||||
tokio::spawn(node::vardiff_loop(pool.clone()));
|
||||
tokio::spawn(server::listen(pool.clone()));
|
||||
tokio::spawn(api::serve(pool.clone()));
|
||||
{
|
||||
let (pool, payer) = (pool.clone(), payer.clone());
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(pool.cfg.payout_interval_s.max(5))).await;
|
||||
for l in payer.round(&pool.state).await {
|
||||
println!("{} {l}", state::unix_ms());
|
||||
}
|
||||
for l in payer.receipts(&pool.state).await {
|
||||
println!("{} {l}", state::unix_ms());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let (pool, path) = (pool.clone(), state_path.clone());
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(pool.cfg.snapshot_interval_s.max(1))).await;
|
||||
let mut s = pool.state.lock().unwrap();
|
||||
if s.dirty
|
||||
&& let Err(e) = s.save(&path)
|
||||
{
|
||||
eprintln!("state: save failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// a status line every 30 s
|
||||
{
|
||||
let pool = pool.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
let (miners, workers) = pool.online();
|
||||
let s = pool.state.lock().unwrap();
|
||||
let (n, mean, p50, p99, max) = s.check_cost();
|
||||
println!(
|
||||
"{} STATUS miners={miners} workers={workers} accepted={} stale={} rejected={} hashrate={:.0} H/s blocks={} confirmed={} orphans={} window={:.3} blocks/{} shares check_ms n={n} mean={mean:.3} p50={p50:.3} p99={p99:.3} max={max:.3}",
|
||||
state::unix_ms(),
|
||||
s.accepted,
|
||||
s.stale,
|
||||
s.rejected,
|
||||
s.hashrate(600, None, None),
|
||||
s.blocks_found,
|
||||
s.blocks_confirmed,
|
||||
s.blocks_orphaned,
|
||||
s.pplns.total_weight,
|
||||
s.pplns.shares.len()
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
println!("{} pool: stopping, saving state", state::unix_ms());
|
||||
let mut s = pool.state.lock().unwrap();
|
||||
if let Err(e) = s.save(&state_path) {
|
||||
eprintln!("state: save failed: {e}");
|
||||
}
|
||||
}
|
||||
386
pool/src/node.rs
Normal file
386
pool/src/node.rs
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
//! The node side: templates per member (the member's vote key in the header, its key reveal and the pool's `IGNA`
|
||||
//! address in the coinbase extra data, spec 9.4.1 items 1 and 2), jobs on them, found blocks submitted to every
|
||||
//! node, the blue-set walk that confirms or orphans them, and the network numbers for the API.
|
||||
|
||||
use crate::pool::{JobRec, Member, NetInfo, Pool};
|
||||
use crate::protocol::{hex_bytes, hex_u64, Msg};
|
||||
use crate::state::{unix_ms, BlockRec, WEI_PER_SOMPI};
|
||||
use crate::vardiff::{share_target, Vardiff};
|
||||
use crate::verify::JobKey;
|
||||
use kaspa_consensus_core::block::Block;
|
||||
use kaspa_consensus_core::header::Header;
|
||||
use kaspa_consensus_core::igneum::{block_subsidy, install_pow_schedule, pow_schedule, producer_share, PowSchedule};
|
||||
use kaspa_grpc_client::GrpcClient;
|
||||
use kaspa_hashes::Hash;
|
||||
use kaspa_notify::{listener::ListenerId, scope::NewBlockTemplateScope};
|
||||
use kaspa_pow::igneum::{day_index, header_prehash, target64, EpochSeeds, IgneumEngine};
|
||||
use kaspa_rpc_core::api::rpc::RpcApi;
|
||||
use kaspa_rpc_core::{Notification, RpcRawBlock};
|
||||
use rand::Rng;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn now() -> String {
|
||||
let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap();
|
||||
format!("{}.{:03}", t.as_secs(), t.subsec_millis())
|
||||
}
|
||||
|
||||
/// The extra data of a member's template: its key reveal (so its first block reveals the key) and the pool's
|
||||
/// payout address (`IGNA`, the address the execution layer pays).
|
||||
pub fn extra_data(member: &Member, pool_address: &[u8; 20]) -> Vec<u8> {
|
||||
let mut v = member.reveal.to_extra_data();
|
||||
v.extend_from_slice(&kaspa_consensus_core::evm::miner_address_extra_data(pool_address));
|
||||
v
|
||||
}
|
||||
|
||||
/// One template for one member, turned into a job. Blocks the thread on the first use of a seed pair (the 256 MiB
|
||||
/// cache build), so it runs under `spawn_blocking`.
|
||||
pub async fn fetch_job(pool: &Arc<Pool>, member: &Arc<Member>) -> Result<(), String> {
|
||||
let t0 = Instant::now();
|
||||
let tmpl = tokio::time::timeout(Duration::from_secs(5), pool.node.get_block_template(pool.pay_address.clone(), extra_data(member, &pool.pool_address)))
|
||||
.await
|
||||
.map_err(|_| "template timed out".to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut raw = tmpl.block;
|
||||
raw.header.vote_key_hash = member.key_hash;
|
||||
let block: Block = raw.clone().try_into().map_err(|e| format!("block convert: {e}"))?;
|
||||
let header: Header = block.header.as_ref().clone();
|
||||
let info = tmpl.pow_epoch.as_ref().ok_or("the node reports no pow_epoch; a devnet-v4 line node is needed")?;
|
||||
let wanted = PowSchedule::clamped(info.epoch_blocks, info.epoch_lead, info.day_ms);
|
||||
if wanted != pow_schedule() {
|
||||
install_pow_schedule(wanted);
|
||||
eprintln!("{} node PoW schedule: {} DAA per epoch, lead {}, day {} ms", now(), wanted.epoch_blocks, wanted.epoch_lead, wanted.day_ms);
|
||||
}
|
||||
let seeds = EpochSeeds { epoch: info.epoch_seed, day: day_index(header.timestamp) };
|
||||
let engine = pool.engine.clone();
|
||||
let epoch = tokio::task::spawn_blocking(move || engine.epoch_for(&seeds)).await.map_err(|e| e.to_string())?;
|
||||
let prehash = header_prehash(&header);
|
||||
let t64 = target64(header.bits);
|
||||
let parents: Vec<Hash> = header.direct_parents().to_vec();
|
||||
let template_id = pool.template_id();
|
||||
let job_id = pool.job_id();
|
||||
let hi: u32 = rand::thread_rng().r#gen();
|
||||
let nonce_start = (hi as u64) << 32;
|
||||
let (_shift, clean, seeds_changed, share_t) = {
|
||||
let mut g = member.inner.lock().unwrap();
|
||||
if g.vardiff.changes == 0 && g.current_target64 == 0 {
|
||||
// first job: size the shift from the target
|
||||
let s = Vardiff::initial_shift(t64, pool.cfg.min_shift, pool.cfg.max_shift);
|
||||
g.vardiff.shift = s;
|
||||
}
|
||||
g.current_target64 = t64;
|
||||
let cap = Vardiff::cap_for(t64).min(pool.cfg.max_shift);
|
||||
if g.vardiff.shift > cap {
|
||||
g.vardiff.shift = cap;
|
||||
}
|
||||
let shift = g.vardiff.shift;
|
||||
let clean = g.last_parents.as_ref() != Some(&parents);
|
||||
if clean {
|
||||
for j in g.jobs.iter_mut() {
|
||||
if j.superseded_at.is_none() {
|
||||
j.superseded_at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
g.last_parents = Some(parents);
|
||||
let seeds_changed = g.last_seeds != Some(seeds);
|
||||
g.last_seeds = Some(seeds);
|
||||
let share_t = share_target(t64, shift);
|
||||
g.jobs.push_back(JobRec {
|
||||
job_id,
|
||||
template_id,
|
||||
raw: raw.clone(),
|
||||
key: JobKey { prehash, target64: t64, share_target64: share_t, seeds },
|
||||
epoch,
|
||||
shift,
|
||||
issued: Instant::now(),
|
||||
superseded_at: None,
|
||||
seen: HashSet::new(),
|
||||
daa_score: header.daa_score,
|
||||
blue_score: header.blue_score,
|
||||
});
|
||||
while g.jobs.len() > 12 {
|
||||
g.jobs.pop_front();
|
||||
}
|
||||
(shift, clean, seeds_changed, share_t)
|
||||
};
|
||||
if seeds_changed {
|
||||
let (_, day_bytes) = IgneumEngine::seed_bytes(&seeds);
|
||||
member.send(
|
||||
Msg::Seeds {
|
||||
epoch_seed: seeds.epoch.to_string(),
|
||||
day: seeds.day,
|
||||
day_seed: hex_bytes(&day_bytes),
|
||||
seed_source: "node pow_epoch".into(),
|
||||
next_epoch_seed: info.next_epoch_seed.map(|h| h.to_string()),
|
||||
next_at_daa: info.boundary_daa_score,
|
||||
epoch_blocks: info.epoch_blocks,
|
||||
epoch_lead: info.epoch_lead,
|
||||
day_ms: info.day_ms,
|
||||
}
|
||||
.line(),
|
||||
);
|
||||
}
|
||||
member.send(Msg::Template { template_id, mode: "A".into(), block: Box::new(raw), daa_score: header.daa_score, bits: header.bits, votes_carried: Vec::new() }.line());
|
||||
member.send(
|
||||
Msg::Job {
|
||||
job_id,
|
||||
template_id,
|
||||
prehash: hex_bytes(&prehash),
|
||||
target64: hex_u64(t64),
|
||||
share_target64: hex_u64(share_t),
|
||||
nonce_start: hex_u64(nonce_start),
|
||||
nonce_count: hex_u64(1 << 32),
|
||||
epoch_seed: seeds.epoch.to_string(),
|
||||
day: seeds.day,
|
||||
clean,
|
||||
}
|
||||
.line(),
|
||||
);
|
||||
let _ = t0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn subscribe(url: &str) -> Result<(GrpcClient, async_channel::Receiver<Notification>), String> {
|
||||
let client = tokio::time::timeout(Duration::from_secs(5), GrpcClient::connect(url.to_string())).await.map_err(|_| "connect timed out".to_string())?.map_err(|e| e.to_string())?;
|
||||
tokio::time::timeout(Duration::from_secs(5), client.start_notify(ListenerId::default(), NewBlockTemplateScope {}.into()))
|
||||
.await
|
||||
.map_err(|_| "start_notify timed out".to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rx = client.notification_channel_receiver();
|
||||
Ok((client, rx))
|
||||
}
|
||||
|
||||
/// The template feed: on every NewBlockTemplate notification, every second, or on demand, a fresh template and job
|
||||
/// for every authorised member (fetched in parallel).
|
||||
pub async fn template_feed(pool: Arc<Pool>) {
|
||||
let url = pool.cfg.nodes[0].clone();
|
||||
let mut sub: Option<(GrpcClient, async_channel::Receiver<Notification>)> = None;
|
||||
let mut next_sub = Instant::now();
|
||||
loop {
|
||||
if sub.is_none() && Instant::now() >= next_sub {
|
||||
match subscribe(&url).await {
|
||||
Ok(s) => {
|
||||
eprintln!("{} templates: subscribed to NewBlockTemplate at {url}", now());
|
||||
sub = Some(s);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{} templates: subscription failed ({e}); polling once a second", now());
|
||||
next_sub = Instant::now() + Duration::from_secs(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut lost = false;
|
||||
match &sub {
|
||||
Some((_, rx)) => {
|
||||
tokio::select! {
|
||||
n = rx.recv() => { if n.is_err() { lost = true; } },
|
||||
_ = pool.want_templates.notified() => {},
|
||||
_ = tokio::time::sleep(Duration::from_secs(1)) => {},
|
||||
}
|
||||
if let Some((_, rx)) = &sub {
|
||||
while rx.try_recv().is_ok() {}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(1), pool.want_templates.notified()).await;
|
||||
}
|
||||
}
|
||||
if lost {
|
||||
sub = None;
|
||||
next_sub = Instant::now() + Duration::from_secs(2);
|
||||
}
|
||||
let members = pool.members_snapshot();
|
||||
let mut handles = Vec::with_capacity(members.len());
|
||||
for m in members {
|
||||
let pool = pool.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
if let Err(e) = fetch_job(&pool, &m).await {
|
||||
eprintln!("{} template for member {} ({}): {e}", now(), m.id, m.worker);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
let _ = h.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Submits a found block to every node; records it as pending with the PPLNS snapshot.
|
||||
pub async fn submit_block(pool: Arc<Pool>, member: Arc<Member>, mut raw: RpcRawBlock, nonce: u64, shift: u32, daa_score: u64, blue_score: u64) {
|
||||
raw.header.nonce = nonce;
|
||||
let hash = Block::try_from(raw.clone()).map(|b| b.hash().to_string()).unwrap_or_default();
|
||||
let mut accepted = false;
|
||||
let mut detail = String::new();
|
||||
for (i, c) in std::iter::once(&pool.node).chain(pool.extra_nodes.iter()).enumerate() {
|
||||
match tokio::time::timeout(Duration::from_secs(5), c.submit_block(raw.clone(), false)).await {
|
||||
Ok(Ok(r)) if r.report.is_success() => {
|
||||
accepted = true;
|
||||
}
|
||||
Ok(Ok(r)) => detail = format!("node {}: {:?}", i + 1, r.report),
|
||||
Ok(Err(e)) => detail = format!("node {}: {e}", i + 1),
|
||||
Err(_) => detail = format!("node {}: timed out", i + 1),
|
||||
}
|
||||
}
|
||||
let subsidy = block_subsidy(daa_score, 1);
|
||||
let reward_wei = producer_share(subsidy) as u128 * WEI_PER_SOMPI;
|
||||
if accepted {
|
||||
let mut s = pool.state.lock().unwrap();
|
||||
s.block_found(BlockRec {
|
||||
hash: hash.clone(),
|
||||
daa_score,
|
||||
blue_score,
|
||||
found_ms: unix_ms(),
|
||||
finder: member.address.clone(),
|
||||
worker: member.worker.clone(),
|
||||
status: "pending".into(),
|
||||
reward_wei,
|
||||
fee_wei: 0,
|
||||
effort: 0.0,
|
||||
payees: Vec::new(),
|
||||
confirmed_ms: None,
|
||||
nonce: hex_u64(nonce),
|
||||
shift,
|
||||
});
|
||||
member.inner.lock().unwrap().blocks += 1;
|
||||
println!("{} BLOCK {} daa={} by {} ({}) nonce={:#x} reward={} IGN expected, pending", now(), &hash[..16.min(hash.len())], daa_score, member.address, member.worker, nonce, crate::state::ign(reward_wei));
|
||||
} else {
|
||||
pool.state.lock().unwrap().submit_errors += 1;
|
||||
eprintln!("{} block {} REJECTED by every node: {detail}", now(), &hash[..16.min(hash.len())]);
|
||||
}
|
||||
pool.want_templates.notify_one();
|
||||
}
|
||||
|
||||
/// Confirms pending blocks by walking the selected chain: a block is paid when it is a chain block or in the
|
||||
/// mergeset blues of one (the execution layer pays exactly those, executor.rs `b.is_blue`); a pending block more
|
||||
/// than `orphan_after_daa` behind the virtual with no blue merge is an orphan.
|
||||
pub async fn confirm_loop(pool: Arc<Pool>) {
|
||||
let mut last_chain: Option<Hash> = None;
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
let pending: Vec<(String, u64)> = {
|
||||
let s = pool.state.lock().unwrap();
|
||||
s.blocks.iter().filter(|b| b.status == "pending").map(|b| (b.hash.clone(), b.daa_score)).collect()
|
||||
};
|
||||
let info = match pool.node.get_block_dag_info().await {
|
||||
Ok(i) => i,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if last_chain.is_none() {
|
||||
last_chain = Some(info.pruning_point_hash);
|
||||
}
|
||||
if pending.is_empty() {
|
||||
// keep the cursor near the tip so the first pending block costs one short walk
|
||||
last_chain = Some(info.sink);
|
||||
continue;
|
||||
}
|
||||
let pending_set: HashSet<String> = pending.iter().map(|p| p.0.clone()).collect();
|
||||
let low = last_chain.unwrap();
|
||||
let chain = match pool.node.get_virtual_chain_from_block(low, false, None).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("{} confirm: getVirtualChainFromBlock failed ({e}); restarting from the pruning point", now());
|
||||
last_chain = Some(info.pruning_point_hash);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut blues: HashSet<String> = HashSet::new();
|
||||
for h in &chain.added_chain_block_hashes {
|
||||
blues.insert(h.to_string());
|
||||
if let Ok(b) = pool.node.get_block(*h, false).await
|
||||
&& let Some(v) = b.verbose_data
|
||||
{
|
||||
for m in v.merge_set_blues_hashes {
|
||||
blues.insert(m.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(h) = chain.added_chain_block_hashes.last() {
|
||||
last_chain = Some(*h);
|
||||
}
|
||||
for (hash, daa) in pending {
|
||||
if blues.contains(&hash) {
|
||||
let r = pool.state.lock().unwrap().confirm_block(&hash, pool.cfg.fee_percent);
|
||||
if let Some((reward, parts)) = r {
|
||||
println!(
|
||||
"{} CONFIRMED {} reward {} IGN paid to {} addresses: {}",
|
||||
now(),
|
||||
&hash[..16],
|
||||
crate::state::ign(reward),
|
||||
parts.len(),
|
||||
parts.iter().map(|(a, w)| format!("{}={:.6}", &a[..10], crate::state::ign(*w))).collect::<Vec<_>>().join(" ")
|
||||
);
|
||||
}
|
||||
} else if info.virtual_daa_score.saturating_sub(daa) > pool.cfg.orphan_after_daa && pool.state.lock().unwrap().orphan_block(&hash) {
|
||||
println!("{} ORPHAN {} (daa {}, virtual {}): not blue within {} DAA", now(), &hash[..16], daa, info.virtual_daa_score, pool.cfg.orphan_after_daa);
|
||||
}
|
||||
}
|
||||
let _ = pending_set;
|
||||
}
|
||||
}
|
||||
|
||||
/// Network numbers every 5 s.
|
||||
pub async fn net_loop(pool: Arc<Pool>) {
|
||||
loop {
|
||||
let mut n = NetInfo { network: pool.cfg.network.clone(), chain_id: pool.cfg.chain_id(), ..Default::default() };
|
||||
if let Ok(i) = pool.node.get_block_dag_info().await {
|
||||
n.difficulty = i.difficulty;
|
||||
n.daa_score = i.virtual_daa_score;
|
||||
n.block_count = i.block_count;
|
||||
n.network = i.network.to_string();
|
||||
}
|
||||
n.hashrate = pool.node.estimate_network_hashes_per_second(1000, None).await.ok().map(|h| h as f64);
|
||||
if let Ok(b) = pool.node.get_sink_blue_score().await {
|
||||
n.blue_score = b;
|
||||
}
|
||||
if let Ok(i) = pool.node.get_info().await {
|
||||
n.synced = i.is_synced;
|
||||
n.node_version = i.server_version;
|
||||
}
|
||||
let subsidy = block_subsidy(n.daa_score, 1);
|
||||
n.block_reward_ign = subsidy as f64 / 1e8;
|
||||
n.miner_reward_ign = producer_share(subsidy) as f64 / 1e8;
|
||||
n.updated_ms = unix_ms();
|
||||
{
|
||||
let members = pool.members_snapshot();
|
||||
let seeds = members.first().and_then(|m| m.inner.lock().unwrap().last_seeds);
|
||||
if let Some(seeds) = seeds {
|
||||
n.epoch_seed = seeds.epoch.to_string();
|
||||
n.epoch_index = n.daa_score / kaspa_consensus_core::igneum::pow_epoch_blocks();
|
||||
}
|
||||
}
|
||||
*pool.net.lock().unwrap() = n;
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vardiff ticks for every member once a second (shares also retarget on arrival).
|
||||
pub async fn vardiff_loop(pool: Arc<Pool>) {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
let now_s = pool.now_s();
|
||||
for m in pool.members_snapshot() {
|
||||
let changed = {
|
||||
let mut g = m.inner.lock().unwrap();
|
||||
let t64 = g.current_target64;
|
||||
if t64 == 0 {
|
||||
None
|
||||
} else {
|
||||
g.vardiff.retarget(now_s, t64).map(|s| (s, share_target(t64, s)))
|
||||
}
|
||||
};
|
||||
if let Some((s, st)) = changed {
|
||||
m.send(Msg::SetTarget { shift: s, share_target64: hex_u64(st) }.line());
|
||||
println!("{} VARDIFF member {} ({}) shift -> {} (share target {:016x})", now(), m.id, m.worker, s, st);
|
||||
let pool = pool.clone();
|
||||
let m = m.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = fetch_job(&pool, &m).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
let _ = pool.next_member_id.load(Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
341
pool/src/payout.rs
Normal file
341
pool/src/payout.rs
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
//! Payouts on the EVM side. The execution layer credits 80% of every blue block's subsidy to the coinbase's `IGNA`
|
||||
//! address (igneum/exec/src/executor.rs, "Rewards as a state transition applied by rule"), and the 20% proving share
|
||||
//! to the proving pool escrow by the same rule: the pool's templates name the pool's address, so the pool receives
|
||||
//! the 80% and never touches the 20%. Members are paid from that address by EIP-1559 transfers through the node's
|
||||
//! eth_ JSON-RPC, one transaction per payee, at most 16 in flight (the node's mempool refuses a nonce more than 16
|
||||
//! ahead). `--dry-run` computes and records every payment and sends nothing.
|
||||
|
||||
use crate::state::{PaymentRec, State, WEI_PER_IGN};
|
||||
use alloy_consensus::{SignableTransaction, TxEip1559, TxEnvelope};
|
||||
use alloy_eips::eip2718::Encodable2718;
|
||||
use alloy_primitives::{Address, Bytes, Signature, TxKind, B256, U256};
|
||||
use alloy_signer_local::PrivateKeySigner;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// The payout key file: `{"private_key": "0x..64 hex", "address": "0x..40 hex"}`, created with a fresh random key
|
||||
/// when missing, mode 0600. The operator backs it up and never commits it (pool/README.md).
|
||||
pub fn load_or_create_key(path: &Path) -> Result<PrivateKeySigner, String> {
|
||||
if let Ok(bytes) = std::fs::read(path) {
|
||||
let v: Value = serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let pk = v.get("private_key").and_then(|x| x.as_str()).ok_or("payout key file has no private_key")?;
|
||||
let raw = hex::decode(pk.trim_start_matches("0x")).map_err(|e| format!("private_key: {e}"))?;
|
||||
let b = B256::try_from(raw.as_slice()).map_err(|_| "private_key is not 32 bytes".to_string())?;
|
||||
return PrivateKeySigner::from_bytes(&b).map_err(|e| e.to_string());
|
||||
}
|
||||
let signer = PrivateKeySigner::random();
|
||||
let key = format!("0x{}", hex::encode(signer.to_bytes()));
|
||||
let body = serde_json::to_vec_pretty(&json!({ "private_key": key, "address": format!("{:?}", signer.address()), "created": "igneum-pool" }))
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(path, body).map_err(|e| e.to_string())?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
eprintln!("payout key: created {} for address {:?} (back it up; it holds the pool's coinbase income)", path.display(), signer.address());
|
||||
Ok(signer)
|
||||
}
|
||||
|
||||
/// A minimal JSON-RPC over HTTP/1.1 client for the node's eth_ endpoint: one request per connection.
|
||||
pub struct EvmRpc {
|
||||
host: String,
|
||||
port: u16,
|
||||
path: String,
|
||||
id: Mutex<u64>,
|
||||
}
|
||||
|
||||
impl EvmRpc {
|
||||
pub fn new(url: &str) -> Result<Self, String> {
|
||||
let rest = url.strip_prefix("http://").ok_or("the EVM RPC URL must start with http://")?;
|
||||
let (hostport, path) = match rest.find('/') {
|
||||
Some(i) => (&rest[..i], &rest[i..]),
|
||||
None => (rest, "/"),
|
||||
};
|
||||
let (host, port) = match hostport.rsplit_once(':') {
|
||||
Some((h, p)) => (h.to_string(), p.parse::<u16>().map_err(|_| "bad port")?),
|
||||
None => (hostport.to_string(), 80),
|
||||
};
|
||||
Ok(Self { host, port, path: path.to_string(), id: Mutex::new(0) })
|
||||
}
|
||||
|
||||
pub async fn call(&self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let id = {
|
||||
let mut g = self.id.lock().unwrap();
|
||||
*g += 1;
|
||||
*g
|
||||
};
|
||||
let body = serde_json::to_vec(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).unwrap();
|
||||
let req = format!(
|
||||
"POST {} HTTP/1.1\r\nHost: {}:{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
self.path,
|
||||
self.host,
|
||||
self.port,
|
||||
body.len()
|
||||
);
|
||||
let fut = async {
|
||||
let mut s = tokio::net::TcpStream::connect((self.host.as_str(), self.port)).await.map_err(|e| format!("connect {}:{}: {e}", self.host, self.port))?;
|
||||
s.write_all(req.as_bytes()).await.map_err(|e| e.to_string())?;
|
||||
s.write_all(&body).await.map_err(|e| e.to_string())?;
|
||||
let mut buf = Vec::new();
|
||||
s.read_to_end(&mut buf).await.map_err(|e| e.to_string())?;
|
||||
Ok::<Vec<u8>, String>(buf)
|
||||
};
|
||||
let raw = tokio::time::timeout(std::time::Duration::from_secs(15), fut).await.map_err(|_| "EVM RPC timed out".to_string())??;
|
||||
let body = http_body(&raw)?;
|
||||
let v: Value = serde_json::from_slice(&body).map_err(|e| format!("{method}: bad JSON: {e}"))?;
|
||||
if let Some(err) = v.get("error") {
|
||||
return Err(format!("{method}: {}", err.get("message").and_then(|m| m.as_str()).unwrap_or(&err.to_string())));
|
||||
}
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
}
|
||||
|
||||
/// The body of an HTTP/1.1 response: by Content-Length, by chunked transfer encoding, or to the close.
|
||||
pub fn http_body(raw: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let sep = raw.windows(4).position(|w| w == b"\r\n\r\n").ok_or("no HTTP header end")?;
|
||||
let head = String::from_utf8_lossy(&raw[..sep]).to_string();
|
||||
let rest = &raw[sep + 4..];
|
||||
let status = head.lines().next().unwrap_or("").to_string();
|
||||
let mut chunked = false;
|
||||
let mut len: Option<usize> = None;
|
||||
for l in head.lines().skip(1) {
|
||||
let lower = l.to_ascii_lowercase();
|
||||
if let Some(v) = lower.strip_prefix("content-length:") {
|
||||
len = v.trim().parse().ok();
|
||||
}
|
||||
if lower.starts_with("transfer-encoding:") && lower.contains("chunked") {
|
||||
chunked = true;
|
||||
}
|
||||
}
|
||||
let body = if chunked {
|
||||
let mut out = Vec::new();
|
||||
let mut p = 0;
|
||||
loop {
|
||||
let nl = rest[p..].windows(2).position(|w| w == b"\r\n").ok_or("bad chunk")? + p;
|
||||
let size = usize::from_str_radix(String::from_utf8_lossy(&rest[p..nl]).trim().split(';').next().unwrap_or("0"), 16).map_err(|_| "bad chunk size")?;
|
||||
if size == 0 {
|
||||
break;
|
||||
}
|
||||
let start = nl + 2;
|
||||
out.extend_from_slice(rest.get(start..start + size).ok_or("short chunk")?);
|
||||
p = start + size + 2;
|
||||
}
|
||||
out
|
||||
} else if let Some(n) = len {
|
||||
rest.get(..n).ok_or("short body")?.to_vec()
|
||||
} else {
|
||||
rest.to_vec()
|
||||
};
|
||||
if !status.contains(" 200") {
|
||||
return Err(format!("HTTP {status}: {}", String::from_utf8_lossy(&body).chars().take(200).collect::<String>()));
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
fn hex_u128(v: &Value) -> Option<u128> {
|
||||
u128::from_str_radix(v.as_str()?.trim_start_matches("0x"), 16).ok()
|
||||
}
|
||||
|
||||
pub struct Payer {
|
||||
pub signer: PrivateKeySigner,
|
||||
pub rpc: EvmRpc,
|
||||
pub chain_id: u64,
|
||||
pub dry_run: bool,
|
||||
pub min_payout_wei: u128,
|
||||
}
|
||||
|
||||
impl Payer {
|
||||
pub fn address_hex(&self) -> String {
|
||||
format!("{:?}", self.signer.address()).to_lowercase()
|
||||
}
|
||||
|
||||
/// The pool address's EVM balance in wei, or an error text.
|
||||
pub async fn balance_of(&self, address: &str) -> Result<u128, String> {
|
||||
let v = self.rpc.call("eth_getBalance", json!([address, "latest"])).await?;
|
||||
hex_u128(&v).ok_or_else(|| "eth_getBalance: not a quantity".into())
|
||||
}
|
||||
|
||||
/// Signs and sends one transfer; returns the transaction hash.
|
||||
pub async fn send(&self, to: &str, amount_wei: u128, nonce: u64) -> Result<String, String> {
|
||||
let from = self.address_hex();
|
||||
let block = self.rpc.call("eth_getBlockByNumber", json!(["latest", false])).await?;
|
||||
let base_fee = block.get("baseFeePerGas").and_then(hex_u128).unwrap_or(1_000_000_000);
|
||||
let tip = self.rpc.call("eth_maxPriorityFeePerGas", json!([])).await.ok().and_then(|v| hex_u128(&v)).unwrap_or(1_000_000_000);
|
||||
let gas = self
|
||||
.rpc
|
||||
.call("eth_estimateGas", json!([{ "from": from, "to": to, "value": format!("{:#x}", amount_wei) }]))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| hex_u128(&v))
|
||||
.map(|g| (g as u64).max(21_000))
|
||||
.unwrap_or(30_000);
|
||||
let to_addr: Address = to.parse().map_err(|_| format!("bad payee address {to}"))?;
|
||||
let tx = TxEip1559 {
|
||||
chain_id: self.chain_id,
|
||||
nonce,
|
||||
gas_limit: gas,
|
||||
max_fee_per_gas: base_fee * 2 + tip,
|
||||
max_priority_fee_per_gas: tip,
|
||||
to: TxKind::Call(to_addr),
|
||||
value: U256::from(amount_wei),
|
||||
access_list: Default::default(),
|
||||
input: Bytes::new(),
|
||||
};
|
||||
let sig: Signature = alloy_signer::SignerSync::sign_hash_sync(&self.signer, &tx.signature_hash()).map_err(|e| e.to_string())?;
|
||||
let env: TxEnvelope = tx.into_signed(sig).into();
|
||||
let raw = format!("0x{}", hex::encode(env.encoded_2718()));
|
||||
let h = self.rpc.call("eth_sendRawTransaction", json!([raw])).await?;
|
||||
h.as_str().map(|s| s.to_string()).ok_or_else(|| "eth_sendRawTransaction: no hash".into())
|
||||
}
|
||||
|
||||
/// One payout round over the ledger: every balance at or above the minimum, at most 16 transfers.
|
||||
pub async fn round(&self, state: &Mutex<State>) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let due: Vec<(String, u128)> = {
|
||||
let s = state.lock().unwrap();
|
||||
let mut v: Vec<(String, u128)> = s.balances.iter().filter(|(_, w)| **w >= self.min_payout_wei).map(|(a, w)| (a.clone(), *w)).collect();
|
||||
v.sort();
|
||||
v.truncate(16);
|
||||
v
|
||||
};
|
||||
if due.is_empty() {
|
||||
return lines;
|
||||
}
|
||||
if self.dry_run {
|
||||
let mut s = state.lock().unwrap();
|
||||
for (a, wei) in due {
|
||||
s.payments.push(PaymentRec {
|
||||
at_ms: crate::state::unix_ms(),
|
||||
address: a.clone(),
|
||||
amount_wei: wei,
|
||||
tx_hash: None,
|
||||
status: "dry-run".into(),
|
||||
dry_run: true,
|
||||
nonce: None,
|
||||
note: "computed, not sent (--dry-run); the balance stays".into(),
|
||||
});
|
||||
lines.push(format!("DRY RUN payout {} IGN to {a}", wei as f64 / WEI_PER_IGN as f64));
|
||||
}
|
||||
s.dirty = true;
|
||||
return lines;
|
||||
}
|
||||
let from = self.address_hex();
|
||||
let nonce0 = match self.rpc.call("eth_getTransactionCount", json!([from, "pending"])).await.and_then(|v| hex_u128(&v).ok_or("nonce".into())) {
|
||||
Ok(n) => n as u64,
|
||||
Err(e) => {
|
||||
lines.push(format!("payout: nonce unavailable: {e}"));
|
||||
return lines;
|
||||
}
|
||||
};
|
||||
let pool_balance = self.balance_of(&from).await.unwrap_or(0);
|
||||
let mut nonce = nonce0;
|
||||
let mut spent = 0u128;
|
||||
for (a, wei) in due {
|
||||
if spent + wei + 100_000_000_000_000 > pool_balance {
|
||||
lines.push(format!("payout: pool balance {} IGN cannot cover {} IGN to {a}; waiting", pool_balance as f64 / WEI_PER_IGN as f64, wei as f64 / WEI_PER_IGN as f64));
|
||||
break;
|
||||
}
|
||||
match self.send(&a, wei, nonce).await {
|
||||
Ok(tx) => {
|
||||
let mut s = state.lock().unwrap();
|
||||
let left = s.balances.get(&a).copied().unwrap_or(0).saturating_sub(wei);
|
||||
s.balances.insert(a.clone(), left);
|
||||
*s.paid.entry(a.clone()).or_insert(0) += wei;
|
||||
s.payments.push(PaymentRec { at_ms: crate::state::unix_ms(), address: a.clone(), amount_wei: wei, tx_hash: Some(tx.clone()), status: "sent".into(), dry_run: false, nonce: Some(nonce), note: String::new() });
|
||||
s.dirty = true;
|
||||
lines.push(format!("PAYOUT {} IGN to {a} tx {tx} nonce {nonce}", wei as f64 / WEI_PER_IGN as f64));
|
||||
nonce += 1;
|
||||
spent += wei;
|
||||
}
|
||||
Err(e) => {
|
||||
lines.push(format!("payout to {a} failed: {e}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Receipts for sent payments: confirmed, failed (balance re-credited), or still pending.
|
||||
pub async fn receipts(&self, state: &Mutex<State>) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let sent: Vec<(usize, String, String, u128)> = {
|
||||
let s = state.lock().unwrap();
|
||||
s.payments.iter().enumerate().filter(|(_, p)| p.status == "sent").filter_map(|(i, p)| p.tx_hash.clone().map(|h| (i, h, p.address.clone(), p.amount_wei))).collect()
|
||||
};
|
||||
for (i, h, a, wei) in sent {
|
||||
let r = match self.rpc.call("eth_getTransactionReceipt", json!([h])).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
lines.push(format!("receipt {h}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if r.is_null() {
|
||||
continue;
|
||||
}
|
||||
let ok = r.get("status").and_then(|s| s.as_str()).map(|s| s == "0x1").unwrap_or(false);
|
||||
let mut s = state.lock().unwrap();
|
||||
if let Some(p) = s.payments.get_mut(i) {
|
||||
p.status = if ok { "confirmed".into() } else { "failed".into() };
|
||||
}
|
||||
if !ok {
|
||||
*s.balances.entry(a.clone()).or_insert(0) += wei;
|
||||
let paid = s.paid.get(&a).copied().unwrap_or(0).saturating_sub(wei);
|
||||
s.paid.insert(a.clone(), paid);
|
||||
lines.push(format!("payment {h} to {a} FAILED on chain; {} IGN re-credited", wei as f64 / WEI_PER_IGN as f64));
|
||||
} else {
|
||||
lines.push(format!("payment {h} to {a} confirmed ({} IGN)", wei as f64 / WEI_PER_IGN as f64));
|
||||
}
|
||||
s.dirty = true;
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn http_bodies_by_length_and_by_chunks() {
|
||||
let r = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello".to_vec();
|
||||
assert_eq!(http_body(&r).unwrap(), b"hello");
|
||||
let r = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n2\r\nde\r\n0\r\n\r\n".to_vec();
|
||||
assert_eq!(http_body(&r).unwrap(), b"abcde");
|
||||
let r = b"HTTP/1.1 500 Internal\r\nContent-Length: 3\r\n\r\nbad".to_vec();
|
||||
assert!(http_body(&r).unwrap_err().contains("500"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_key_file_round_trips_and_names_its_address() {
|
||||
let dir = std::env::temp_dir().join(format!("igneum-pool-key-{}", std::process::id()));
|
||||
let path = dir.join("payout-key.json");
|
||||
let a = load_or_create_key(&path).unwrap();
|
||||
let b = load_or_create_key(&path).unwrap();
|
||||
assert_eq!(a.address(), b.address());
|
||||
let v: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(v["address"].as_str().unwrap().to_lowercase(), format!("{:?}", a.address()).to_lowercase());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signed_transfer_decodes_to_its_fields() {
|
||||
let signer = PrivateKeySigner::from_bytes(&B256::from([7u8; 32])).unwrap();
|
||||
let tx = TxEip1559 { chain_id: 4463, nonce: 3, gas_limit: 21_000, max_fee_per_gas: 3_000_000_000, max_priority_fee_per_gas: 1_000_000_000, to: TxKind::Call(Address::from([1u8; 20])), value: U256::from(5u64), access_list: Default::default(), input: Bytes::new() };
|
||||
let sig: Signature = alloy_signer::SignerSync::sign_hash_sync(&signer, &tx.signature_hash()).unwrap();
|
||||
let env: TxEnvelope = tx.into_signed(sig).into();
|
||||
let raw = env.encoded_2718();
|
||||
assert_eq!(raw[0], 2, "EIP-1559 type byte");
|
||||
let back = alloy_eips::eip2718::Decodable2718::decode_2718(&mut raw.as_slice()).unwrap();
|
||||
let TxEnvelope::Eip1559(s) = back else { panic!("not 1559") };
|
||||
assert_eq!(s.tx().nonce, 3);
|
||||
assert_eq!(s.recover_signer().unwrap(), signer.address());
|
||||
}
|
||||
}
|
||||
132
pool/src/pool.rs
Normal file
132
pool/src/pool.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
//! The live pool: configuration, the share verifier engine, the ledger, the connected members and their jobs.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::state::State;
|
||||
use crate::vardiff::Vardiff;
|
||||
use crate::verify::JobKey;
|
||||
use kaspa_consensus_core::finality::KeyReveal;
|
||||
use kaspa_grpc_client::GrpcClient;
|
||||
use kaspa_hashes::Hash;
|
||||
use kaspa_pow::igneum::{EpochRef, EpochSeeds, IgneumEngine};
|
||||
use kaspa_rpc_core::RpcRawBlock;
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
/// A job issued to one member: the template it was built on and what its shares are checked against.
|
||||
pub struct JobRec {
|
||||
pub job_id: u64,
|
||||
pub template_id: u64,
|
||||
pub raw: RpcRawBlock,
|
||||
pub key: JobKey,
|
||||
pub epoch: Arc<EpochRef>,
|
||||
pub shift: u32,
|
||||
pub issued: Instant,
|
||||
/// Set when a job on a newer tip was issued: shares after the grace are stale
|
||||
pub superseded_at: Option<Instant>,
|
||||
pub seen: HashSet<u64>,
|
||||
pub daa_score: u64,
|
||||
pub blue_score: u64,
|
||||
}
|
||||
|
||||
pub struct MemberInner {
|
||||
pub vardiff: Vardiff,
|
||||
pub jobs: VecDeque<JobRec>,
|
||||
pub last_parents: Option<Vec<Hash>>,
|
||||
pub last_seeds: Option<EpochSeeds>,
|
||||
pub accepted: u64,
|
||||
pub stale: u64,
|
||||
pub rejected: u64,
|
||||
pub blocks: u64,
|
||||
pub refused: u64,
|
||||
pub last_share: Option<Instant>,
|
||||
pub revealed: bool,
|
||||
/// The member's own report (spec `stats`): hashrate, workers, and the proving flag (consequence C6)
|
||||
pub reported_hashrate: f64,
|
||||
pub reported_workers: u32,
|
||||
pub proving: bool,
|
||||
pub current_target64: u64,
|
||||
pub in_flight_checks: u32,
|
||||
}
|
||||
|
||||
pub struct Member {
|
||||
pub id: u64,
|
||||
pub address: String,
|
||||
pub worker: String,
|
||||
pub key_hash: Hash,
|
||||
pub reveal: KeyReveal,
|
||||
pub remote: String,
|
||||
pub connected_at: Instant,
|
||||
pub tx: tokio::sync::mpsc::UnboundedSender<String>,
|
||||
pub inner: Mutex<MemberInner>,
|
||||
}
|
||||
|
||||
impl Member {
|
||||
pub fn send(&self, line: String) {
|
||||
let _ = self.tx.send(line);
|
||||
}
|
||||
pub fn job(&self, job_id: u64) -> Option<(JobKey, Arc<EpochRef>, u32, bool, u64, u64)> {
|
||||
let g = self.inner.lock().unwrap();
|
||||
g.jobs.iter().find(|j| j.job_id == job_id).map(|j| (j.key.clone(), j.epoch.clone(), j.shift, j.superseded_at.is_some(), j.daa_score, j.blue_score))
|
||||
}
|
||||
}
|
||||
|
||||
/// The network numbers the API reports beside the pool's own, refreshed every 5 s from the primary node.
|
||||
#[derive(Clone, Debug, Default, serde::Serialize)]
|
||||
pub struct NetInfo {
|
||||
pub network: String,
|
||||
pub chain_id: u64,
|
||||
pub difficulty: f64,
|
||||
pub hashrate: Option<f64>,
|
||||
pub daa_score: u64,
|
||||
pub block_count: u64,
|
||||
pub blue_score: u64,
|
||||
pub synced: bool,
|
||||
pub node_version: String,
|
||||
pub block_reward_ign: f64,
|
||||
pub miner_reward_ign: f64,
|
||||
pub updated_ms: u64,
|
||||
pub epoch_seed: String,
|
||||
pub epoch_index: u64,
|
||||
}
|
||||
|
||||
pub struct Pool {
|
||||
pub cfg: Config,
|
||||
pub engine: Arc<IgneumEngine>,
|
||||
pub state: Mutex<State>,
|
||||
pub members: Mutex<HashMap<u64, Arc<Member>>>,
|
||||
pub next_member_id: AtomicU64,
|
||||
pub next_template_id: AtomicU64,
|
||||
pub next_job_id: AtomicU64,
|
||||
pub node: Arc<GrpcClient>,
|
||||
pub extra_nodes: Vec<Arc<GrpcClient>>,
|
||||
/// The pool's EVM coinbase address, lowercase 0x hex, named in every template's `IGNA` field
|
||||
pub pool_address: [u8; 20],
|
||||
pub pool_address_hex: String,
|
||||
pub pay_address: kaspa_addresses::Address,
|
||||
pub net: Mutex<NetInfo>,
|
||||
pub want_templates: tokio::sync::Notify,
|
||||
pub verify_permits: tokio::sync::Semaphore,
|
||||
pub started: Instant,
|
||||
}
|
||||
|
||||
impl Pool {
|
||||
pub fn members_snapshot(&self) -> Vec<Arc<Member>> {
|
||||
self.members.lock().unwrap().values().cloned().collect()
|
||||
}
|
||||
pub fn online(&self) -> (usize, usize) {
|
||||
let m = self.members.lock().unwrap();
|
||||
let addresses: HashSet<&str> = m.values().map(|x| x.address.as_str()).collect();
|
||||
(addresses.len(), m.len())
|
||||
}
|
||||
pub fn now_s(&self) -> f64 {
|
||||
self.started.elapsed().as_secs_f64()
|
||||
}
|
||||
pub fn template_id(&self) -> u64 {
|
||||
self.next_template_id.fetch_add(1, Ordering::Relaxed) + 1
|
||||
}
|
||||
pub fn job_id(&self) -> u64 {
|
||||
self.next_job_id.fetch_add(1, Ordering::Relaxed) + 1
|
||||
}
|
||||
}
|
||||
172
pool/src/pplns.rs
Normal file
172
pool/src/pplns.rs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
//! PPLNS: pay per last N shares, with N measured in block weight (a share at shift `s` weighs `2^-s` of a block,
|
||||
//! spec 09 section 9.8 item 3). The window holds the newest shares whose weights sum to `window_blocks` blocks of
|
||||
//! expected work; when a block is found, the reward after the pool fee is split across the window in proportion to
|
||||
//! weight. The split is snapshotted when the block is found and credited when the block is confirmed blue, so an
|
||||
//! orphan pays nobody and a late joiner is paid for the share of the window it earned.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ShareRec {
|
||||
pub address: String,
|
||||
pub worker: String,
|
||||
pub weight: f64,
|
||||
pub at_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Pplns {
|
||||
pub window_blocks: f64,
|
||||
pub shares: VecDeque<ShareRec>,
|
||||
pub total_weight: f64,
|
||||
/// Weight accumulated since the last block, for the effort figure
|
||||
pub since_block: f64,
|
||||
}
|
||||
|
||||
/// One payee's part of a block: `fraction` of the reward after the fee.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Payee {
|
||||
pub address: String,
|
||||
pub fraction: f64,
|
||||
}
|
||||
|
||||
impl Pplns {
|
||||
pub fn new(window_blocks: f64) -> Self {
|
||||
Self { window_blocks, shares: VecDeque::new(), total_weight: 0.0, since_block: 0.0 }
|
||||
}
|
||||
|
||||
pub fn push(&mut self, s: ShareRec) {
|
||||
self.total_weight += s.weight;
|
||||
self.since_block += s.weight;
|
||||
self.shares.push_back(s);
|
||||
while self.shares.len() > 1 && self.total_weight - self.shares.front().map(|s| s.weight).unwrap_or(0.0) >= self.window_blocks {
|
||||
let front = self.shares.pop_front().unwrap();
|
||||
self.total_weight -= front.weight;
|
||||
}
|
||||
if self.total_weight < 0.0 {
|
||||
self.total_weight = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// The split of the window right now, by address, fractions summing to 1 (empty window: no payees).
|
||||
pub fn snapshot(&self) -> Vec<Payee> {
|
||||
if self.total_weight <= 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut by: HashMap<&str, f64> = HashMap::new();
|
||||
for s in &self.shares {
|
||||
*by.entry(s.address.as_str()).or_insert(0.0) += s.weight;
|
||||
}
|
||||
let mut out: Vec<Payee> = by.into_iter().map(|(a, w)| Payee { address: a.to_string(), fraction: w / self.total_weight }).collect();
|
||||
out.sort_by(|a, b| b.fraction.partial_cmp(&a.fraction).unwrap().then(a.address.cmp(&b.address)));
|
||||
out
|
||||
}
|
||||
|
||||
/// Effort of the block just found: window weight since the previous block over one block of expected work.
|
||||
pub fn take_effort(&mut self) -> f64 {
|
||||
let e = self.since_block;
|
||||
self.since_block = 0.0;
|
||||
e
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits `reward_wei` across `payees` after a fee of `fee_percent`. Rounding dust stays with the pool (the sum of
|
||||
/// the parts never exceeds the reward after the fee).
|
||||
pub fn distribute(reward_wei: u128, fee_percent: f64, payees: &[Payee]) -> (Vec<(String, u128)>, u128) {
|
||||
let fee = ((reward_wei as f64) * (fee_percent.clamp(0.0, 100.0) / 100.0)).floor() as u128;
|
||||
let net = reward_wei.saturating_sub(fee);
|
||||
let mut out = Vec::with_capacity(payees.len());
|
||||
let mut given = 0u128;
|
||||
for p in payees {
|
||||
let part = ((net as f64) * p.fraction.clamp(0.0, 1.0)).floor() as u128;
|
||||
let part = part.min(net - given);
|
||||
given += part;
|
||||
out.push((p.address.clone(), part));
|
||||
}
|
||||
(out, fee + (net - given))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn share(a: &str, w: f64) -> ShareRec {
|
||||
ShareRec { address: a.into(), worker: "w".into(), weight: w, at_ms: 0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_holds_the_newest_n_blocks_of_weight() {
|
||||
let mut p = Pplns::new(2.0);
|
||||
for _ in 0..10 {
|
||||
p.push(share("a", 0.5)); // 5 blocks of weight pushed
|
||||
}
|
||||
assert!((p.total_weight - 2.0).abs() < 1e-9, "{}", p.total_weight);
|
||||
assert_eq!(p.shares.len(), 4);
|
||||
// mixed shifts: a 2^-1 and many 2^-4 shares
|
||||
let mut p = Pplns::new(1.0);
|
||||
p.push(share("a", 0.5));
|
||||
for _ in 0..16 {
|
||||
p.push(share("b", 1.0 / 16.0));
|
||||
}
|
||||
assert!((p.total_weight - 1.0).abs() < 1e-9);
|
||||
// a's share fell out once b filled a whole block of weight
|
||||
assert!(p.shares.iter().all(|s| s.address == "b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_split_is_by_weight_in_the_window_and_sums_to_one() {
|
||||
let mut p = Pplns::new(4.0);
|
||||
p.push(share("a", 1.0));
|
||||
p.push(share("b", 0.5));
|
||||
p.push(share("b", 0.5));
|
||||
p.push(share("c", 2.0));
|
||||
let snap = p.snapshot();
|
||||
assert_eq!(snap[0], Payee { address: "c".into(), fraction: 0.5 });
|
||||
assert_eq!(snap[1], Payee { address: "a".into(), fraction: 0.25 });
|
||||
assert_eq!(snap[2], Payee { address: "b".into(), fraction: 0.25 });
|
||||
let sum: f64 = snap.iter().map(|p| p.fraction).sum();
|
||||
assert!((sum - 1.0).abs() < 1e-12);
|
||||
assert!(Pplns::new(1.0).snapshot().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_joiner_is_paid_only_for_its_part_of_the_window() {
|
||||
let mut p = Pplns::new(2.0);
|
||||
for _ in 0..8 {
|
||||
p.push(share("old", 0.25));
|
||||
}
|
||||
p.push(share("new", 0.25)); // pushes the oldest 0.25 out
|
||||
let snap = p.snapshot();
|
||||
let new = snap.iter().find(|x| x.address == "new").unwrap();
|
||||
assert!((new.fraction - 0.125).abs() < 1e-12, "1 of 8 shares in the window: {}", new.fraction);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distribution_takes_the_fee_first_and_never_overpays() {
|
||||
let payees = vec![Payee { address: "a".into(), fraction: 2.0 / 3.0 }, Payee { address: "b".into(), fraction: 1.0 / 3.0 }];
|
||||
let reward = 1_000_000_000_000_000_000u128; // 1 IGN
|
||||
let (parts, kept) = distribute(reward, 1.0, &payees);
|
||||
let fee = reward / 100;
|
||||
let paid: u128 = parts.iter().map(|p| p.1).sum();
|
||||
assert!(paid <= reward - fee);
|
||||
assert!(paid + kept == reward, "every wei is accounted for");
|
||||
assert!(kept >= fee && kept < fee + 10, "fee plus at most rounding dust: {kept}");
|
||||
assert_eq!(parts[0].0, "a");
|
||||
assert!((parts[0].1 as f64 / (reward - fee) as f64 - 2.0 / 3.0).abs() < 1e-9);
|
||||
// zero fee, one payee: everything
|
||||
let (parts, kept) = distribute(1000, 0.0, &[Payee { address: "a".into(), fraction: 1.0 }]);
|
||||
assert_eq!((parts[0].1, kept), (1000, 0));
|
||||
// no payees: the pool keeps it
|
||||
assert_eq!(distribute(1000, 1.0, &[]).1, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_counts_weight_since_the_previous_block() {
|
||||
let mut p = Pplns::new(10.0);
|
||||
p.push(share("a", 0.5));
|
||||
p.push(share("a", 0.75));
|
||||
assert!((p.take_effort() - 1.25).abs() < 1e-12);
|
||||
assert_eq!(p.take_effort(), 0.0);
|
||||
}
|
||||
}
|
||||
228
pool/src/protocol.rs
Normal file
228
pool/src/protocol.rs
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
//! The pool protocol on the wire: spec 09 section 9.5, one JSON object per line, `"t"` names the type.
|
||||
//!
|
||||
//! v0 encoding notes (docs/plans/pool.md): a `template` carries the node's own `RpcRawBlock` JSON (mode A, the full
|
||||
//! block, so a member with a node can submit it there as well); `authorize` carries the member's payout address and
|
||||
//! worker name beside the key, because v0 pays by EVM address and the spec's message names the member by key only;
|
||||
//! `job` carries the seed pair the worker protocol wants (`epoch_seed`, `day`) so a member needs no `seeds` lookup
|
||||
//! per job. Unknown fields are ignored, as the spec requires.
|
||||
|
||||
use kaspa_rpc_core::RpcRawBlock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Protocol version string sent in `hello.versions` and `welcome.version`.
|
||||
pub const VERSION: &str = "0.1";
|
||||
/// Largest line accepted on either side (spec 9.3: 4 MiB, a template with its transactions).
|
||||
pub const MAX_LINE: usize = 4 << 20;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "t", rename_all = "snake_case")]
|
||||
pub enum Msg {
|
||||
Hello {
|
||||
versions: Vec<String>,
|
||||
chain_id: u64,
|
||||
client: String,
|
||||
#[serde(default)]
|
||||
modes: Vec<String>,
|
||||
},
|
||||
Welcome {
|
||||
version: String,
|
||||
chain_id: u64,
|
||||
network: String,
|
||||
pool_address: String,
|
||||
modes: Vec<String>,
|
||||
vote_mode: String,
|
||||
share_scheme: ShareScheme,
|
||||
min_shift: u32,
|
||||
max_shift: u32,
|
||||
share_interval_s: u64,
|
||||
stale_grace_ms: u64,
|
||||
pool_name: String,
|
||||
},
|
||||
Authorize {
|
||||
/// 48 bytes hex, the member's BLS vote key
|
||||
pubkey: String,
|
||||
/// 96 bytes hex, the proof of possession (spec 3.10 KeyReveal)
|
||||
pop: String,
|
||||
/// Worker name shown on the dashboards
|
||||
label: String,
|
||||
/// EVM payout address, 0x + 40 hex (v0 addition)
|
||||
payout: String,
|
||||
/// `binding` of spec 9.3 (a signature over the TLS exporter) is absent in v0: no TLS yet
|
||||
#[serde(default)]
|
||||
binding: String,
|
||||
},
|
||||
Authorized {
|
||||
member_id: u64,
|
||||
revealed: bool,
|
||||
key_hash: String,
|
||||
},
|
||||
Seeds {
|
||||
epoch_seed: String,
|
||||
day: u64,
|
||||
day_seed: String,
|
||||
seed_source: String,
|
||||
next_epoch_seed: Option<String>,
|
||||
next_at_daa: u64,
|
||||
epoch_blocks: u64,
|
||||
epoch_lead: u64,
|
||||
day_ms: u64,
|
||||
},
|
||||
SetTarget {
|
||||
shift: u32,
|
||||
share_target64: String,
|
||||
},
|
||||
Template {
|
||||
template_id: u64,
|
||||
mode: String,
|
||||
block: Box<RpcRawBlock>,
|
||||
daa_score: u64,
|
||||
bits: u32,
|
||||
#[serde(default)]
|
||||
votes_carried: Vec<u64>,
|
||||
},
|
||||
Job {
|
||||
job_id: u64,
|
||||
template_id: u64,
|
||||
prehash: String,
|
||||
target64: String,
|
||||
share_target64: String,
|
||||
nonce_start: String,
|
||||
nonce_count: String,
|
||||
epoch_seed: String,
|
||||
day: u64,
|
||||
clean: bool,
|
||||
},
|
||||
JobRefused {
|
||||
job_id: u64,
|
||||
code: String,
|
||||
detail: String,
|
||||
},
|
||||
Share {
|
||||
job_id: u64,
|
||||
nonce: String,
|
||||
hash: String,
|
||||
},
|
||||
ShareResult {
|
||||
job_id: u64,
|
||||
nonce: String,
|
||||
accepted: bool,
|
||||
code: String,
|
||||
weight: f64,
|
||||
#[serde(default)]
|
||||
block: bool,
|
||||
},
|
||||
Solution {
|
||||
job_id: u64,
|
||||
nonce: String,
|
||||
hash: String,
|
||||
block_hash: String,
|
||||
},
|
||||
Stats {
|
||||
#[serde(default)]
|
||||
hashrate: f64,
|
||||
#[serde(default)]
|
||||
workers: u32,
|
||||
#[serde(default)]
|
||||
refusals: u64,
|
||||
#[serde(default)]
|
||||
shares: u64,
|
||||
#[serde(default)]
|
||||
members: u32,
|
||||
#[serde(default)]
|
||||
blocks_24h: u64,
|
||||
#[serde(default)]
|
||||
declared_share: f64,
|
||||
},
|
||||
Ping {
|
||||
id: u64,
|
||||
},
|
||||
Pong {
|
||||
id: u64,
|
||||
},
|
||||
Bye {
|
||||
reason: String,
|
||||
},
|
||||
Error {
|
||||
code: String,
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ShareScheme {
|
||||
pub scheme: String,
|
||||
pub fee_percent: f64,
|
||||
pub pplns_window_blocks: f64,
|
||||
pub min_payout_ign: f64,
|
||||
}
|
||||
|
||||
impl Msg {
|
||||
/// One line, newline terminated.
|
||||
pub fn line(&self) -> String {
|
||||
let mut s = serde_json::to_string(self).expect("message serialises");
|
||||
s.push('\n');
|
||||
s
|
||||
}
|
||||
pub fn parse(line: &str) -> Result<Msg, String> {
|
||||
serde_json::from_str(line).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hex_u64(v: u64) -> String {
|
||||
format!("{v:016x}")
|
||||
}
|
||||
|
||||
pub fn parse_hex_u64(s: &str) -> Option<u64> {
|
||||
u64::from_str_radix(s.trim_start_matches("0x"), 16).ok()
|
||||
}
|
||||
|
||||
pub fn hex_bytes(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
pub fn parse_hex_array<const N: usize>(s: &str) -> Option<[u8; N]> {
|
||||
let s = s.trim_start_matches("0x");
|
||||
if s.len() != 2 * N {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; N];
|
||||
for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
|
||||
out[i] = u8::from_str_radix(std::str::from_utf8(chunk).ok()?, 16).ok()?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn messages_round_trip_with_the_type_tag() {
|
||||
let m = Msg::Share { job_id: 7, nonce: hex_u64(0x1234), hash: hex_u64(42) };
|
||||
let line = m.line();
|
||||
assert!(line.starts_with("{\"t\":\"share\""), "{line}");
|
||||
assert!(line.ends_with('\n'));
|
||||
match Msg::parse(line.trim()).unwrap() {
|
||||
Msg::Share { job_id, nonce, hash } => {
|
||||
assert_eq!(job_id, 7);
|
||||
assert_eq!(parse_hex_u64(&nonce), Some(0x1234));
|
||||
assert_eq!(parse_hex_u64(&hash), Some(42));
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_are_ignored_and_missing_optionals_default() {
|
||||
let m = Msg::parse(r#"{"t":"hello","versions":["0.1"],"chain_id":4463,"client":"x","extra":1}"#).unwrap();
|
||||
assert!(matches!(m, Msg::Hello { modes, .. } if modes.is_empty()));
|
||||
assert!(Msg::parse(r#"{"t":"nope"}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_helpers() {
|
||||
assert_eq!(parse_hex_array::<2>("0a0b"), Some([10, 11]));
|
||||
assert_eq!(parse_hex_array::<2>("0a0"), None);
|
||||
assert_eq!(hex_bytes(&[255, 0]), "ff00");
|
||||
}
|
||||
}
|
||||
325
pool/src/server.rs
Normal file
325
pool/src/server.rs
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
//! Member connections: newline JSON over TCP (spec 9.3 wants TLS 1.3; v0 listens in the clear and the README puts a
|
||||
//! TLS terminator in front, docs/plans/pool.md). One reader task and one writer task per connection.
|
||||
|
||||
use crate::node::{fetch_job, submit_block};
|
||||
use crate::pool::{Member, MemberInner, Pool};
|
||||
use crate::protocol::{hex_u64, parse_hex_array, parse_hex_u64, Msg, ShareScheme, MAX_LINE, VERSION};
|
||||
use crate::vardiff::{share_target, share_weight, Vardiff};
|
||||
use crate::verify::{check, Code};
|
||||
use kaspa_consensus_core::finality::{verify_pop, KeyReveal};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
fn now() -> String {
|
||||
let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap();
|
||||
format!("{}.{:03}", t.as_secs(), t.subsec_millis())
|
||||
}
|
||||
|
||||
pub async fn listen(pool: Arc<Pool>) {
|
||||
let listener = tokio::net::TcpListener::bind(&pool.cfg.listen).await.unwrap_or_else(|e| {
|
||||
eprintln!("cannot listen on {}: {e}", pool.cfg.listen);
|
||||
std::process::exit(1)
|
||||
});
|
||||
println!("{} pool: members on {} (chain id {}, {})", now(), pool.cfg.listen, pool.cfg.chain_id(), pool.cfg.network);
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((sock, addr)) => {
|
||||
let pool = pool.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = sock.set_nodelay(true);
|
||||
connection(pool, sock, addr.to_string()).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{} accept: {e}", now());
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn welcome(pool: &Pool) -> Msg {
|
||||
Msg::Welcome {
|
||||
version: VERSION.into(),
|
||||
chain_id: pool.cfg.chain_id(),
|
||||
network: pool.cfg.network.clone(),
|
||||
pool_address: pool.pool_address_hex.clone(),
|
||||
modes: vec!["A".into()],
|
||||
vote_mode: "member".into(),
|
||||
share_scheme: ShareScheme { scheme: "pplns".into(), fee_percent: pool.cfg.fee_percent, pplns_window_blocks: pool.cfg.pplns_window_blocks, min_payout_ign: pool.cfg.min_payout_ign },
|
||||
min_shift: pool.cfg.min_shift,
|
||||
max_shift: pool.cfg.max_shift,
|
||||
share_interval_s: pool.cfg.share_interval_s,
|
||||
stale_grace_ms: pool.cfg.stale_grace_ms,
|
||||
pool_name: pool.cfg.name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn connection(pool: Arc<Pool>, sock: TcpStream, remote: String) {
|
||||
let (rd, mut wr) = sock.into_split();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
let writer = tokio::spawn(async move {
|
||||
while let Some(line) = rx.recv().await {
|
||||
if wr.write_all(line.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut lines = BufReader::with_capacity(64 << 10, rd).lines();
|
||||
let mut member: Option<Arc<Member>> = None;
|
||||
let mut said_hello = false;
|
||||
let mut buf_guard = 0usize;
|
||||
loop {
|
||||
let l = match tokio::time::timeout(Duration::from_secs(300), lines.next_line()).await {
|
||||
Ok(Ok(Some(l))) => l,
|
||||
Ok(Ok(None)) => break,
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("{} {remote}: read error: {e}", now());
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = tx.send(Msg::Bye { reason: "idle for 300 s".into() }.line());
|
||||
break;
|
||||
}
|
||||
};
|
||||
buf_guard += l.len();
|
||||
if l.len() > MAX_LINE {
|
||||
let _ = tx.send(Msg::Bye { reason: "line over 4 MiB".into() }.line());
|
||||
break;
|
||||
}
|
||||
let msg = match Msg::parse(&l) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
let _ = tx.send(Msg::Error { code: "parse".into(), detail: e }.line());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match msg {
|
||||
Msg::Hello { versions, chain_id, client, .. } => {
|
||||
if !versions.iter().any(|v| v == VERSION) {
|
||||
let _ = tx.send(Msg::Bye { reason: format!("protocol {VERSION} only") }.line());
|
||||
break;
|
||||
}
|
||||
if chain_id != pool.cfg.chain_id() {
|
||||
let _ = tx.send(Msg::Bye { reason: format!("chain id {} here, you said {chain_id}", pool.cfg.chain_id()) }.line());
|
||||
break;
|
||||
}
|
||||
said_hello = true;
|
||||
println!("{} {remote}: hello from {client}", now());
|
||||
let _ = tx.send(welcome(&pool).line());
|
||||
}
|
||||
Msg::Authorize { pubkey, pop, label, payout, .. } => {
|
||||
if !said_hello {
|
||||
let _ = tx.send(Msg::Error { code: "order".into(), detail: "hello first".into() }.line());
|
||||
continue;
|
||||
}
|
||||
let (Some(pk), Some(pp)) = (parse_hex_array::<48>(&pubkey), parse_hex_array::<96>(&pop)) else {
|
||||
let _ = tx.send(Msg::Bye { reason: "pubkey is 48 bytes hex and pop 96".into() }.line());
|
||||
break;
|
||||
};
|
||||
if !verify_pop(&pk, &pp) {
|
||||
let _ = tx.send(Msg::Bye { reason: "proof of possession does not verify".into() }.line());
|
||||
break;
|
||||
}
|
||||
let Some(addr) = parse_hex_array::<20>(&payout) else {
|
||||
let _ = tx.send(Msg::Bye { reason: "payout is 0x followed by 40 hex".into() }.line());
|
||||
break;
|
||||
};
|
||||
let reveal = KeyReveal { pubkey: pk, pop: pp };
|
||||
let key_hash = reveal.key_hash();
|
||||
let worker: String = label.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.').take(32).collect();
|
||||
let worker = if worker.is_empty() { "worker".to_string() } else { worker };
|
||||
let id = pool.next_member_id.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let m = Arc::new(Member {
|
||||
id,
|
||||
address: format!("0x{}", hex::encode(addr)),
|
||||
worker: worker.clone(),
|
||||
key_hash,
|
||||
reveal,
|
||||
remote: remote.clone(),
|
||||
connected_at: Instant::now(),
|
||||
tx: tx.clone(),
|
||||
inner: Mutex::new(MemberInner {
|
||||
vardiff: Vardiff::new(pool.cfg.min_shift, pool.cfg.min_shift, pool.cfg.max_shift, pool.cfg.share_interval_s as f64, pool.now_s()),
|
||||
jobs: VecDeque::new(),
|
||||
last_parents: None,
|
||||
last_seeds: None,
|
||||
accepted: 0,
|
||||
stale: 0,
|
||||
rejected: 0,
|
||||
blocks: 0,
|
||||
refused: 0,
|
||||
last_share: None,
|
||||
revealed: false,
|
||||
reported_hashrate: 0.0,
|
||||
reported_workers: 1,
|
||||
proving: false,
|
||||
current_target64: 0,
|
||||
in_flight_checks: 0,
|
||||
}),
|
||||
});
|
||||
pool.members.lock().unwrap().insert(id, m.clone());
|
||||
member = Some(m.clone());
|
||||
println!("{} {remote}: member {id} '{worker}' key {} pays {}", now(), &key_hash.to_string()[..16], m.address);
|
||||
let _ = tx.send(Msg::Authorized { member_id: id, revealed: false, key_hash: key_hash.to_string() }.line());
|
||||
let pool2 = pool.clone();
|
||||
let m2 = m.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = fetch_job(&pool2, &m2).await {
|
||||
eprintln!("{} first job for member {}: {e}", now(), m2.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
Msg::Share { job_id, nonce, hash } => {
|
||||
let Some(m) = member.clone() else {
|
||||
let _ = tx.send(Msg::Error { code: "order".into(), detail: "authorize first".into() }.line());
|
||||
continue;
|
||||
};
|
||||
let (Some(n), Some(h)) = (parse_hex_u64(&nonce), parse_hex_u64(&hash)) else {
|
||||
let _ = tx.send(Msg::ShareResult { job_id, nonce, accepted: false, code: "parse".into(), weight: 0.0, block: false }.line());
|
||||
continue;
|
||||
};
|
||||
handle_share(&pool, &m, job_id, n, h, false).await;
|
||||
}
|
||||
Msg::Solution { job_id, nonce, hash, .. } => {
|
||||
// a solution follows the same nonce's share (the member sends both, spec 9.5): the share already
|
||||
// credited and submitted it, so a seen nonce is acknowledged and not counted twice
|
||||
let Some(m) = member.clone() else {
|
||||
let _ = tx.send(Msg::Error { code: "order".into(), detail: "authorize first".into() }.line());
|
||||
continue;
|
||||
};
|
||||
let (Some(n), Some(h)) = (parse_hex_u64(&nonce), parse_hex_u64(&hash)) else {
|
||||
let _ = tx.send(Msg::ShareResult { job_id, nonce, accepted: false, code: "parse".into(), weight: 0.0, block: false }.line());
|
||||
continue;
|
||||
};
|
||||
handle_share(&pool, &m, job_id, n, h, true).await;
|
||||
}
|
||||
Msg::Stats { hashrate, workers, .. } => {
|
||||
if let Some(m) = &member {
|
||||
let mut g = m.inner.lock().unwrap();
|
||||
g.reported_hashrate = hashrate;
|
||||
g.reported_workers = workers.max(1);
|
||||
// consequence C6: a member that also proves reports it in `stats` (the `proving` field of the
|
||||
// v0 stats line), so a 4% share drop reads as proving and not as a sick card
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&l) {
|
||||
g.proving = v.get("proving").and_then(|p| p.as_bool()).unwrap_or(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Msg::JobRefused { job_id, code, detail } => {
|
||||
if let Some(m) = &member {
|
||||
m.inner.lock().unwrap().refused += 1;
|
||||
println!("{} member {} refused job {job_id}: {code} {detail}", now(), m.id);
|
||||
}
|
||||
}
|
||||
Msg::Ping { id } => {
|
||||
let _ = tx.send(Msg::Pong { id }.line());
|
||||
}
|
||||
Msg::Pong { .. } => {}
|
||||
Msg::Bye { reason } => {
|
||||
println!("{} {remote}: bye ({reason})", now());
|
||||
break;
|
||||
}
|
||||
other => {
|
||||
let _ = tx.send(Msg::Error { code: "unexpected".into(), detail: format!("{:?}", std::mem::discriminant(&other)) }.line());
|
||||
}
|
||||
}
|
||||
if buf_guard > (64 << 20) {
|
||||
buf_guard = 0;
|
||||
}
|
||||
}
|
||||
if let Some(m) = member {
|
||||
pool.members.lock().unwrap().remove(&m.id);
|
||||
let g = m.inner.lock().unwrap();
|
||||
println!("{} {remote}: member {} '{}' left after {:.0} s: accepted={} stale={} rejected={} blocks={}", now(), m.id, m.worker, m.connected_at.elapsed().as_secs_f64(), g.accepted, g.stale, g.rejected, g.blocks);
|
||||
}
|
||||
drop(tx);
|
||||
let _ = writer.await;
|
||||
}
|
||||
|
||||
async fn handle_share(pool: &Arc<Pool>, m: &Arc<Member>, job_id: u64, nonce: u64, claimed: u64, is_solution: bool) {
|
||||
// Book-keeping under the lock: the job, duplicate and stale checks
|
||||
let found = {
|
||||
let mut g = m.inner.lock().unwrap();
|
||||
let grace = Duration::from_millis(pool.cfg.stale_grace_ms);
|
||||
match g.jobs.iter_mut().find(|j| j.job_id == job_id) {
|
||||
None => Err(Code::UnknownJob),
|
||||
Some(j) => {
|
||||
if !j.seen.insert(nonce) {
|
||||
Err(Code::Duplicate)
|
||||
} else if j.superseded_at.is_some_and(|t| t.elapsed() > grace) {
|
||||
Err(Code::Stale)
|
||||
} else {
|
||||
Ok((j.key.clone(), j.epoch.clone(), j.shift, j.raw.clone(), j.daa_score, j.blue_score))
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let (key, epoch, shift, raw, daa, blue) = match found {
|
||||
Ok(x) => x,
|
||||
Err(Code::Duplicate) if is_solution => return,
|
||||
Err(code) => {
|
||||
{
|
||||
let mut s = pool.state.lock().unwrap();
|
||||
s.refuse_share(&m.address, &m.worker, code == Code::Stale, None);
|
||||
}
|
||||
{
|
||||
let mut g = m.inner.lock().unwrap();
|
||||
if code == Code::Stale {
|
||||
g.stale += 1
|
||||
} else {
|
||||
g.rejected += 1
|
||||
}
|
||||
}
|
||||
m.send(Msg::ShareResult { job_id, nonce: hex_u64(nonce), accepted: false, code: code.as_str().into(), weight: 0.0, block: false }.line());
|
||||
return;
|
||||
}
|
||||
};
|
||||
// The CPU evaluation, off the async threads, bounded by the verifier permits
|
||||
let permit = pool.verify_permits.acquire().await;
|
||||
let key2 = key.clone();
|
||||
let v = tokio::task::spawn_blocking(move || check(&epoch, &key2, nonce, claimed)).await.expect("verifier task");
|
||||
drop(permit);
|
||||
let now_s = pool.now_s();
|
||||
if v.code != Code::Ok {
|
||||
{
|
||||
let mut s = pool.state.lock().unwrap();
|
||||
s.refuse_share(&m.address, &m.worker, false, Some(v.cost_ms));
|
||||
}
|
||||
m.inner.lock().unwrap().rejected += 1;
|
||||
m.send(Msg::ShareResult { job_id, nonce: hex_u64(nonce), accepted: false, code: v.code.as_str().into(), weight: 0.0, block: false }.line());
|
||||
if v.code == Code::WrongHash {
|
||||
println!("{} member {} ({}) WRONG HASH nonce={:#x} claimed={:016x} pool={:016x}", now(), m.id, m.worker, nonce, claimed, v.hash);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let weight = share_weight(shift);
|
||||
{
|
||||
let mut s = pool.state.lock().unwrap();
|
||||
s.accept_share(&m.address, &m.worker, weight, key.share_target64, v.cost_ms);
|
||||
}
|
||||
let retarget = {
|
||||
let mut g = m.inner.lock().unwrap();
|
||||
g.accepted += 1;
|
||||
g.last_share = Some(Instant::now());
|
||||
g.vardiff.on_share(now_s);
|
||||
let t64 = g.current_target64;
|
||||
g.vardiff.retarget(now_s, t64).map(|s| (s, share_target(t64, s)))
|
||||
};
|
||||
m.send(Msg::ShareResult { job_id, nonce: hex_u64(nonce), accepted: true, code: "ok".into(), weight, block: v.block }.line());
|
||||
if let Some((s, st)) = retarget {
|
||||
m.send(Msg::SetTarget { shift: s, share_target64: hex_u64(st) }.line());
|
||||
println!("{} VARDIFF member {} ({}) shift -> {} (share target {:016x})", now(), m.id, m.worker, s, st);
|
||||
let (pool2, m2) = (pool.clone(), m.clone());
|
||||
tokio::spawn(async move {
|
||||
let _ = fetch_job(&pool2, &m2).await;
|
||||
});
|
||||
}
|
||||
if v.block {
|
||||
tokio::spawn(submit_block(pool.clone(), m.clone(), raw, nonce, shift, daa, blue));
|
||||
}
|
||||
}
|
||||
304
pool/src/state.rs
Normal file
304
pool/src/state.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
//! The pool's ledger: shares, blocks, balances, payments and the per-miner counters. One `Mutex<State>` with short
|
||||
//! critical sections; a JSON snapshot every `snapshot_interval_s` and at shutdown (v0 persistence, docs/plans/pool.md).
|
||||
|
||||
use crate::pplns::{Payee, Pplns, ShareRec};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::path::Path;
|
||||
|
||||
pub const WEI_PER_IGN: u128 = 1_000_000_000_000_000_000;
|
||||
/// 1 IGN = 10^8 sompi on the UTXO side = 10^18 wei on the EVM side (igneum/exec/src/config.rs WEI_PER_SOMPI)
|
||||
pub const WEI_PER_SOMPI: u128 = 10_000_000_000;
|
||||
|
||||
pub fn unix_ms() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn ign(wei: u128) -> f64 {
|
||||
wei as f64 / WEI_PER_IGN as f64
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct BlockRec {
|
||||
pub hash: String,
|
||||
pub daa_score: u64,
|
||||
pub blue_score: u64,
|
||||
pub found_ms: u64,
|
||||
pub finder: String,
|
||||
pub worker: String,
|
||||
/// pending, confirmed, orphan
|
||||
pub status: String,
|
||||
pub reward_wei: u128,
|
||||
pub fee_wei: u128,
|
||||
pub effort: f64,
|
||||
pub payees: Vec<Payee>,
|
||||
pub confirmed_ms: Option<u64>,
|
||||
pub nonce: String,
|
||||
pub shift: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct PaymentRec {
|
||||
pub at_ms: u64,
|
||||
pub address: String,
|
||||
pub amount_wei: u128,
|
||||
pub tx_hash: Option<String>,
|
||||
/// dry-run, sent, confirmed, failed
|
||||
pub status: String,
|
||||
pub dry_run: bool,
|
||||
pub nonce: Option<u64>,
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct WorkerRec {
|
||||
pub accepted: u64,
|
||||
pub stale: u64,
|
||||
pub rejected: u64,
|
||||
pub last_share_ms: u64,
|
||||
pub last_seen_ms: u64,
|
||||
pub blocks: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct MinerRec {
|
||||
pub accepted: u64,
|
||||
pub stale: u64,
|
||||
pub rejected: u64,
|
||||
pub blocks: u64,
|
||||
pub last_share_ms: u64,
|
||||
pub first_seen_ms: u64,
|
||||
pub workers: HashMap<String, WorkerRec>,
|
||||
}
|
||||
|
||||
/// One accepted share for the hashrate windows: expected hashes at its target.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Sample {
|
||||
pub at_ms: u64,
|
||||
pub address: String,
|
||||
pub worker: String,
|
||||
pub hashes: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct State {
|
||||
pub started_ms: u64,
|
||||
pub pplns: Pplns,
|
||||
pub blocks: Vec<BlockRec>,
|
||||
pub balances: HashMap<String, u128>,
|
||||
pub paid: HashMap<String, u128>,
|
||||
pub payments: Vec<PaymentRec>,
|
||||
pub miners: HashMap<String, MinerRec>,
|
||||
pub accepted: u64,
|
||||
pub stale: u64,
|
||||
pub rejected: u64,
|
||||
pub blocks_found: u64,
|
||||
pub blocks_confirmed: u64,
|
||||
pub blocks_orphaned: u64,
|
||||
pub pool_fee_wei: u128,
|
||||
pub submit_errors: u64,
|
||||
#[serde(skip)]
|
||||
pub samples: VecDeque<Sample>,
|
||||
/// Verification cost of the last 20,000 shares, milliseconds
|
||||
#[serde(skip)]
|
||||
pub check_ms: VecDeque<f64>,
|
||||
#[serde(skip)]
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn new(window_blocks: f64) -> Self {
|
||||
State {
|
||||
started_ms: unix_ms(),
|
||||
pplns: Pplns::new(window_blocks),
|
||||
blocks: Vec::new(),
|
||||
balances: HashMap::new(),
|
||||
paid: HashMap::new(),
|
||||
payments: Vec::new(),
|
||||
miners: HashMap::new(),
|
||||
accepted: 0,
|
||||
stale: 0,
|
||||
rejected: 0,
|
||||
blocks_found: 0,
|
||||
blocks_confirmed: 0,
|
||||
blocks_orphaned: 0,
|
||||
pool_fee_wei: 0,
|
||||
submit_errors: 0,
|
||||
samples: VecDeque::new(),
|
||||
check_ms: VecDeque::new(),
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(path: &Path, window_blocks: f64) -> Self {
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => match serde_json::from_slice::<State>(&bytes) {
|
||||
Ok(mut s) => {
|
||||
s.pplns.window_blocks = window_blocks;
|
||||
s.dirty = false;
|
||||
eprintln!("state: loaded {} ({} blocks, {} payments, {} miners)", path.display(), s.blocks.len(), s.payments.len(), s.miners.len());
|
||||
s
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("state: {} unreadable ({e}); starting empty", path.display());
|
||||
State::new(window_blocks)
|
||||
}
|
||||
},
|
||||
Err(_) => State::new(window_blocks),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&mut self, path: &Path) -> std::io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, serde_json::to_vec(self)?)?;
|
||||
std::fs::rename(tmp, path)?;
|
||||
self.dirty = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn miner(&mut self, address: &str, worker: &str, now: u64) -> &mut WorkerRec {
|
||||
let m = self.miners.entry(address.to_string()).or_insert_with(|| MinerRec { first_seen_ms: now, ..Default::default() });
|
||||
let w = m.workers.entry(worker.to_string()).or_default();
|
||||
w.last_seen_ms = now;
|
||||
w
|
||||
}
|
||||
|
||||
pub fn accept_share(&mut self, address: &str, worker: &str, weight: f64, share_target64: u64, cost_ms: f64) {
|
||||
let now = unix_ms();
|
||||
self.accepted += 1;
|
||||
self.pplns.push(ShareRec { address: address.into(), worker: worker.into(), weight, at_ms: now });
|
||||
let hashes = if share_target64 == 0 { 0.0 } else { (u64::MAX as f64 + 1.0) / share_target64 as f64 };
|
||||
self.samples.push_back(Sample { at_ms: now, address: address.into(), worker: worker.into(), hashes });
|
||||
let cutoff = now.saturating_sub(15 * 60 * 1000);
|
||||
while self.samples.front().is_some_and(|s| s.at_ms < cutoff) {
|
||||
self.samples.pop_front();
|
||||
}
|
||||
self.check_ms.push_back(cost_ms);
|
||||
while self.check_ms.len() > 20_000 {
|
||||
self.check_ms.pop_front();
|
||||
}
|
||||
let w = self.miner(address, worker, now);
|
||||
w.accepted += 1;
|
||||
w.last_share_ms = now;
|
||||
let m = self.miners.get_mut(address).unwrap();
|
||||
m.accepted += 1;
|
||||
m.last_share_ms = now;
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
pub fn refuse_share(&mut self, address: &str, worker: &str, stale: bool, cost_ms: Option<f64>) {
|
||||
let now = unix_ms();
|
||||
if let Some(c) = cost_ms {
|
||||
self.check_ms.push_back(c);
|
||||
}
|
||||
if stale {
|
||||
self.stale += 1;
|
||||
} else {
|
||||
self.rejected += 1;
|
||||
}
|
||||
let w = self.miner(address, worker, now);
|
||||
if stale {
|
||||
w.stale += 1
|
||||
} else {
|
||||
w.rejected += 1
|
||||
}
|
||||
let m = self.miners.get_mut(address).unwrap();
|
||||
if stale {
|
||||
m.stale += 1
|
||||
} else {
|
||||
m.rejected += 1
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
pub fn block_found(&mut self, mut rec: BlockRec) {
|
||||
let now = unix_ms();
|
||||
rec.payees = self.pplns.snapshot();
|
||||
rec.effort = self.pplns.take_effort();
|
||||
self.blocks_found += 1;
|
||||
let w = self.miner(&rec.finder.clone(), &rec.worker.clone(), now);
|
||||
w.blocks += 1;
|
||||
self.miners.get_mut(&rec.finder).unwrap().blocks += 1;
|
||||
self.blocks.push(rec);
|
||||
while self.blocks.len() > 5000 {
|
||||
self.blocks.remove(0);
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Credits a confirmed block to its payees (the PPLNS snapshot of the moment it was found).
|
||||
pub fn confirm_block(&mut self, hash: &str, fee_percent: f64) -> Option<(u128, Vec<(String, u128)>)> {
|
||||
let idx = self.blocks.iter().position(|b| b.hash == hash && b.status == "pending")?;
|
||||
let reward = self.blocks[idx].reward_wei;
|
||||
let (parts, kept) = crate::pplns::distribute(reward, fee_percent, &self.blocks[idx].payees);
|
||||
for (a, wei) in &parts {
|
||||
*self.balances.entry(a.clone()).or_insert(0) += wei;
|
||||
}
|
||||
self.pool_fee_wei += kept;
|
||||
self.blocks_confirmed += 1;
|
||||
let b = &mut self.blocks[idx];
|
||||
b.status = "confirmed".into();
|
||||
b.fee_wei = kept;
|
||||
b.confirmed_ms = Some(unix_ms());
|
||||
self.dirty = true;
|
||||
Some((reward, parts))
|
||||
}
|
||||
|
||||
pub fn orphan_block(&mut self, hash: &str) -> bool {
|
||||
let Some(b) = self.blocks.iter_mut().find(|b| b.hash == hash && b.status == "pending") else { return false };
|
||||
b.status = "orphan".into();
|
||||
self.blocks_orphaned += 1;
|
||||
self.dirty = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// Hashes per second over the last `secs` seconds, pool-wide or for one address or worker.
|
||||
pub fn hashrate(&self, secs: u64, address: Option<&str>, worker: Option<&str>) -> f64 {
|
||||
let now = unix_ms();
|
||||
let cutoff = now.saturating_sub(secs * 1000);
|
||||
let span = (now.saturating_sub(self.started_ms.max(cutoff))).max(1000) as f64 / 1000.0;
|
||||
let sum: f64 = self
|
||||
.samples
|
||||
.iter()
|
||||
.filter(|s| s.at_ms >= cutoff && address.is_none_or(|a| s.address == a) && worker.is_none_or(|w| s.worker == w))
|
||||
.map(|s| s.hashes)
|
||||
.sum();
|
||||
sum / span
|
||||
}
|
||||
|
||||
pub fn blocks_in(&self, secs: u64) -> (u64, u64, u64) {
|
||||
let cutoff = unix_ms().saturating_sub(secs * 1000);
|
||||
let mut found = 0;
|
||||
let mut confirmed = 0;
|
||||
let mut orphan = 0;
|
||||
for b in self.blocks.iter().filter(|b| b.found_ms >= cutoff) {
|
||||
found += 1;
|
||||
match b.status.as_str() {
|
||||
"confirmed" => confirmed += 1,
|
||||
"orphan" => orphan += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(found, confirmed, orphan)
|
||||
}
|
||||
|
||||
pub fn paid_in(&self, secs: u64) -> u128 {
|
||||
let cutoff = unix_ms().saturating_sub(secs * 1000);
|
||||
self.payments.iter().filter(|p| p.at_ms >= cutoff && !p.dry_run && p.status != "failed").map(|p| p.amount_wei).sum()
|
||||
}
|
||||
|
||||
/// Share check cost: (count, mean ms, p50, p99, max)
|
||||
pub fn check_cost(&self) -> (usize, f64, f64, f64, f64) {
|
||||
if self.check_ms.is_empty() {
|
||||
return (0, 0.0, 0.0, 0.0, 0.0);
|
||||
}
|
||||
let mut v: Vec<f64> = self.check_ms.iter().copied().collect();
|
||||
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let n = v.len();
|
||||
let mean = v.iter().sum::<f64>() / n as f64;
|
||||
(n, mean, v[n / 2], v[(n * 99 / 100).min(n - 1)], v[n - 1])
|
||||
}
|
||||
}
|
||||
229
pool/src/vardiff.rs
Normal file
229
pool/src/vardiff.rs
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
//! Variable difficulty per member (spec 09 section 9.8 item 4): choose the shift `s` so that the member sends about
|
||||
//! one share per `share_interval_s`. The share target is `target64 << s`; a larger `s` means easier shares.
|
||||
//!
|
||||
//! Rules. `s` never saturates the 64-bit target (`s < leading_zeros(target64)`), and stays inside the pool's
|
||||
//! `[min_shift, max_shift]`. After the first correction, the shift moves by ONE per change and never more than once
|
||||
//! per `min_change_s` (30 s, Designed), so the share rate is a smooth hashrate estimate. The FIRST correction is
|
||||
//! sized from the measured rate (up to 8 steps at once) because a new member's hashrate is unknown and a card at
|
||||
//! 100 MH/s would otherwise flood the verifier for minutes; the plan says so (docs/plans/pool.md).
|
||||
//! Idle rule: a member that sent no share for three intervals since the last change gets one easier step.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Vardiff {
|
||||
pub shift: u32,
|
||||
pub min_shift: u32,
|
||||
pub max_shift: u32,
|
||||
pub interval_s: f64,
|
||||
pub min_change_s: f64,
|
||||
started_s: f64,
|
||||
last_change_s: f64,
|
||||
/// Share times since the last change (seconds)
|
||||
times: VecDeque<f64>,
|
||||
first_done: bool,
|
||||
pub changes: u64,
|
||||
}
|
||||
|
||||
impl Vardiff {
|
||||
pub fn new(initial_shift: u32, min_shift: u32, max_shift: u32, interval_s: f64, now_s: f64) -> Self {
|
||||
Self {
|
||||
shift: initial_shift.clamp(min_shift, max_shift),
|
||||
min_shift,
|
||||
max_shift,
|
||||
interval_s,
|
||||
min_change_s: 30.0,
|
||||
started_s: now_s,
|
||||
last_change_s: now_s,
|
||||
times: VecDeque::new(),
|
||||
first_done: false,
|
||||
changes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest shift that keeps `target64 << s` under 2^64 (spec: a saturated target makes every hash a share).
|
||||
pub fn cap_for(target64: u64) -> u32 {
|
||||
target64.leading_zeros().saturating_sub(1)
|
||||
}
|
||||
|
||||
/// The initial shift for a member whose hashrate is unknown: shares worth about 2^20 hashes each, so a CPU at
|
||||
/// 0.1 MH/s sends one per 10 s and a 100 MH/s card sends 100 per second until the first correction.
|
||||
pub fn initial_shift(target64: u64, min_shift: u32, max_shift: u32) -> u32 {
|
||||
let log2_target = 63u32.saturating_sub(target64.leading_zeros());
|
||||
let want = 44u32.saturating_sub(log2_target);
|
||||
want.clamp(min_shift, max_shift.min(Self::cap_for(target64)))
|
||||
}
|
||||
|
||||
pub fn on_share(&mut self, now_s: f64) {
|
||||
self.times.push_back(now_s);
|
||||
while self.times.len() > 1000 {
|
||||
self.times.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shares per second since the last change (or since the start).
|
||||
pub fn rate(&self, now_s: f64) -> f64 {
|
||||
let since = (now_s - self.last_change_s).max(1.0);
|
||||
self.times.len() as f64 / since
|
||||
}
|
||||
|
||||
/// Call every share and every second. Returns the new shift when it changes.
|
||||
pub fn retarget(&mut self, now_s: f64, target64: u64) -> Option<u32> {
|
||||
let since_change = now_s - self.last_change_s;
|
||||
let n = self.times.len();
|
||||
let cap = self.max_shift.min(Self::cap_for(target64));
|
||||
let old = self.shift;
|
||||
if !self.first_done {
|
||||
// First correction: after 8 shares, or after one interval with fewer, size the jump from the rate
|
||||
if n < 8 && (now_s - self.started_s) < self.interval_s {
|
||||
return None;
|
||||
}
|
||||
if n == 0 {
|
||||
// nothing yet after an interval: wait up to three intervals, then one easier step
|
||||
if (now_s - self.started_s) < 3.0 * self.interval_s {
|
||||
return None;
|
||||
}
|
||||
self.shift = (self.shift + 1).min(cap);
|
||||
} else {
|
||||
let per_interval = self.rate(now_s) * self.interval_s;
|
||||
let steps = per_interval.log2().abs().ceil().min(8.0) as u32;
|
||||
if per_interval > 1.5 {
|
||||
self.shift = self.shift.saturating_sub(steps).max(self.min_shift);
|
||||
} else if per_interval < 0.66 {
|
||||
self.shift = (self.shift + steps).min(cap);
|
||||
}
|
||||
}
|
||||
self.first_done = true;
|
||||
} else {
|
||||
if since_change < self.min_change_s {
|
||||
return None;
|
||||
}
|
||||
if n == 0 {
|
||||
if since_change < 3.0 * self.interval_s {
|
||||
return None;
|
||||
}
|
||||
self.shift = (self.shift + 1).min(cap);
|
||||
} else {
|
||||
let per_interval = self.rate(now_s) * self.interval_s;
|
||||
if per_interval > 1.5 {
|
||||
self.shift = self.shift.saturating_sub(1).max(self.min_shift);
|
||||
} else if per_interval < 0.66 {
|
||||
self.shift = (self.shift + 1).min(cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
// the cap can fall when the network target tightens: always honour it
|
||||
self.shift = self.shift.min(cap);
|
||||
if self.shift != old {
|
||||
self.last_change_s = now_s;
|
||||
self.times.clear();
|
||||
self.changes += 1;
|
||||
Some(self.shift)
|
||||
} else {
|
||||
self.times.retain(|t| now_s - *t <= 120.0);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `min(2^64 - 1, target64 << s)`, never saturated by construction of the cap.
|
||||
pub fn share_target(target64: u64, shift: u32) -> u64 {
|
||||
if shift >= 64 {
|
||||
return u64::MAX;
|
||||
}
|
||||
target64.checked_shl(shift).filter(|t| (*t >> shift) == target64).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// The weight of a share at shift `s`: `2^-s` of a block (spec 9.8 item 3).
|
||||
pub fn share_weight(shift: u32) -> f64 {
|
||||
2f64.powi(-(shift as i32))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const T64: u64 = 1 << 40; // a block every 2^24 hashes
|
||||
|
||||
#[test]
|
||||
fn the_cap_never_saturates_the_target() {
|
||||
assert_eq!(Vardiff::cap_for(T64), 22);
|
||||
assert_eq!(share_target(T64, 22), 1 << 62);
|
||||
assert_eq!(share_target(T64, 23), 1 << 63, "one past the cap still fits");
|
||||
assert_eq!(share_target(T64, 24), u64::MAX, "an overflowing shift saturates, which the cap prevents");
|
||||
assert_eq!(Vardiff::cap_for(u64::MAX), 0);
|
||||
assert_eq!(share_weight(3), 0.125);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_shift_targets_a_million_hashes_per_share() {
|
||||
// target 2^40: 2^24 hashes per block, wanted 2^20 per share: shift 4
|
||||
assert_eq!(Vardiff::initial_shift(T64, 0, 60), 4);
|
||||
// target 2^20: 2^44 hashes per block: shift 24
|
||||
assert_eq!(Vardiff::initial_shift(1 << 20, 0, 60), 24);
|
||||
// a target so easy that 2^44 is above it: clamped to the floor
|
||||
assert_eq!(Vardiff::initial_shift(1 << 50, 0, 60), 0);
|
||||
// clamped to the cap
|
||||
assert_eq!(Vardiff::initial_shift(1 << 20, 0, 10), 10);
|
||||
}
|
||||
|
||||
/// A fast member: 100 shares per second at the initial shift. The first correction jumps by up to 8 steps,
|
||||
/// afterwards one step per 30 s, until the rate is about one per 10 s.
|
||||
#[test]
|
||||
fn a_fast_member_is_brought_down_and_then_stepped() {
|
||||
let mut v = Vardiff::new(20, 0, 60, 10.0, 0.0);
|
||||
let mut t = 0.0;
|
||||
for _ in 0..8 {
|
||||
t += 0.01;
|
||||
v.on_share(t);
|
||||
}
|
||||
// eight shares in 80 ms: rate 8/s (since is floored to 1 s), 80 per interval: log2(80) = 6.3 -> 7 steps
|
||||
assert_eq!(v.retarget(t, 1 << 20), Some(13));
|
||||
// the next change is refused inside 30 s whatever the rate
|
||||
for _ in 0..50 {
|
||||
t += 0.1;
|
||||
v.on_share(t);
|
||||
}
|
||||
assert_eq!(v.retarget(t, 1 << 20), None);
|
||||
t += 30.0;
|
||||
assert_eq!(v.retarget(t, 1 << 20), Some(12), "one step after 30 s");
|
||||
}
|
||||
|
||||
/// A slow member sends nothing: after three intervals the shift eases by one, then one per 30 s.
|
||||
#[test]
|
||||
fn a_silent_member_gets_easier_shares_slowly() {
|
||||
let mut v = Vardiff::new(4, 0, 60, 10.0, 0.0);
|
||||
assert_eq!(v.retarget(20.0, 1 << 20), None);
|
||||
assert_eq!(v.retarget(31.0, 1 << 20), Some(5));
|
||||
assert_eq!(v.retarget(50.0, 1 << 20), None, "inside 30 s of the change");
|
||||
assert_eq!(v.retarget(62.0, 1 << 20), Some(6));
|
||||
}
|
||||
|
||||
/// At the right rate nothing changes.
|
||||
#[test]
|
||||
fn a_member_on_rate_is_left_alone() {
|
||||
let mut v = Vardiff::new(10, 0, 60, 10.0, 0.0);
|
||||
let mut t = 0.0;
|
||||
for _ in 0..12 {
|
||||
t += 10.0;
|
||||
v.on_share(t);
|
||||
assert_eq!(v.retarget(t, 1 << 20), None, "t={t}");
|
||||
}
|
||||
assert_eq!(v.changes, 0);
|
||||
}
|
||||
|
||||
/// The shift respects the pool's bounds and the saturation cap when the network target eases.
|
||||
#[test]
|
||||
fn bounds_and_cap_hold() {
|
||||
let mut v = Vardiff::new(30, 2, 40, 10.0, 0.0);
|
||||
// a slow member: the cap of a very easy target (2^60 -> leading zeros 3 -> cap 2) wins over its shift
|
||||
assert_eq!(v.retarget(31.0, 1 << 60), Some(2), "cap wins over the member's shift");
|
||||
let mut v = Vardiff::new(3, 2, 40, 10.0, 0.0);
|
||||
let mut t = 0.0;
|
||||
for _ in 0..100 {
|
||||
t += 0.01;
|
||||
v.on_share(t);
|
||||
}
|
||||
assert_eq!(v.retarget(t, 1 << 20), Some(2), "floor at min_shift");
|
||||
}
|
||||
}
|
||||
145
pool/src/verify.rs
Normal file
145
pool/src/verify.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
//! Share validation against the CPU warp verifier (spec 09 section 9.8 item 5): every share is evaluated exactly as a
|
||||
//! node verifies a block, through the node's own `IgneumEngine` (the same program, cache and init words). The codes
|
||||
//! are the spec's: `stale`, `duplicate`, `above_target`, `wrong_hash`, `unknown_job`, `ok`.
|
||||
|
||||
use kaspa_pow::igneum::{EpochRef, EpochSeeds, IgneumEngine};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Code {
|
||||
Ok,
|
||||
Stale,
|
||||
Duplicate,
|
||||
AboveTarget,
|
||||
WrongHash,
|
||||
UnknownJob,
|
||||
}
|
||||
|
||||
impl Code {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Code::Ok => "ok",
|
||||
Code::Stale => "stale",
|
||||
Code::Duplicate => "duplicate",
|
||||
Code::AboveTarget => "above_target",
|
||||
Code::WrongHash => "wrong_hash",
|
||||
Code::UnknownJob => "unknown_job",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a job fixes for its shares.
|
||||
#[derive(Clone)]
|
||||
pub struct JobKey {
|
||||
pub prehash: [u8; 32],
|
||||
pub target64: u64,
|
||||
pub share_target64: u64,
|
||||
pub seeds: EpochSeeds,
|
||||
}
|
||||
|
||||
/// The verdict on one share, with the hash the pool computed and the time it took.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Verdict {
|
||||
pub code: Code,
|
||||
pub hash: u64,
|
||||
/// The share is also a block (hash at or below the block target)
|
||||
pub block: bool,
|
||||
pub cost_ms: f64,
|
||||
}
|
||||
|
||||
/// Evaluates the share's lane on the CPU. `claimed` is the member's `hash` field.
|
||||
pub fn check(epoch: &EpochRef, job: &JobKey, nonce: u64, claimed: u64) -> Verdict {
|
||||
let t = Instant::now();
|
||||
let hash = epoch.hash_bound(&job.prehash, nonce);
|
||||
let cost_ms = t.elapsed().as_secs_f64() * 1e3;
|
||||
let code = if hash != claimed {
|
||||
Code::WrongHash
|
||||
} else if hash > job.share_target64 {
|
||||
Code::AboveTarget
|
||||
} else {
|
||||
Code::Ok
|
||||
};
|
||||
Verdict { code, hash, block: code == Code::Ok && hash <= job.target64, cost_ms }
|
||||
}
|
||||
|
||||
/// The engine's program and cache for a job's seeds (built once per pair, shared afterwards).
|
||||
pub fn epoch_for(engine: &IgneumEngine, seeds: &EpochSeeds) -> Arc<EpochRef> {
|
||||
engine.epoch_for(seeds)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kaspa_hashes::Hash;
|
||||
|
||||
fn job(epoch: &EpochRef, prehash: [u8; 32], nonce: u64, seeds: EpochSeeds) -> (JobKey, u64) {
|
||||
let h = epoch.hash_bound(&prehash, nonce);
|
||||
(JobKey { prehash, target64: h / 2, share_target64: h, seeds }, h)
|
||||
}
|
||||
|
||||
/// A known-good share (the member's hash equals the pool's evaluation and sits at the share target) is accepted;
|
||||
/// a known-bad one (a wrong hash claim, or a hash above the share target) is refused with the spec's code.
|
||||
#[test]
|
||||
fn known_good_and_known_bad_shares() {
|
||||
let engine = IgneumEngine::new();
|
||||
let seeds = EpochSeeds { epoch: Hash::from_bytes([5u8; 32]), day: 0 };
|
||||
let epoch = engine.epoch_for(&seeds);
|
||||
let prehash = [9u8; 32];
|
||||
let nonce = 0x0123_4567_89ab_cdefu64;
|
||||
let (key, h) = job(&epoch, prehash, nonce, seeds);
|
||||
let good = check(&epoch, &key, nonce, h);
|
||||
assert_eq!(good.code, Code::Ok);
|
||||
assert_eq!(good.hash, h);
|
||||
assert!(!good.block, "target64 is below the hash: a share, not a block");
|
||||
assert!(good.cost_ms > 0.0 && good.cost_ms < 1000.0, "{}", good.cost_ms);
|
||||
// wrong hash claim: a worker fault
|
||||
assert_eq!(check(&epoch, &key, nonce, h ^ 1).code, Code::WrongHash);
|
||||
// the right hash on a tighter share target
|
||||
let tight = JobKey { share_target64: h.saturating_sub(1), ..key.clone() };
|
||||
assert_eq!(check(&epoch, &tight, nonce, h).code, Code::AboveTarget);
|
||||
// the same nonce on another template hashes differently and is refused as a wrong hash
|
||||
let other = JobKey { prehash: [10u8; 32], ..key.clone() };
|
||||
assert_eq!(check(&epoch, &other, nonce, h).code, Code::WrongHash);
|
||||
// a share at or below the block target is a block
|
||||
let block_key = JobKey { target64: h, ..key };
|
||||
assert!(check(&epoch, &block_key, nonce, h).block);
|
||||
}
|
||||
|
||||
/// Lane arithmetic: the pool evaluates the lane `nonce AND 31` of the group `nonce AND NOT 31` (spec 9.8 item 1);
|
||||
/// the 32 lanes of one group hash differently, so a nonce from the next lane is not the same share.
|
||||
#[test]
|
||||
fn lanes_of_one_group_are_distinct_shares() {
|
||||
let engine = IgneumEngine::new();
|
||||
let seeds = EpochSeeds { epoch: Hash::from_bytes([6u8; 32]), day: 0 };
|
||||
let epoch = engine.epoch_for(&seeds);
|
||||
let prehash = [1u8; 32];
|
||||
let base = 0x0000_0001_0000_0020u64;
|
||||
let a = epoch.hash_bound(&prehash, base);
|
||||
let b = epoch.hash_bound(&prehash, base + 1);
|
||||
assert_ne!(a, b);
|
||||
let key = JobKey { prehash, target64: 0, share_target64: u64::MAX, seeds };
|
||||
assert_eq!(check(&epoch, &key, base + 1, a).code, Code::WrongHash);
|
||||
assert_eq!(check(&epoch, &key, base + 1, b).code, Code::Ok);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cost {
|
||||
use super::*;
|
||||
use kaspa_hashes::Hash;
|
||||
|
||||
/// Prints the share check cost over 500 shares (run under the measure lock with --nocapture for the number).
|
||||
#[test]
|
||||
fn share_check_cost_ms() {
|
||||
let engine = IgneumEngine::new();
|
||||
let seeds = EpochSeeds { epoch: Hash::from_bytes([3u8; 32]), day: 0 };
|
||||
let epoch = engine.epoch_for(&seeds);
|
||||
let key = JobKey { prehash: [2u8; 32], target64: 0, share_target64: u64::MAX, seeds };
|
||||
let mut v: Vec<f64> = (0..500u64).map(|i| check(&epoch, &key, i * 97, 0).cost_ms).collect();
|
||||
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let mean = v.iter().sum::<f64>() / v.len() as f64;
|
||||
println!("SHARE_CHECK_MS n={} mean={:.3} p50={:.3} p99={:.3} max={:.3}", v.len(), mean, v[250], v[495], v[499]);
|
||||
assert!(mean < 50.0);
|
||||
}
|
||||
}
|
||||
1
pool/tests/fixtures/miner.json
vendored
Normal file
1
pool/tests/fixtures/miner.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"ok":true,"address":"0x0000000000000000000000000000000000000000","hashrate":0.0,"hashrate_1h":0.0,"hashrate_reported":0.0,"shares":{"accepted":0,"stale":0,"rejected":0},"workers":[],"blocks":0,"balance_ign":0.0,"paid_ign":0.0,"payments":[],"last_share_ms":0,"first_seen_ms":0,"online":false,"proving":false}
|
||||
1
pool/tests/fixtures/payments.json
vendored
Normal file
1
pool/tests/fixtures/payments.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"ok":true,"payments":[{"time":"1970-01-01T00:00:00Z","ts_ms":0,"address":"0x0000000000000000000000000000000000000000","amount_ign":0.0,"tx_hash":null,"status":"dry-run","dry_run":true,"nonce":null}]}
|
||||
1
pool/tests/fixtures/pool-stats.json
vendored
Normal file
1
pool/tests/fixtures/pool-stats.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"hashrate":0.0,"miners":0,"workers":0,"blocks":0,"lastBlock":null,"fee":1.0,"minPayout":1.0,"scheme":"PPLNS","symbol":"IGN","algo":"igneum","difficulty":0.0,"networkHashrate":null,"height":0}
|
||||
1
pool/tests/fixtures/stats.json
vendored
Normal file
1
pool/tests/fixtures/stats.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"ok":true,"now":"2026-10-05T21:00:00Z","pool":{"name":"Igneum pool","url":"127.0.0.1:30463","address":"0x0000000000000000000000000000000000000000","algorithm":"igneum","scheme":"PPLNS","fee_percent":1.0,"min_payout_ign":1.0,"pplns_window_blocks":2.0,"hashrate":0.0,"hashrate_unit":"H/s","hashrate_1h":0.0,"miners":0,"workers":0,"shares":{"accepted":0,"stale":0,"rejected":0,"window_weight_blocks":0.0,"window_shares":0},"blocks_24h":0,"blocks_confirmed_24h":0,"blocks_orphaned_24h":0,"blocks_total":0,"blocks_confirmed_total":0,"blocks_orphaned_total":0,"last_block":{"hash":"","daa_score":0,"blue_score":0,"time":"1970-01-01T00:00:00Z","ts_ms":0,"finder":"","worker":"","status":"pending","reward_ign":0.0,"fee_ign":0.0,"effort":0.0,"payees":[],"confirmed_ms":null,"nonce":"0","shift":0},"effort_current":0.0,"luck_24h":0.0,"paid_24h_ign":0.0,"paid_total_ign":0.0,"pending_balance_ign":0.0,"pool_fee_total_ign":0.0,"uptime_s":0,"share_check_ms":{"count":0,"mean":0.0,"p50":0.0,"p99":0.0,"max":0.0},"dry_run":true},"network":{"name":"devnet","chain_id":4463,"difficulty":0.0,"hashrate":null,"hashrate_unit":"H/s","daa_score":0,"block_count":0,"blue_score":0,"block_reward_ign":0.0,"miner_reward_ign":0.0,"block_time_target_s":1,"synced":false,"node_version":"","epoch_seed":"","epoch_index":0,"updated_ms":0},"source":"placeholder"}
|
||||
20
pool/tools/capture-fixtures.mjs
Normal file
20
pool/tools/capture-fixtures.mjs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
#!/usr/bin/env node
|
||||
// Captures the API responses of a running pool into tests/fixtures/ (the field contract test reads them).
|
||||
// node pool/tools/capture-fixtures.mjs [http://127.0.0.1:30480] [0xaddress]
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
const base = process.argv[2] || 'http://127.0.0.1:30480';
|
||||
const addr = process.argv[3] || '0x' + '11'.repeat(20);
|
||||
const out = join(dirname(fileURLToPath(import.meta.url)), '..', 'tests', 'fixtures');
|
||||
const get = async (p) => (await fetch(base + p)).json();
|
||||
const stats = await get('/api/stats');
|
||||
if (!stats.pool.last_block) throw new Error('no block yet: the stats fixture needs last_block');
|
||||
writeFileSync(join(out, 'stats.json'), JSON.stringify(stats, null, 1));
|
||||
writeFileSync(join(out, 'miner.json'), JSON.stringify(await get('/api/miners/' + addr), null, 1));
|
||||
const pay = await get('/api/payments?limit=5');
|
||||
if (!pay.payments.length) throw new Error('no payment yet');
|
||||
writeFileSync(join(out, 'payments.json'), JSON.stringify(pay, null, 1));
|
||||
writeFileSync(join(out, 'pool-stats.json'), JSON.stringify(await get('/api/pool-stats'), null, 1));
|
||||
writeFileSync(join(out, 'blocks.json'), JSON.stringify(await get('/api/blocks?limit=5'), null, 1));
|
||||
console.log('fixtures written to', out);
|
||||
197
pool/tools/measure.mjs
Normal file
197
pool/tools/measure.mjs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
#!/usr/bin/env node
|
||||
// Pool v0 measured on a private fast-time network (docs/plans/pool.md): one igneumd on ports 30400 and up (network
|
||||
// id igneum-devnet-3040, data under the scratch directory), the pool, and three CPU miners of different identities
|
||||
// and thread counts pointed at it. Records shares per minute per miner, the share check cost, found blocks, orphans,
|
||||
// the PPLNS credits (dry run, then a real local payout on the same chain), and the API under 100 requests per second.
|
||||
// Never touches the live devnet (26610/26611) or other agents' port ranges (27800+, 28500+, 29300+, 29500+, 29700+,
|
||||
// 29800+, 29900+, 29950+).
|
||||
//
|
||||
// tools/lock/with-lock.sh run node pool/tools/measure.mjs [--secs 600] [--payout-secs 240] [--rps 100] [--scratch <dir>]
|
||||
// [--node <igneumd>] [--miner <igneum-miner>] [--pool <igneum-pool>] [--threads 4,2,2] [--genesis-bits 0x1e400000]
|
||||
// [--carrier 8] a solo CPU miner of that many threads as "the rest of the network", so the pool holds a minority
|
||||
// of the hashrate, vardiff has shares to set, and the pool's blocks compete (orphans)
|
||||
//
|
||||
// Output: one line per step and a JSON summary at <scratch>/summary.json. Every number in the summary names its source.
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(here, '..', '..'); // the repository worktree
|
||||
const MAIN = '/Users/joshm/Projects/igneum'; // the main checkout (the 0.3.6 node binary, the fast-time profile)
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (n, d) => { const i = args.indexOf(n); return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : d; };
|
||||
const SECS = +opt('--secs', 600);
|
||||
const PAYOUT_SECS = +opt('--payout-secs', 240);
|
||||
const RPS = +opt('--rps', 100);
|
||||
const SCRATCH = opt('--scratch', process.env.SCRATCH || `/tmp/igneum-pool-measure`);
|
||||
const NODE = opt('--node', `${MAIN}/vendor/igneum-node-036/target-integration/release/igneumd`);
|
||||
const MINER = opt('--miner', `${MAIN}/vendor/igneum-node-pool/target/release/igneum-miner`);
|
||||
const POOL = opt('--pool', `${ROOT}/pool/target/release/igneum-pool`);
|
||||
const THREADS = opt('--threads', '4,2,2').split(',').map(Number);
|
||||
const CARRIER = +opt('--carrier', 0);
|
||||
const GENESIS_BITS = Number(opt('--genesis-bits', '0x1e400000'));
|
||||
const BASE = 30400, SUFFIX = 3040, POOL_PORT = 30463, HTTP_PORT = 30480;
|
||||
const FAST = `${MAIN}/infra/fast-time/override-60x.json`;
|
||||
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
for (const b of [NODE, MINER, POOL]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
|
||||
rmSync(SCRATCH, { recursive: true, force: true }); mkdirSync(SCRATCH, { recursive: true });
|
||||
const started = [];
|
||||
const stopAll = () => { for (const p of started.splice(0).reverse()) { try { p.kill('SIGINT'); } catch {} } };
|
||||
process.on('SIGINT', () => { stopAll(); process.exit(130); });
|
||||
process.on('SIGTERM', () => { stopAll(); process.exit(143); });
|
||||
const summary = { started: new Date().toISOString(), node: NODE, miner: MINER, pool: POOL, threads: THREADS, carrier_threads: CARRIER, genesis_bits: '0x' + GENESIS_BITS.toString(16), secs: SECS, steps: {} };
|
||||
const save = () => writeFileSync(join(SCRATCH, 'summary.json'), JSON.stringify(summary, null, 2));
|
||||
|
||||
// the override: the 60x profile with real proof of work at a CPU difficulty; edited as text (u64 "never" sentinels)
|
||||
const override = join(SCRATCH, 'override.json');
|
||||
let text = readFileSync(FAST, 'utf8')
|
||||
.replace(/"skip_proof_of_work":\s*(true|false)/, '"skip_proof_of_work": false')
|
||||
.replace(/"genesis_bits":\s*\d+/, `"genesis_bits": ${GENESIS_BITS}`);
|
||||
if (!/"skip_proof_of_work": false/.test(text) || !text.includes(`"genesis_bits": ${GENESIS_BITS}`)) throw new Error('override edit failed');
|
||||
writeFileSync(override, text);
|
||||
|
||||
function run(name, bin, a, env = {}) {
|
||||
const out = openSync(join(SCRATCH, `${name}.log`), 'a');
|
||||
const p = spawn(bin, a, { stdio: ['ignore', out, out], env: { ...process.env, ...env } });
|
||||
p.on('exit', (code) => { p.exitCode2 = code; });
|
||||
started.push(p);
|
||||
log(`${name} pid ${p.pid}: ${bin.split('/').pop()} ${a.join(' ').slice(0, 160)}`);
|
||||
return p;
|
||||
}
|
||||
const logOf = (name) => { try { return readFileSync(join(SCRATCH, `${name}.log`), 'utf8'); } catch { return ''; } };
|
||||
async function api(path) { const r = await fetch(`http://127.0.0.1:${HTTP_PORT}${path}`); return r.json(); }
|
||||
let rpcId = 0;
|
||||
async function evm(method, params = []) {
|
||||
const r = await fetch(`http://127.0.0.1:${BASE + 3}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: ++rpcId, method, params }) });
|
||||
const j = await r.json(); if (j.error) throw new Error(j.error.message || JSON.stringify(j.error)); return j.result;
|
||||
}
|
||||
|
||||
// 1. the node
|
||||
const nodeDir = join(SCRATCH, 'node');
|
||||
run('node', NODE, ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex', '--outpeers=0',
|
||||
`--appdir=${nodeDir}`, `--rpclisten=127.0.0.1:${BASE}`, `--rpclisten-json=127.0.0.1:${BASE + 2}`, `--evm-rpclisten=127.0.0.1:${BASE + 3}`, `--listen=127.0.0.1:${BASE + 1}`,
|
||||
`--override-params-file=${override}`, '--loglevel=info', '--yes'], { IGNEUM_POW_DAY_MS: '1440000' });
|
||||
// `watch 1` takes one sample, sleeps 10 s and prints its summary: about 11 s, so the timeout is 15 s (an 8 s timeout
|
||||
// killed every attempt and the first run waited 60 x 9 s before the pool started, 5 October 2026)
|
||||
for (let i = 0; i < 20; i++) { await sleep(1000); if (spawnSync(MINER, ['watch', '1', `grpc://127.0.0.1:${BASE}`], { timeout: 15000 }).status === 0) break; }
|
||||
log('node answers');
|
||||
if (CARRIER > 0) run('carrier', MINER, ['mine', `grpc://127.0.0.1:${BASE}`, String(CARRIER), String(SECS + PAYOUT_SECS + 180), 'carrier', '--engine', 'igneum-pow', '--evm-address', '0x' + '44'.repeat(20), '--status-secs', '30', '--dev-fee', '0'], { IGNEUM_POW_DAY_MS: '1440000' });
|
||||
|
||||
// the three miners' payout addresses: fixed test values, no keys behind them (what the pool pays is checked on chain)
|
||||
const ADDR = ['0x' + '11'.repeat(20), '0x' + '22'.repeat(20), '0x' + '33'.repeat(20)];
|
||||
|
||||
// 2. the pool, dry run first
|
||||
const poolDir = join(SCRATCH, 'pool-data');
|
||||
const poolArgs = (extra) => ['--node', `grpc://127.0.0.1:${BASE}`, '--evm-rpc', `http://127.0.0.1:${BASE + 3}`, '--listen', `127.0.0.1:${POOL_PORT}`, '--http', `127.0.0.1:${HTTP_PORT}`,
|
||||
'--data-dir', poolDir, '--network', 'devnet', '--fee-percent', '1', '--pplns-window', '2', '--payout-interval-s', '30', '--name', 'Igneum pool (measure)', '--public-url', `127.0.0.1:${POOL_PORT}`, ...extra];
|
||||
let pool = run('pool-dry', POOL, poolArgs(['--dry-run', '--min-payout', '0.5']), { IGNEUM_POW_DAY_MS: '1440000' });
|
||||
for (let i = 0; i < 30; i++) { await sleep(1000); try { if ((await api('/health')).ok) break; } catch {} }
|
||||
const first = await api('/api/stats');
|
||||
log(`pool up: address ${first.pool.address}, chain ${first.network.chain_id}`);
|
||||
summary.pool_address = first.pool.address;
|
||||
|
||||
// 3. three miners of different identities, thread counts and payout addresses
|
||||
const miners = THREADS.map((t, i) => run(`miner${i + 1}`, MINER, ['mine', `grpc://127.0.0.1:${BASE}`, String(t), String(SECS + PAYOUT_SECS + 120), `pool-m${i + 1}`,
|
||||
'--pool', `127.0.0.1:${POOL_PORT}`, '--evm-address', ADDR[i], '--worker-name', `rig${i + 1}`, '--status-secs', '30', '--network', 'devnet'], { IGNEUM_POW_DAY_MS: '1440000' }));
|
||||
const t0 = Date.now();
|
||||
let samples = [];
|
||||
while (Date.now() - t0 < SECS * 1000) {
|
||||
await sleep(30000);
|
||||
try {
|
||||
const s = await api('/api/stats');
|
||||
const ms = await Promise.all(ADDR.map(a => api(`/api/miners/${a}`)));
|
||||
const pm = s.pool.blocks_total, nb = s.network.block_count;
|
||||
samples.push({ t: (Date.now() - t0) / 1000, pool_block_share: nb ? +(pm / nb).toFixed(3) : null, accepted: s.pool.shares.accepted, stale: s.pool.shares.stale, rejected: s.pool.shares.rejected, blocks: s.pool.blocks_total, confirmed: s.pool.blocks_confirmed_total, orphans: s.pool.blocks_orphaned_total, hashrate: s.pool.hashrate, check: s.pool.share_check_ms, per_miner: ms.map(m => ({ accepted: m.shares.accepted, hashrate: m.hashrate, shift: m.workers[0]?.shift })) , net_diff: s.network.difficulty, daa: s.network.daa_score });
|
||||
log(`t=${Math.round((Date.now() - t0) / 1000)}s accepted=${s.pool.shares.accepted} stale=${s.pool.shares.stale} rejected=${s.pool.shares.rejected} blocks=${s.pool.blocks_total}/${s.pool.blocks_confirmed_total}c/${s.pool.blocks_orphaned_total}o hashrate=${Math.round(s.pool.hashrate)} H/s check p50=${s.pool.share_check_ms.p50} ms miners=${ms.map(m => m.shares.accepted + '@s' + (m.workers[0]?.shift ?? '?')).join(' ')} diff=${Math.round(s.network.difficulty)} daa=${s.network.daa_score}`);
|
||||
} catch (e) { log(`sample failed: ${e.message}`); }
|
||||
for (const [i, m] of miners.entries()) if (m.exitCode2 != null) log(`miner${i + 1} EXITED ${m.exitCode2}`);
|
||||
}
|
||||
// the dry-run phase, read back
|
||||
const stats = await api('/api/stats');
|
||||
const blocks = await api('/api/blocks?limit=1000');
|
||||
const payments = await api('/api/payments?limit=1000');
|
||||
const minersApi = await Promise.all(ADDR.map(a => api(`/api/miners/${a}`)));
|
||||
const secsRun = (Date.now() - t0) / 1000;
|
||||
summary.steps.dry_run = {
|
||||
secs: Math.round(secsRun),
|
||||
shares_accepted: stats.pool.shares.accepted, shares_stale: stats.pool.shares.stale, shares_rejected: stats.pool.shares.rejected,
|
||||
shares_per_minute_pool: +(stats.pool.shares.accepted / secsRun * 60).toFixed(2),
|
||||
per_miner: minersApi.map((m, i) => ({ worker: `rig${i + 1}`, threads: THREADS[i], accepted: m.shares.accepted, stale: m.shares.stale, rejected: m.shares.rejected, shares_per_minute: +(m.shares.accepted / secsRun * 60).toFixed(2), hashrate_10m_hs: m.hashrate, final_shift: m.workers[0]?.shift, blocks: m.blocks, balance_ign: m.balance_ign })),
|
||||
share_check_ms: stats.pool.share_check_ms,
|
||||
pool_hashrate_hs: stats.pool.hashrate, network_hashrate_hs: stats.network.hashrate, network_difficulty: stats.network.difficulty,
|
||||
blocks_found: stats.pool.blocks_total, blocks_confirmed: stats.pool.blocks_confirmed_total, blocks_orphaned: stats.pool.blocks_orphaned_total,
|
||||
network_block_count: stats.network.block_count, pool_share_of_blocks: stats.network.block_count ? +(stats.pool.blocks_total / stats.network.block_count).toFixed(3) : null,
|
||||
carrier_status: CARRIER ? ((logOf('carrier').match(/STATUS .*|miner 'carrier'.*found=\d+.*/g) || []).slice(-1)[0] || null) : null,
|
||||
orphan_rate: stats.pool.blocks_total ? +(stats.pool.blocks_orphaned_total / stats.pool.blocks_total).toFixed(4) : null,
|
||||
pending_balance_ign: stats.pool.pending_balance_ign, pool_fee_total_ign: stats.pool.pool_fee_total_ign,
|
||||
dry_run_payments: payments.payments.filter(p => p.dry_run).length,
|
||||
first_confirmed_block: blocks.blocks.filter(b => b.status === 'confirmed').slice(-1)[0] || null,
|
||||
samples,
|
||||
source: 'GET /api/stats, /api/blocks, /api/miners/<address>, /api/payments on the measure pool; miners rig1..3 STATUS lines in <scratch>/minerN.log',
|
||||
};
|
||||
save();
|
||||
log(`dry run done: ${JSON.stringify({ ...summary.steps.dry_run, samples: undefined, first_confirmed_block: undefined })}`);
|
||||
|
||||
// 4. the real payout: the same pool state, without --dry-run; the pool's address was paid 80% of every blue block it
|
||||
// found by the execution layer, so its balance covers the PPLNS credits
|
||||
pool.kill('SIGINT'); await sleep(3000);
|
||||
const poolBalanceBefore = BigInt(await evm('eth_getBalance', [first.pool.address, 'latest']));
|
||||
const minerBalancesBefore = await Promise.all(ADDR.map(a => evm('eth_getBalance', [a, 'latest']).then(BigInt)));
|
||||
log(`pool EVM balance before payout: ${Number(poolBalanceBefore) / 1e18} IGN; miners ${minerBalancesBefore.map(b => Number(b) / 1e18).join(' ')}`);
|
||||
pool = run('pool-real', POOL, poolArgs(['--min-payout', '0.1']), { IGNEUM_POW_DAY_MS: '1440000' });
|
||||
for (let i = 0; i < 30; i++) { await sleep(1000); try { if ((await api('/health')).ok) break; } catch {} }
|
||||
const t1 = Date.now();
|
||||
while (Date.now() - t1 < PAYOUT_SECS * 1000) {
|
||||
await sleep(15000);
|
||||
try { const p = await api('/api/payments?limit=100'); const real = p.payments.filter(x => !x.dry_run); log(`payments: ${real.length} real (${real.filter(x => x.status === 'confirmed').length} confirmed, ${real.filter(x => x.status === 'sent').length} sent, ${real.filter(x => x.status === 'failed').length} failed)`); } catch (e) { log(`payments read failed: ${e.message}`); }
|
||||
}
|
||||
const pay = await api('/api/payments?limit=1000');
|
||||
const real = pay.payments.filter(x => !x.dry_run);
|
||||
const minerBalancesAfter = await Promise.all(ADDR.map(a => evm('eth_getBalance', [a, 'latest']).then(BigInt)));
|
||||
const poolBalanceAfter = BigInt(await evm('eth_getBalance', [first.pool.address, 'latest']));
|
||||
const stats2 = await api('/api/stats');
|
||||
summary.steps.real_payout = {
|
||||
secs: PAYOUT_SECS,
|
||||
pool_balance_before_ign: Number(poolBalanceBefore) / 1e18, pool_balance_after_ign: Number(poolBalanceAfter) / 1e18,
|
||||
payments: real.map(p => ({ address: p.address, amount_ign: p.amount_ign, tx: p.tx_hash, status: p.status })),
|
||||
confirmed: real.filter(x => x.status === 'confirmed').length, sent: real.filter(x => x.status === 'sent').length, failed: real.filter(x => x.status === 'failed').length,
|
||||
miner_balances_ign: ADDR.map((a, i) => ({ address: a, before: Number(minerBalancesBefore[i]) / 1e18, after: Number(minerBalancesAfter[i]) / 1e18, received: Number(minerBalancesAfter[i] - minerBalancesBefore[i]) / 1e18 })),
|
||||
paid_total_ign: stats2.pool.paid_total_ign, pending_after_ign: stats2.pool.pending_balance_ign,
|
||||
source: 'eth_getBalance on the private node before and after; GET /api/payments (status from eth_getTransactionReceipt)',
|
||||
};
|
||||
save();
|
||||
log(`real payout: ${JSON.stringify({ ...summary.steps.real_payout, payments: summary.steps.real_payout.payments.length })}`);
|
||||
|
||||
// 5. the API under load: RPS requests per second for 30 s across the four endpoints, latency percentiles
|
||||
const paths = ['/api/stats', '/api/blocks?limit=50', `/api/miners/${ADDR[0]}`, '/api/payments?limit=50'];
|
||||
const lat = []; let errors = 0; let sent = 0;
|
||||
const tl = Date.now(); const interval = 1000 / RPS;
|
||||
const inflight = new Set();
|
||||
while (Date.now() - tl < 30000) {
|
||||
const target = tl + sent * interval;
|
||||
const wait = target - Date.now(); if (wait > 0) await sleep(wait);
|
||||
const p = paths[sent % paths.length]; sent++;
|
||||
const s = performance.now();
|
||||
const f = fetch(`http://127.0.0.1:${HTTP_PORT}${p}`).then(r => r.json()).then(j => { if (!j.ok) errors++; lat.push(performance.now() - s); }).catch(() => { errors++; }).finally(() => inflight.delete(f));
|
||||
inflight.add(f);
|
||||
}
|
||||
await Promise.all([...inflight]);
|
||||
lat.sort((a, b) => a - b);
|
||||
const pct = (q) => +(lat[Math.min(lat.length - 1, Math.floor(lat.length * q))] || 0).toFixed(2);
|
||||
summary.steps.api_load = { rps_target: RPS, secs: 30, sent, ok: lat.length, errors, achieved_rps: +(sent / 30).toFixed(1), latency_ms: { p50: pct(0.5), p90: pct(0.9), p99: pct(0.99), max: +(lat[lat.length - 1] || 0).toFixed(2) }, source: 'this script, fetch() from Node 22 on the same machine against the measure pool' };
|
||||
save();
|
||||
log(`api load: ${JSON.stringify(summary.steps.api_load)}`);
|
||||
|
||||
// 6. the miners' own view
|
||||
summary.miners_summary = THREADS.map((_, i) => (logOf(`miner${i + 1}`).match(/MINER SUMMARY.*/g) || []).slice(-1)[0] || (logOf(`miner${i + 1}`).match(/STATUS pool=.*/g) || []).slice(-1)[0] || null);
|
||||
summary.pool_status_lines = (logOf('pool-dry').match(/STATUS .*/g) || []).slice(-3);
|
||||
summary.finished = new Date().toISOString();
|
||||
save();
|
||||
stopAll();
|
||||
await sleep(2000);
|
||||
log(`done; summary at ${join(SCRATCH, 'summary.json')}`);
|
||||
process.exit(0);
|
||||
199
pool/web/index.html
Normal file
199
pool/web/index.html
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>__POOL_NAME__</title>
|
||||
<meta name="description" content="An Igneum mining pool: hash rate, miners, blocks, payouts, and how to connect igneum-miner.">
|
||||
<meta name="theme-color" content="#0C0C0E">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Crect width='1024' height='1024' fill='%230C0C0E'/%3E%3Cg transform='translate(166.95 166.95) scale(6.901)'%3E%3Cpolygon points='50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34' fill='%23F2541B'/%3E%3Cpolygon points='50,42 59,58 50,82 41,58' fill='%230C0C0E'/%3E%3C/g%3E%3C/svg%3E" type="image/svg+xml">
|
||||
<style>
|
||||
/* the site's tokens (site/partials/head.html and site/explorer.html), self-contained: the pool runs on its own host */
|
||||
:root{--f-sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;--f-display:'Unbounded','Arial Black',sans-serif;--f-mono:'IBM Plex Mono',ui-monospace,Menlo,monospace;
|
||||
--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;--green:#3FD39A;
|
||||
--max:1200px;--gutter:clamp(16px,4vw,32px);--gap:24px;--gap-tile:12px;--card-pad:clamp(18px,3vw,28px);--card-r:18px;--tile-r:14px;--fs-h1:clamp(32px,5.5vw,56px);--fs-h3:clamp(18px,2vw,22px);--fs-tile:clamp(20px,2.2vw,26px)}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--f-sans);font-size:17px;line-height:1.55;-webkit-font-smoothing:antialiased;overflow-x:hidden}
|
||||
a{color:inherit;text-decoration:none}a:hover{color:var(--molten)}
|
||||
.wrap{max-width:var(--max);margin:0 auto;padding-inline:var(--gutter)}
|
||||
.nav{position:sticky;top:0;z-index:20;background:rgba(12,12,14,.84);backdrop-filter:blur(12px);border-bottom:1px solid var(--line)}
|
||||
.nav .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:68px}
|
||||
.brand{display:inline-flex;align-items:center;gap:10px}
|
||||
.brand .word{font-family:var(--f-display);font-weight:900;font-size:20px;letter-spacing:.06em}
|
||||
.nav .links{display:flex;gap:clamp(14px,2vw,26px);font-size:15px;font-weight:500;color:var(--ink-2)}
|
||||
.mono{font-family:var(--f-mono)}
|
||||
.eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash)}
|
||||
h1,h2{font-family:var(--f-display);margin:0;line-height:1.08}
|
||||
h1{font-weight:900;font-size:var(--fs-h1);letter-spacing:-.01em;overflow-wrap:anywhere}
|
||||
h2{font-weight:700;font-size:var(--fs-h3)}
|
||||
p{margin:0}
|
||||
.head{padding-block:clamp(40px,6vw,64px) var(--gap);display:flex;flex-direction:column;gap:14px}
|
||||
.head p{color:var(--ink-2);max-width:64ch;font-size:16px}
|
||||
.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0;margin-top:var(--gap)}
|
||||
.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)}
|
||||
@media (max-width:700px){.strip{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:18px 20px;display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.cell .k{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.cell .v{font-family:var(--f-display);font-weight:700;font-size:var(--fs-tile);line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cell .v small{font-family:var(--f-mono);font-weight:500;font-size:13px;color:var(--ash);margin-left:4px}
|
||||
.cell .s{font-family:var(--f-mono);font-size:12px;color:var(--ash)}
|
||||
.viz-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px 20px;margin-bottom:12px}
|
||||
.tbl{overflow-x:auto;margin-top:12px}
|
||||
table{border-collapse:collapse;width:100%;font-size:14px}
|
||||
th,td{padding:10px 10px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap;vertical-align:top}
|
||||
th{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500}
|
||||
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}
|
||||
tr:last-child td{border-bottom:0}
|
||||
.hash{font-family:var(--f-mono)}
|
||||
.chip{display:inline-block;font-family:var(--f-mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;padding:3px 8px;border-radius:999px;border:1px solid var(--line-2);color:var(--ink-2)}
|
||||
.chip.ok{border-color:var(--green);color:var(--green)}.chip.hot{border-color:var(--molten);color:var(--molten)}.chip.bad{border-color:var(--ember);color:var(--ember)}
|
||||
.note{font-size:13px;color:var(--ash);margin-top:14px;max-width:80ch}
|
||||
pre{background:var(--obsidian);border:1px solid var(--line);border-radius:10px;padding:14px 16px;font:500 13px/1.5 var(--f-mono);overflow-x:auto;margin:12px 0 0;color:var(--bone)}
|
||||
.search{display:flex;gap:10px;max-width:760px;margin-top:4px}
|
||||
.search input{flex:1;min-width:0;min-height:48px;padding:10px 14px;border-radius:10px;border:1px solid var(--line-2);background:var(--graphite);color:var(--bone);font:500 15px/1.3 var(--f-mono)}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;min-height:48px;padding:12px 22px;border-radius:10px;font-weight:600;font-size:16px;border:1px solid var(--ember);color:var(--ember-ink);background:var(--ember);cursor:pointer}
|
||||
.btn:hover{background:#FF6A2B}
|
||||
.msg{font-family:var(--f-mono);font-size:13px;color:var(--molten);min-height:18px}
|
||||
dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:8px 20px;margin:12px 0 0;font-size:14px}
|
||||
dt{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);padding-top:3px}
|
||||
dd{margin:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}
|
||||
.two{display:grid;gap:var(--gap);grid-template-columns:1fr;margin-top:var(--gap)}
|
||||
@media (min-width:900px){.two{grid-template-columns:1fr 1fr}.two .card{margin-top:0}}
|
||||
main{padding-bottom:clamp(56px,8vw,96px)}
|
||||
.foot{border-top:1px solid var(--line);padding:28px 0;font-size:13px;color:var(--ash)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="nav" aria-label="Main">
|
||||
<div class="wrap">
|
||||
<a href="https://igneum.network/" class="brand" aria-label="Igneum home">
|
||||
<svg viewBox="0 0 1024 1024" width="36" height="36" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg>
|
||||
<span class="word">IGNEUM</span>
|
||||
</a>
|
||||
<div class="links"><a href="#connect">Connect</a><a href="#blocks">Blocks</a><a href="#payments">Payments</a><a href="#lookup">Your address</a><a href="/api/stats">API</a><a href="https://igneum.network/miner">The miner</a></div>
|
||||
</div>
|
||||
</nav>
|
||||
<main id="main" class="wrap">
|
||||
<div class="head">
|
||||
<div class="eyebrow">__NETWORK__ · PPLNS · fee __FEE__% · minimum payout __MIN_PAYOUT__ IGN</div>
|
||||
<h1>__POOL_NAME__</h1>
|
||||
<p>Your vote key stays yours: the pool names your key in every header it gives you, so your finality weight follows you (spec 09). The pool pays 80% of each block it finds across the last shares; the 20% proving share goes to provers by the chain's own rule and never passes through here.</p>
|
||||
</div>
|
||||
<div class="strip" aria-live="polite">
|
||||
<div class="cell"><div class="k">Pool hash rate</div><div class="v" id="p-hr">0</div><div class="s" id="p-hr1h">10 min / 1 h</div></div>
|
||||
<div class="cell"><div class="k">Miners / workers</div><div class="v" id="p-miners">0 / 0</div><div class="s">online now</div></div>
|
||||
<div class="cell"><div class="k">Blocks 24 h</div><div class="v" id="p-blocks">0</div><div class="s" id="p-blocks-s">confirmed / orphaned</div></div>
|
||||
<div class="cell"><div class="k">Last block</div><div class="v" id="p-last">none</div><div class="s" id="p-last-s"></div></div>
|
||||
<div class="cell"><div class="k">Network hash rate</div><div class="v" id="n-hr">0</div><div class="s" id="n-diff">difficulty</div></div>
|
||||
<div class="cell"><div class="k">Block reward</div><div class="v" id="n-reward">0<small>IGN</small></div><div class="s">80% to the miner, 20% to provers</div></div>
|
||||
<div class="cell"><div class="k">Effort</div><div class="v" id="p-effort">0%</div><div class="s" id="p-luck">luck 24 h</div></div>
|
||||
<div class="cell"><div class="k">Paid 24 h</div><div class="v" id="p-paid">0<small>IGN</small></div><div class="s" id="p-paid-s">pending</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="connect">
|
||||
<div class="viz-head"><h2>Connect</h2><div class="eyebrow">igneum-miner 0.3.6 or later</div></div>
|
||||
<p class="note" style="margin-top:0">One command per card. The payout address is an EVM address you hold the key for; the worker name labels the card on this page. Your own node is optional: with one, the miner checks the pool's seeds and templates against it, votes on finality with your key, and submits found blocks there too. Without one it hashes and does not vote.</p>
|
||||
<pre>igneum-miner mine grpc://127.0.0.1:26610 1 100000000 rig1 --pool __POOL_URL__ --evm-address 0xYOURADDRESS --worker-name rig1-gpu0 \
|
||||
--worker ./igneum-worker-cuda --worker-args "--device 0 --pack packs/devnet"
|
||||
|
||||
# without a node of your own (hashes, does not vote):
|
||||
igneum-miner mine none 1 100000000 rig1 --pool __POOL_URL__ --evm-address 0xYOURADDRESS --worker-name rig1-gpu0 --worker ./igneum-worker-cuda</pre>
|
||||
<dl>
|
||||
<dt>HiveOS</dt><dd>Flight Sheet pool URL <span class="mono">pool://__POOL_URL__</span>, wallet <span class="mono">0xYOURADDRESS.%WORKER_NAME%</span>; the rest as on igneum.network/miner</dd>
|
||||
<dt>Shares</dt><dd>a share is a lane hash at or below your share target; the pool sets it so you send about one every 10 s and checks every one on the CPU exactly as a node checks a block</dd>
|
||||
<dt>Dev fee</dt><dd>in pool mode the miner software takes no dev fee: the pool's fee (__FEE__%) is the only fee, and it is in the welcome message every miner prints at start</dd>
|
||||
<dt>Pool address</dt><dd class="hash">__POOL_ADDRESS__</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card" id="lookup">
|
||||
<div class="viz-head"><h2>Your address</h2><div class="eyebrow">shares, hash rate, balance, payments</div></div>
|
||||
<form class="search" id="search"><input id="q" type="search" autocomplete="off" spellcheck="false" placeholder="0x your payout address" aria-label="Payout address"><button class="btn" type="submit">Look up</button></form>
|
||||
<div class="msg" id="msg" aria-live="polite"></div>
|
||||
<div id="miner" hidden>
|
||||
<dl>
|
||||
<dt>Hash rate</dt><dd id="m-hr"></dd>
|
||||
<dt>Shares</dt><dd id="m-shares"></dd>
|
||||
<dt>Balance</dt><dd id="m-balance"></dd>
|
||||
<dt>Paid</dt><dd id="m-paid"></dd>
|
||||
<dt>Blocks found</dt><dd id="m-blocks"></dd>
|
||||
</dl>
|
||||
<div class="tbl"><table><thead><tr><th>Worker</th><th class="n">Hash rate</th><th class="n">Accepted</th><th class="n">Stale</th><th class="n">Rejected</th><th>Shift</th><th>Proving</th><th>Status</th></tr></thead><tbody id="m-workers"></tbody></table></div>
|
||||
<p class="note">A worker that also proves shards gives about 4% of its hash rate to the prover (measured on an RTX 5090, bench-log "proving v1"); proving income is paid to your own key by the chain and does not appear here. The Proving column says when the miner reports it.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two">
|
||||
<div class="card" id="blocks" style="margin-top:0">
|
||||
<div class="viz-head"><h2>Blocks</h2><div class="eyebrow">newest first, every 5 s</div></div>
|
||||
<div class="tbl"><table><thead><tr><th>Block</th><th class="n">DAA</th><th>Finder</th><th class="n">Reward</th><th class="n">Effort</th><th>Status</th><th>Time</th></tr></thead><tbody id="blocks-body"><tr><td colspan="7">Loading.</td></tr></tbody></table></div>
|
||||
</div>
|
||||
<div class="card" id="payments" style="margin-top:0">
|
||||
<div class="viz-head"><h2>Payments</h2><div class="eyebrow">EVM transfers from the pool address</div></div>
|
||||
<div class="tbl"><table><thead><tr><th>Time</th><th>Address</th><th class="n">IGN</th><th>Tx</th><th>Status</th></tr></thead><tbody id="pay-body"><tr><td colspan="5">Loading.</td></tr></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="viz-head"><h2>API</h2><div class="eyebrow">JSON, GET, CORS open</div></div>
|
||||
<dl>
|
||||
<dt>/api/stats</dt><dd><a class="hash" href="/api/stats">pool hash rate, miners, workers, blocks 24 h, last block, fee, minimum payout, luck, and the network's difficulty, hash rate and reward</a></dd>
|
||||
<dt>/api/blocks</dt><dd><a class="hash" href="/api/blocks">blocks found with DAA score, time, finder, status and the PPLNS split</a></dd>
|
||||
<dt>/api/miners/<address></dt><dd class="hash">hash rate, shares, workers, balance, paid, payments</dd>
|
||||
<dt>/api/payments</dt><dd><a class="hash" href="/api/payments">every payout with its transaction hash</a></dd>
|
||||
<dt>/api/pool-stats</dt><dd><a class="hash" href="/api/pool-stats">the flat camelCase object pool dashboards poll</a></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="foot"><div class="wrap">Igneum. Mined by GPUs. Proven by fire. · <a href="https://igneum.network/">igneum.network</a> · Nothing on this page is an offer to sell anything.</div></footer>
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s ?? '').replace(/&/g,'&').replace(/</g,'<');
|
||||
const hr = h => { h = Number(h) || 0; const u = ['H/s','kH/s','MH/s','GH/s','TH/s']; let i = 0; while (h >= 1000 && i < u.length - 1) { h /= 1000; i++; } return h.toFixed(i ? 2 : 0) + ' ' + u[i]; };
|
||||
const short = h => h ? h.slice(0, 10) + '…' : '';
|
||||
const rel = ms => { const d = (Date.now() - ms) / 1000; return d < 60 ? Math.round(d) + ' s ago' : d < 3600 ? Math.round(d / 60) + ' min ago' : Math.round(d / 3600) + ' h ago'; };
|
||||
const chip = s => `<span class="chip ${s === 'confirmed' ? 'ok' : s === 'orphan' || s === 'failed' ? 'bad' : 'hot'}">${esc(s)}</span>`;
|
||||
async function stats() {
|
||||
try {
|
||||
const r = await fetch('/api/stats'); const s = await r.json(); const p = s.pool, n = s.network;
|
||||
$('p-hr').textContent = hr(p.hashrate); $('p-hr1h').textContent = hr(p.hashrate_1h) + ' over 1 h';
|
||||
$('p-miners').textContent = p.miners + ' / ' + p.workers;
|
||||
$('p-blocks').textContent = p.blocks_24h; $('p-blocks-s').textContent = p.blocks_confirmed_24h + ' confirmed / ' + p.blocks_orphaned_24h + ' orphaned, ' + p.blocks_total + ' total';
|
||||
if (p.last_block) { $('p-last').textContent = short(p.last_block.hash); $('p-last-s').textContent = rel(p.last_block.ts_ms) + ', ' + p.last_block.status; }
|
||||
$('n-hr').textContent = n.hashrate == null ? 'n/a' : hr(n.hashrate); $('n-diff').textContent = 'difficulty ' + Math.round(n.difficulty).toLocaleString() + ', DAA ' + n.daa_score;
|
||||
$('n-reward').innerHTML = esc(n.miner_reward_ign.toFixed(4)) + '<small>IGN</small>';
|
||||
$('p-effort').textContent = Math.round(p.effort_current * 100) + '%'; $('p-luck').textContent = 'luck 24 h ' + (p.luck_24h ? Math.round(p.luck_24h * 100) + '%' : 'n/a');
|
||||
$('p-paid').innerHTML = esc(p.paid_24h_ign.toFixed(4)) + '<small>IGN</small>'; $('p-paid-s').textContent = p.pending_balance_ign.toFixed(4) + ' IGN pending' + (p.dry_run ? ' (dry run)' : '');
|
||||
} catch (e) { $('p-last-s').textContent = 'stats unavailable'; }
|
||||
}
|
||||
async function blocks() {
|
||||
try {
|
||||
const r = await fetch('/api/blocks?limit=25'); const j = await r.json();
|
||||
$('blocks-body').innerHTML = j.blocks.length ? j.blocks.map(b => `<tr><td class="hash">${short(b.hash)}</td><td class="n">${b.daa_score}</td><td class="hash">${short(b.finder)} ${esc(b.worker)}</td><td class="n">${b.reward_ign.toFixed(4)}</td><td class="n">${Math.round(b.effort * 100)}%</td><td>${chip(b.status)}</td><td>${rel(b.ts_ms)}</td></tr>`).join('') : '<tr><td colspan="7">No block yet.</td></tr>';
|
||||
} catch (e) {}
|
||||
}
|
||||
async function payments() {
|
||||
try {
|
||||
const r = await fetch('/api/payments?limit=25'); const j = await r.json();
|
||||
$('pay-body').innerHTML = j.payments.length ? j.payments.map(p => `<tr><td>${rel(p.ts_ms)}</td><td class="hash">${short(p.address)}</td><td class="n">${p.amount_ign.toFixed(6)}</td><td class="hash">${p.tx_hash ? short(p.tx_hash) : ''}</td><td>${chip(p.status)}</td></tr>`).join('') : '<tr><td colspan="5">No payment yet.</td></tr>';
|
||||
} catch (e) {}
|
||||
}
|
||||
$('search').addEventListener('submit', async e => {
|
||||
e.preventDefault(); const a = $('q').value.trim();
|
||||
if (!/^0x[0-9a-fA-F]{40}$/.test(a)) { $('msg').textContent = 'An address is 0x followed by 40 hex characters.'; return; }
|
||||
$('msg').textContent = 'Looking.';
|
||||
const r = await fetch('/api/miners/' + a); const m = await r.json();
|
||||
if (!m.ok) { $('msg').textContent = m.error; return; }
|
||||
$('msg').textContent = m.online ? 'Online.' : (m.first_seen_ms ? 'Not connected now.' : 'No shares from this address yet.');
|
||||
$('miner').hidden = false;
|
||||
$('m-hr').textContent = hr(m.hashrate) + ' (10 min), ' + hr(m.hashrate_1h) + ' (1 h)';
|
||||
$('m-shares').textContent = m.shares.accepted + ' accepted, ' + m.shares.stale + ' stale, ' + m.shares.rejected + ' rejected';
|
||||
$('m-balance').textContent = m.balance_ign.toFixed(6) + ' IGN'; $('m-paid').textContent = m.paid_ign.toFixed(6) + ' IGN'; $('m-blocks').textContent = m.blocks;
|
||||
$('m-workers').innerHTML = m.workers.length ? m.workers.map(w => `<tr><td>${esc(w.name)}</td><td class="n">${hr(w.hashrate)}</td><td class="n">${w.accepted}</td><td class="n">${w.stale}</td><td class="n">${w.rejected}</td><td>${w.shift ?? ''}</td><td>${w.proving ? 'yes' : 'no'}</td><td>${chip(w.online ? 'online' : 'offline')}</td></tr>`).join('') : '<tr><td colspan="8">No worker yet.</td></tr>';
|
||||
history.replaceState(null, '', '#lookup');
|
||||
});
|
||||
stats(); blocks(); payments();
|
||||
setInterval(stats, 5000); setInterval(blocks, 5000); setInterval(payments, 15000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue