Merge remote-tracking branch 'origin/master' into release-0.3.15
This commit is contained in:
commit
409cb0f76c
39 changed files with 11559 additions and 39 deletions
|
|
@ -91,6 +91,11 @@ Also logged for context: the Mac's Linux cross-build with zig (`infra/cross/buil
|
|||
| An all-identical overlay listed only directories | the first run's touch pipeline got an empty file list and failed | files only are counted and re-stamped (lib.sh bs_overlay_dir) |
|
||||
| bash 3.2 on the Mac treats an empty array as unbound under `set -u` | run-from-mac.sh died on `PASS[*]` | a string instead of an array |
|
||||
| `grep -q` plus `pipefail` turned a strings hit into a miss | the commit-string gate failed a stamped igneumd on its first use (SIGPIPE on `strings`) | `grep -c` |
|
||||
| A path dependency inside a vendor repo (the shipper's proving build, 6 Oct 2026) | proving/igneum-prove depends on vendor/igneum-node-exec/igneum/evm-types, a MEMBER of the fork's workspace (it inherits `thiserror` from the fork's root manifest); the first design synced that one directory, so cargo found no workspace root on the box ("failed to load manifest for workspace member"), and the shipper cross-built the Linux prove-host on the Mac with cargo-zigbuild meanwhile | lib.sh groups path dependencies by git top level: one under vendor/ is a whole repository, pushed to its mirror (a fork worktree such as igneum-node-exec goes to /srv/igneum-node.git, which already held its branch; a repository of its own gets /srv/<name>.git, created on first use) and checked out whole at /srv/builds/<worktree>/vendor/<name>; run-from-mac.sh wires every vendor repo the Cargo.toml files reach (today only igneum-node-exec). The detector's first version tested "under BS_TOP" before "own repository" and missed it, since vendor/ lies under the igneum top level on disk. Then `libprotobuf-dev` was missing (sp1-prover-types's build script imports google/protobuf/empty.proto); added to provision.sh. Proof: `cd proving/igneum-prove && tools/build-remote.sh -- build --release -p igneum-prove-host`: igneum-prove-host 71,943,192 B, sha256 e9213e3a6c979512d7859f6d8e848105bab53f4355e99fb0d30fb4a72c2d5714, ELF x86-64, 1 min 05 s warm (the cold run compiled 605 crates in 55 s before protoc stopped it). A Mac worktree has no vendor/ of its own, so a worktree that builds proving needs `git -C vendor/igneum-node worktree add <wt>/vendor/igneum-node-exec execution-layer` first, as the fork worktrees do |
|
||||
| One remote build lost its ssh session after 75 s (18:30:50 UTC, the first full proving build) | the remote bash died with it (slot line left behind, no JSONL line); no OOM, no reboot, the retry a minute later passed | the dashboard collector's one-pass unit finished within a second of the drop, so it was tested: a 90 s remote session through the same ControlMaster path survived two collector passes triggered by hand; the collector only reads (/proc, lock files, `flock -n`, `sccache --show-stats`, `kill(pid, 0)`). One event, no cause in the journal, the retry passed. A build that must survive a dropped connection would need the remote command under setsid with the Mac reconnecting to wait; not done, open if it happens again |
|
||||
| A stamp file at a nested path failed every checkout of /srv/builds/igneum (18:31 to 18:5x UTC, the shipper's 18:52 run) | a repo-kind crate keeps its `.build-remote-sha-<target>` and `target/` inside the crate dir; the checkout mode's clean-tree test only excused them at the tree root | the test is depth-agnostic and uses `--untracked-files=all` (a wholly untracked directory is otherwise collapsed to `?? dir/`); the self-test carries a nested stamp, a nested target dir and a stale `.git/index.lock`, which the mode now removes when no git runs there |
|
||||
| Two runs on one worktree at once (the shipper, 18:48:56Z) | the second run's checkout replaced the first's sources mid-cargo; both died | lib.sh takes a per-worktree lock on the box (`/srv/builds/_locks/wt-<worktree>`, mkdir-atomic, holder line) across sync, build and fetch; a second run waits up to 2 h (a line every minute), a lock older than 3 h is taken over; released on EXIT. The build slot (`build-<k>`) is unchanged |
|
||||
| The 0.3.15 prover pair for the PCs needs `--features igneum-prove-host/cuda` (the PCs run SP1_PROVER=cuda) | my first proving build named no feature | built on the box from master e1b5bc9: igneum-prove-host 73,161,528 B sha256 71bc2438856bb141f6cad3d18489f708568144fad5a06a002fbadefb9ce256f9, igneum-prove-export 3,609,360 B sha256 263bf4cef70af4a13a45b2e79b8dbab373282ea4c571f624d02ddb5791935361 (52 s warm, no CUDA needed at build time); handed to the shipper |
|
||||
| Let's Encrypt saw NXDOMAIN for build.igneum.network | the deSEC record was minutes old; Ubuntu's Caddy then fell back to ZeroSSL and failed with HTTP 422 for ever | issuer pinned to Let's Encrypt; the retry got the certificate |
|
||||
|
||||
## 6. What the box does not do yet
|
||||
|
|
|
|||
93
docs/plans/hands-on-build-1.md
Normal file
93
docs/plans/hands-on-build-1.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# The devnet hands move to igneum-build-1 (node 1 and the observer)
|
||||
|
||||
the project lead's decision, 6 October 2026: the Mac runs nothing the network depends on. After the 0.3.15 cut tonight, node 1 and the
|
||||
observer leave the Mac's launchd agents and run on igneum-build-1 (188.40.146.49, Falkenstein, the build server of
|
||||
docs/plans/build-server.md) as systemd units. The shipper (owner of infra/devnet/restart-hand-nodes.sh and the exec recovery
|
||||
recipe) and the build-server agent run it together on the shipper's "0.3.15 live" line.
|
||||
|
||||
## 1. What runs on the Mac today (read 6 Oct 2026, 19:5x UK)
|
||||
|
||||
| Hand | How it runs | Flags that matter | Data |
|
||||
|---|---|---|---|
|
||||
| node 1 | launchd `network.igneum.devnet.node1`, KeepAlive, under `caffeinate -dims`, binary `igneum-wt-ship0314/vendor/igneum-node-0314/target-integration/release/igneumd` (0.3.14), log `~/Library/Logs/Igneum/node1.out` | `--devnet --enable-unsynced-mining --appdir=/tmp/igneum-devnet/node1 --rpclisten=0.0.0.0:26610 --listen=0.0.0.0:26611 --evm-rpclisten=127.0.0.1:26791 --addpeer=188.245.5.161:26611 --addpeer=192.168.68.67:26611 --override-params-file=/tmp/igneum-devnet/override-v3.json --igneum-exec-snapshot=/tmp/igneum-devnet/node1-copy-snapshot.bin,ac101f13... --nodnsseed --disable-upnp --nologfiles --yes` | 856 MB (`consensus`, `evm` with exec-snapshot.bin 127,564,588 B and .prev, `meta`) |
|
||||
| observer node | launchd `network.igneum.devnet.observer`, KeepAlive, same binary, log `observer.out` | `--appdir=/tmp/igneum-devnet/observer-v4 --rpclisten=127.0.0.1:26640 --rpclisten-json=127.0.0.1:28640 --listen=127.0.0.1:26641 --addpeer=127.0.0.1:26611 --addpeer=188.245.5.161:26611 --addpeer=192.168.68.67:26611` + the same override and snapshot flags; no EVM listener | 822 MB |
|
||||
| observer process | `tools/observer/run.sh` loop (nohup, since 5 Oct 20:39) running `node tools/observer/observer.mjs` from the shared checkout, `IGNEUM_RPC=ws://127.0.0.1:28640`, `IGNEUM_EVM_RPC` default `http://127.0.0.1:26800` (the Miner app's node), `DATABASE_URL` from `~/.config/igneum/env`; `tools/observer/autosync.sh` (since 4 Oct) fast-forwards the shared checkout and restarts it on observer changes | writes Neon (`live_*` tables); `/api/live` and `/live` read Neon only |
|
||||
|
||||
Also found: the Mac's own miner (`igneum-miner`, pid 7721) holds a gRPC connection to node 1 on 26610. The override file today:
|
||||
`{"difficulty_v2_activation_daa":33000,"proving_v0_activation_daa":84100,"fees_v1_activation_daa":210000,"finality_v3_activation_daa":135200,"program_class_v3_activation_daa":154800,"proving_v1_activation_daa":154800,"proving_v1_segment_blocks":8,"proving_v1_unproven_daa":600,"proving_v1_aggregator_share_bps":1000,"proving_v1_fresh_rule_daa":198000,"exec_restart_number":27276,"exec_restart_hash":"bb45cf0d...","exec_restart_trust_daa":200000}`.
|
||||
Node 1's last exec lines: `exec state loaded from a snapshot: tip 141700 ...` and `exec sync: resumed from .../node1/igneum-devnet/datadir/evm/exec-snapshot.bin (tip 141700, 127564588 bytes, sha256 0x67637c55...)`.
|
||||
|
||||
## 2. What runs on the box afterwards
|
||||
|
||||
| Unit | Runs | Ports | Files |
|
||||
|---|---|---|---|
|
||||
| `igneum-node1.service` | `/srv/hands/bin/run-node1.sh` -> `/srv/hands/bin/igneumd` (0.3.15 Linux, the box's own build), `--appdir=/srv/hands/node1`, `--externalip=188.40.146.49` | p2p `0.0.0.0:26611` (ufw open); gRPC `127.0.0.1:26610`, wRPC JSON `127.0.0.1:28610`, EVM `127.0.0.1:26791` on loopback | `/srv/hands/hands.env` (IGNEUMD, OVERRIDE, SNAPSHOT, EXTERNAL_IP, PEERS), `/srv/hands/override.json`, `/srv/hands/node1-copy-snapshot.bin` |
|
||||
| `igneum-observer-node.service` | `run-observer-node.sh`, `--appdir=/srv/hands/observer-node`, peers node 1 on the box and the seed | p2p `127.0.0.1:26641`, gRPC `127.0.0.1:26640`, wRPC JSON `127.0.0.1:28640`, EVM `127.0.0.1:26840` (new: the observer's proving feed came from the Miner app's node on the Mac) | same env and override |
|
||||
| `igneum-observer.service` | `/usr/local/bin/node tools/observer/observer.mjs` in `/srv/observer/igneum` (a clone of the mirror `/srv/igneum.git`, master), `EnvironmentFile=/srv/observer/env` (mode 600, owner build: DATABASE_URL copied from the Mac's `~/.config/igneum/env`, `IGNEUM_RPC=ws://127.0.0.1:28640`, `IGNEUM_EVM_RPC=http://127.0.0.1:26840`; never in the repo), Restart=always 3 s (run.sh's loop) | outbound to Neon only | `/srv/observer/env` |
|
||||
| `igneum-observer-sync.timer` | every 5 min `observer-sync.sh`: fast-forward the clone from the mirror, restart the observer when `tools/observer` or `site/lib` changed (autosync.sh's job; the mirror is fed by every build-remote.sh and run-from-mac.sh push) | | |
|
||||
|
||||
All units: `Restart=always`, journald (`journalctl -u igneum-node1 -f`), `MemoryMax=24G` on the nodes, enabled for reboot, run as user
|
||||
`build` (one uid on a single-purpose box; the units are the separation). Installer: `infra/build-server/hands/install-hands.sh`
|
||||
(idempotent, inert: enables, never starts). Mover: `infra/build-server/hands/move-hand.sh` (dry run by default, `--go` executes).
|
||||
|
||||
## 3. Ports, names, firewall
|
||||
|
||||
| Port | On the Mac today | On the box | ufw |
|
||||
|---|---|---|---|
|
||||
| 26611 TCP | node 1 p2p, open | node 1 p2p, open, `--externalip` so peers learn it | open already |
|
||||
| 26610 TCP | node 1 gRPC on `0.0.0.0` (the Mac's miner connects to it) | gRPC on loopback; a Mac tool reaches it through `ssh -L 26610:127.0.0.1:26610 build@188.40.146.49` | not opened (decision for main: open it to a fixed IP list if a miner must feed node 1 from outside) |
|
||||
| 26791 TCP | node 1 EVM RPC, loopback | loopback | closed |
|
||||
| 26640, 28640, 26641 TCP | observer node, loopback | loopback (+ EVM 26840) | closed |
|
||||
| 26811 TCP | (the TESTNET seed p2p port, not an RPC port: infra/seed-nodes/config.sh) | unused by the hands | open from provision; harmless |
|
||||
|
||||
DNS (deSEC, main adds): `node1.devnet.igneum.network A 188.40.146.49`, `observer.devnet.igneum.network A 188.40.146.49`. The
|
||||
observer node listens on loopback only, so its name is for the future public endpoint and for the runbooks' wording. The public
|
||||
API (`/api/live`, `/live`) reads Neon and is unchanged.
|
||||
|
||||
## 4. The move, one hand at a time (run on the shipper's "0.3.15 live" line)
|
||||
|
||||
| Step | Command (Mac) | What happens | Proof |
|
||||
|---|---|---|---|
|
||||
| 0 | `ssh root@188.40.146.49 'bash -s' < infra/build-server/hands/install-hands.sh` | units, run scripts, dirs, the observer clone; nothing started (done 6 Oct, see section 6) | `systemd-analyze verify` clean, units enabled and inactive |
|
||||
| 1 | `move-hand.sh binary --node /Users/joshm/Projects/igneum-wt-ship0315/vendor/igneum-node-0315` | the 0.3.15 Linux igneumd built ON the box (tools/build-remote.sh), installed as `/srv/hands/bin/igneumd-0.3.15-<sha>`, commit string checked; the Mac's override file and the exec snapshot copied; hands.env pointed at them | `igneumd --version`, commit in strings, sha256 lines |
|
||||
| 2 | `move-hand.sh observer-node --go` | hot rsync of `/tmp/igneum-devnet/observer-v4` (822 MB) while the Mac node runs; `launchctl bootout` of the Mac's observer agent (KeepAlive would restart a killed pid); final rsync (the delta, seconds, node stopped so RocksDB is consistent); `systemctl start igneum-observer-node` | the unit's first `[igneum-exec] exec sync: resumed from ...` line and its first `Accepted N blocks` lines, printed by the script |
|
||||
| 3 | `move-hand.sh observer --go` | `/srv/observer/env` written (scp, mode 600, owner build); the Mac's autosync.sh, run.sh and observer.mjs stopped FIRST (two writers would duplicate Neon rows), then `systemctl start igneum-observer` | the observer's first journal lines (`rpc load`, events); `/api/live` fresh within a minute |
|
||||
| 4 | `move-hand.sh node1 --go` | the same as step 2 for node 1 (856 MB); node 1 is the last node the Mac serves, the observer node on the box already peers with the seed, so the network never loses both hands | node 1's first exec line and `PoW accepted` lines on the box |
|
||||
| 5 | `move-hand.sh unload --go` | the two plists moved aside so a login never brings the Mac hands back; the Mac's miner loses node 1's RPC (section 5) | `pgrep igneumd` on the Mac shows only the wallet's node |
|
||||
| 6 | `move-hand.sh status`, 10 minutes later | both nodes at the network tip, observer writing, `/live` current | the status output in this plan's section 6 |
|
||||
|
||||
Exec recovery on the box: each node resumes from its data dir's `evm/exec-snapshot.bin` (copied with the data dir); if that file
|
||||
is bad or missing, `--igneum-exec-snapshot=/srv/hands/node1-copy-snapshot.bin,<sha256>` (the Mac's recovery snapshot, copied in
|
||||
step 1) is taken, as the launchd agents do today; the override's `exec_restart_number`/`exec_restart_hash`/`exec_restart_trust_daa`
|
||||
travel unchanged. A snapshot from the seed is the fallback the shipper's recipe names; the p2p snapshot path refuses one below the
|
||||
node's tip (CLAUDE.md, Devnet 2 rules).
|
||||
|
||||
Rollback at any step: the Mac's plist is still in `~/Library/LaunchAgents` until step 5; `launchctl bootstrap gui/$(id -u) <plist>`
|
||||
brings a hand back on the Mac within 10 s, and the box unit is stopped with `systemctl stop`. Data dirs are copies; nothing is deleted
|
||||
on the Mac.
|
||||
|
||||
## 5. Decisions and consequences for main (DECIDED by main, 6 October 2026, 19:1x UTC)
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| The Mac's miner on node 1's gRPC | stops (paused by the project lead's order anyway; the Mac mines nothing) |
|
||||
| The LAN peer 192.168.68.67 (a PC) | the shipper adds `--addpeer=188.40.146.49:26611` to the PCs' and the Mac's app node args in the 0.3.15 update; the seeds carry the public peers already |
|
||||
| node 1's gRPC 26610 | loopback only on the box; Mac tools tunnel |
|
||||
| DNS | `node1.devnet.igneum.network` and `observer.devnet.igneum.network` -> 188.40.146.49 exist in deSEC (checked: ns1.desec.io answers both) |
|
||||
|
||||
The table below is the reasoning that led to them.
|
||||
|
||||
|
||||
| Finding | Means | Proposed |
|
||||
|---|---|---|
|
||||
| The Mac's own miner (`igneum-miner` pid 7721) mines through node 1's gRPC 26610 | after step 4 it loses its node; the project lead's rule says the Mac runs nothing the network depends on, and a miner is hashrate, not a dependency | either it stops with node 1, or it follows through an ssh tunnel to the box (`ssh -L 26610:...`); main decides, default: it stops |
|
||||
| `192.168.68.67:26611` is a LAN peer of both hands | unreachable from the box; the box peers with the seed 188.245.5.161 and the two hands peer with each other | hands.env `PEERS=188.245.5.161:26611`; whoever runs 192.168.68.67 adds `--addpeer=188.40.146.49:26611` if it relied on node 1 |
|
||||
| CLAUDE.md "Running agents on this Mac" says the box never hosts a live-devnet node (my R6, 6 Oct 18:xx) | contradicted by the project lead's decision the same evening | rewritten in this commit: the box hosts the two hands as units; it still holds no secret beyond `/srv/observer/env` (DATABASE_URL, mode 600) |
|
||||
| The observer's proving feed on the Mac read the Miner app's node (26800) | on the box there is no app node | the observer node gets `--evm-rpclisten=127.0.0.1:26840` (0.3.15 runs the proving build) and the observer reads it; if its shard plans lag node 1's, point `IGNEUM_EVM_RPC` at node 1's 26791 |
|
||||
| autosync.sh followed origin/master from GitHub; the box has no GitHub credential | the box's clone follows the MIRROR, which moves only when a Mac agent pushes (every build-remote.sh and run-from-mac.sh run pushes the branch it builds; run-from-mac.sh pushes every branch) | enough today; a read-only deploy key on the box (generated there, added by main to the repository) would make it follow GitHub directly, open |
|
||||
| Two hands stop for one to three minutes each during the move (the final rsync and the start) | the other hand serves throughout; the observer feed pauses once for about a minute (step 3) | accepted by the order above |
|
||||
| Data dirs are rsynced hot then with the node stopped | the hot pass moves 99 percent of 1.7 GB with the hands up; the stopped pass is the delta | the Mac's upload rate decides the hot pass (minutes); measured in section 6 |
|
||||
|
||||
## 6. Run log (filled as it happens)
|
||||
|
||||
RUNLOG
|
||||
238
docs/plans/pool.md
Normal file
238
docs/plans/pool.md
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
# Pool v0: what it does, what it does not, what was measured, and the listing checklist
|
||||
|
||||
5 October 2026, branch `pool-v0` (main worktree `../igneum-wt-pool`, fork worktree `vendor/igneum-node-pool` on the
|
||||
fork's `pool-v0` from `release-0.3.6` a24ab01a). REBASED 6 October 2026 (section 9): branch `pool-v0-rebase` on master, fork
|
||||
branch `pool-v0-rebase` on `release-0.3.14-node` 4c6b129d (worktree `../igneum-wt-pool-rebase`, fork worktree
|
||||
`vendor/igneum-node-pr`); the share re-check hashes the template's program class, never a fixed one. the project lead's ask: a public mining pool with the stats API WhatToMine and
|
||||
HiveOS read, "the biggest step toward being listable". Built against `docs/spec/09-pool-protocol.md`, the miner's
|
||||
template subscription and worker protocol (`igneum/miner/src/main.rs`), the Hive package's `local` mode
|
||||
(`packaging/hive`), ledger F10 and G6 (pools and votes), and `docs/plans/explorer.md` section 6. Nothing is deployed.
|
||||
|
||||
## 1. Rust, and why
|
||||
|
||||
Spec 9.3 fixes the member protocol as newline-delimited JSON over TLS, not the node's gRPC. The miner is Rust and
|
||||
talks gRPC to its node; the pool must verify every share on the CPU warp verifier (spec 9.8 item 5), which is the
|
||||
node's own `IgneumEngine` in `consensus/pow/src/igneum.rs` (same program, cache and init words as block validation),
|
||||
and it must build templates and submit blocks over the node's gRPC. Both are Rust crates by path. A Node service
|
||||
would have had to reimplement the verifier or shell out per share; the Rust pool calls it in process at 0.44 ms per
|
||||
share (measured, section 5). JSON lines are three serde derives. So: Rust, `pool/`, its own Cargo workspace reading
|
||||
the fork by path. The miner side is a module in the fork (`igneum/miner/src/pool.rs`), because the identity, voter,
|
||||
template and worker code it reuses lives there.
|
||||
|
||||
## 2. What v0 does
|
||||
|
||||
| Area | v0 | Spec section |
|
||||
|---|---|---|
|
||||
| Transport | newline JSON over plain TCP on 4463 (devnet), 4462, 4461; one object per line, `"t"` names the type, unknown fields ignored, 4 MiB line cap | 9.3, 9.5 |
|
||||
| Session | `hello` / `welcome` (version, chain id, pool address, modes, `vote_mode: member`, `share_scheme` with fee, window and min payout), `authorize` with the member's BLS pubkey and proof of possession (verified with `verify_pop`), payout address and worker name / `authorized` | 9.3, 9.5 |
|
||||
| Templates | mode A, one template per member per tip (NewBlockTemplate subscription, 1 s refresh, on demand): the member's `vote_key_hash` in the header, its key reveal and the pool's `IGNA` payout address in the coinbase extra data; sent as the node's `RpcRawBlock` JSON so a member with a node can submit it there | 9.4, 9.4.1 items 1 and 2, 9.6 item 2 |
|
||||
| Seeds | `seeds` on connection and on every change, from the template's `pow_epoch` (current and next epoch seed, boundary DAA, day, the schedule; since 6 October 2026 also `program_class`, `next_program_class`, `era_seed`, `era_index`, `genesis_day_index`, `genesis_dataset_log2`, `program_class_v3_activation_daa`, so a member without a node builds the same program and day cache as the pool's node) | 9.9 |
|
||||
| Jobs | `job` with prehash, `target64`, `share_target64`, a random 2^32 nonce space, the seed pair, `program_class` and `era_seed` (6 October 2026: the member refuses a job without a class, code `seeds`, and never hashes a guessed one), `clean` on a tip change | 9.5 |
|
||||
| Shares | `share` = a lane hash at or below `share_target64`; the pool evaluates the lane on the CPU and answers `share_result` with `ok`, `stale` (superseded job past the 2,000 ms grace), `duplicate`, `above_target`, `wrong_hash`, `unknown_job`; a share at or below `target64` is a block, submitted to every node | 9.8 items 1, 5, 7 |
|
||||
| Vardiff | per member, one share per 10 s, shift `s` with `share_target = target64 << s` never saturated; first correction sized from the measured rate (up to 8 steps), then one step per 30 s at most; idle members eased one step per 3 intervals | 9.8 items 2 and 4 (one deviation: the sized first step, below) |
|
||||
| Weight | `2^-s` of a block per share | 9.8 item 3 |
|
||||
| Payment | PPLNS over a window of N blocks of weight (default 2); the split is snapshotted when a block is found and credited when it is confirmed blue (a chain block or in a chain block's mergeset blues, the set the execution layer pays); orphans pay nobody; fee (default 1%) off the top | 9.8 item 8 |
|
||||
| Payouts | EIP-1559 transfers from the pool's coinbase address through the node's eth_ JSON-RPC, one per payee at or above the minimum, at most 16 per round, receipts checked, a failed receipt re-credits; `--dry-run` records and sends nothing | design 1.1 (execution layer rewards) |
|
||||
| The 20% | untouched: the execution layer credits the proving escrow by rule from consensus data (`executor.rs`); the pool's templates name only its own address and receive the 80% | 2.5, 5.3 |
|
||||
| Votes | the member votes through its own node with its own key, as in solo mode; the pool holds no key and names each member's key in its header | 9.6, 9.7 items 1 and 2 |
|
||||
| Member checks | own key in the header, the pool's address and own key reveal in the coinbase (refused with `vote_key`, `payout`, `reveal`); the pool's epoch seed, program class and era seed against the member's node (`seeds`; the class and era joined the check on 6 October 2026); a custodial pool (`vote_mode` not `member`) refused by default | 9.4.1 items 1 and 2, 9.9 item 2, 9.6 item 5 |
|
||||
| Found blocks | the member submits to its own node and sends `solution`; the pool submits to every node | 9.2 |
|
||||
| API | `/api/stats`, `/api/blocks`, `/api/miners/<address>`, `/api/payments`, `/api/pool-stats` (Hive-style flat object); field contracts in `pool/src/api.rs`, fixtures from the measured run | explorer.md section 6 |
|
||||
| Page | `pool/web/index.html` under the site's tokens: tiles, connect card with the exact miner flags, address lookup, blocks, payments | |
|
||||
| Persistence | `state.json` snapshot every 15 s and at shutdown (balances, blocks, payments, PPLNS window, lifetime counters) | |
|
||||
| Miner | `igneum-miner mine <grpc url|none> <threads> <secs> <label> --pool <host:port> --evm-address <0x..> --worker-name <s> [--proving] [--worker <exe>]`: CPU threads or a GPU worker fed the share target; the CPU re-check of every GPU `found` before it is sent; STATUS lines with the Hive tokens (`accepted=`, `rejected=`, `now=<MH/s>`) | 9.8 item 6 |
|
||||
| HiveOS | Flight Sheet pool URL `pool://host:4463`, `NODE=local` or `NODE=grpc://...` for a verifier node, `h-run.sh` passes `--pool` and `--worker-name` | packaging/hive |
|
||||
| Dev fee | none in pool mode: the pool issues the templates, so the 1-in-100 template mechanism does not exist; the pool's fee is the only fee and is in `welcome`, on the page and in the Hive README (consequence C6, 5 October 2026) | |
|
||||
| Proving | the member's `stats` line carries `proving: true` when it also proves (`--proving`; the app sets it when its prover runs); the pool shows it per worker and the page says a proving worker gives about 4% of its rate to the prover (bench-log "proving v1") and that proving income never passes through the pool (C6) | 9.8 item 8 |
|
||||
|
||||
## 3. What v0 does not
|
||||
|
||||
| Gap | Why | What closes it |
|
||||
|---|---|---|
|
||||
| TLS on the member port | spec 9.3 requires TLS 1.3 because a hijacked line could feed a member a checkpoint hash to sign. v0 relays no votes and the member signs nothing it got from the pool (votes go through its own node), so the hijack has nothing to feed; the gap is confidentiality of shares and the `binding` field. Plain TCP tonight; `rustls` is in the fork's dependency tree | a `--tls-cert/--tls-key` pair and the TLS exporter binding in `authorize` |
|
||||
| Mode B (commitment) and mode C (declared templates) | the spec requires A and C of a conforming pool; C needs the member to build its own template with the pool's tag, and a node RPC that validates a template without submitting it (O-9.4) | O-9.4 first, then `declare_template` / `template_ack` / `template_refused` |
|
||||
| Vote relay and carriage by the pool | 9.7 items 3 to 6; v0 members vote through their own node (road three of the three roads), so a member without a node does not vote | `vote` / `vote_ack` / `votes_carried`, aggregation per index (O-3.12), the carriage ratio on the page |
|
||||
| Stratum compatibility | none, and the spec is not stratum-like: Stratum V1 is JSON-RPC with `mining.subscribe`, `mining.notify`, `mining.submit` over plain TCP and a job that carries a coinbase split and merkle branches; Igneum's protocol carries a full block, a 64-bit lane target and a BLS key, and its share is a lane of a 32-nonce group. A Stratum V1 bridge would have to hide the vote key, which is the thing 9.6 keeps with the member, so none is planned. Mining operating systems add custom miners by command line (Hive's custom-miner contract, `packaging/hive`), which is how this ships | |
|
||||
| Sample verification at high shifts | spec 9.8 item 5 allows sampling above `sample_shift` 8; v0 verifies every share (2,270 per second per core keeps it cheap at any member count the first pool will see) | a sampling switch when a core is short |
|
||||
| One template fetch per member per second | at 1,000 members that is 1,000 `getBlockTemplate` calls a second on the pool's node and 10 MB/s of mode A templates | a template RPC that takes a list of keys, or mode B |
|
||||
| A database | `state.json` and an in-memory ledger; a crash between snapshots loses up to 15 s of shares | SQLite or Postgres when the first real pool runs |
|
||||
| Custodial mode | `vote_mode: pool` is not offered (allowed by 9.6 item 5, refused by members by default) | not planned |
|
||||
| Vardiff first step | the spec moves the shift one step per `set_target`; v0's first correction after 8 shares or one interval moves up to 8 steps from the measured rate, because a 100 MH/s card at the initial 2^20-hash share target sends 100 shares a second and one step per 30 s would take 5 minutes to reach one per 10 s | spec 9.10 row "Vardiff step" to say "after the first correction" |
|
||||
| Luck | `luck_24h` is blocks found over block-weights of shares in the day; MiningPoolStats reads luck as expected over actual, and the field says which in the API source line | |
|
||||
| App setting | design only (section 7) | |
|
||||
|
||||
## 4. How it was measured
|
||||
|
||||
`pool/tools/measure.mjs` under the `run` lock: one `igneumd` 0.3.6 (`vendor/igneum-node-036/target-integration`,
|
||||
needs no change for the pool) on ports 30400 to 30403 (gRPC, p2p, JSON, EVM), network id `igneum-devnet-3040`, the
|
||||
60x fast-time profile (`infra/fast-time/override-60x.json`) with real proof of work and `genesis_bits 0x1e400000`
|
||||
(2^18 expected hashes per block, the CPU devnet value of the bench log), the pool on 30463 (members) and 30480 (API),
|
||||
and three CPU miners of different identities, payout addresses and thread counts (4, 2, 2) pointed at the pool with
|
||||
the node as their verifier (seed checks, votes). Ten minutes in dry run, then the same pool restarted without
|
||||
`--dry-run` and a 0.1 IGN minimum for the real payout, then 100 requests per second against the API for 30 s.
|
||||
|
||||
Commands (the main checkout's lock script, from the pool worktree):
|
||||
|
||||
```
|
||||
tools/lock/with-lock.sh build nice -n 19 cargo build --release -j 4 # pool/
|
||||
tools/lock/with-lock.sh build nice -n 19 cargo build --release -j 4 -p igneum-miner # vendor/igneum-node-pool
|
||||
tools/lock/with-lock.sh build nice -n 19 cargo test --release -j 4 # pool/ (unit tests)
|
||||
tools/lock/with-lock.sh run node pool/tools/measure.mjs --secs 600 --payout-secs 240 --rps 100
|
||||
```
|
||||
|
||||
## 5. Measured
|
||||
|
||||
Two runs, both 5 October 2026 on this Mac (M-series, 16 cores) while other agents' builds and test networks ran on
|
||||
the same machine: every millisecond below is a number taken under load and says so. Counts, shares, blocks, payments
|
||||
and balances are not disturbed by load. Summary JSON: `<scratch>/measure/summary.json` and `measure2/summary.json`.
|
||||
|
||||
Run 1: the pool was the whole network (three CPU miners, 8 threads in all, no other miner). Run 2: an 8-thread solo
|
||||
CPU miner ("carrier") mined beside the pool so the pool held about half the hashrate and its blocks competed. Run 1
|
||||
also found a bug: the member sends a `share` and then a `solution` for the same nonce (spec 9.5), and the pool counted
|
||||
the solution as a duplicate share (604 "rejected" of 617 accepted, every one a solution); fixed before run 2 (a
|
||||
solution whose nonce the share already credited is acknowledged, not counted), run 2 shows 0 rejected.
|
||||
|
||||
| Number | Run 1 (pool = network) | Run 2 (pool about half) | Source |
|
||||
|---|---|---|---|
|
||||
| Run length | 600 s dry run, 240 s real payout, 30 s API load | same | `measure.mjs` |
|
||||
| Shares accepted / stale / rejected | 617 / 0 / 604 (the solution bug) | 317 / 0 / 0 | `/api/stats` pool.shares |
|
||||
| Shares per minute, pool | 61.7 | 31.7 | accepted / 600 s |
|
||||
| rig1 (4 threads): shares per minute, hashrate 10 min, final shift | 31.2, 86.9 kH/s, 0 | 15.8, 47.5 kH/s, 0 | `/api/miners/0x11..` |
|
||||
| rig2 (2 threads) | 15.4, 40.9 kH/s, 0 | 7.8, 20.4 kH/s, 1 | `/api/miners/0x22..` |
|
||||
| rig3 (2 threads) | 15.1, 40.3 kH/s, 0 | 8.1, 21.5 kH/s, 0 | `/api/miners/0x33..` |
|
||||
| Vardiff changes over 10 min | 0 | 17 (rig3 to shift 1 at 25 s and back at 55 s; rig2 to 1 at 3 min; all at 0 at the end) | pool log `VARDIFF` lines |
|
||||
| Share check cost, ms (mean / p50 / p99 / max) | 2.018 / 2.054 / 2.334 / 5.333 (n 617, under load) | 2.094 / 2.096 / 2.241 / 5.785 (n 317, under load) | `/api/stats` pool.share_check_ms, `Instant` around `hash_bound` |
|
||||
| Share check cost in isolation, under the `measure` lock (every build and run slot held), 500 checks | 1.347 mean / 1.313 p50 / 2.074 p99 / 2.638 max ms | same binary | `cargo test --release share_check_cost_ms -- --nocapture` in `pool/`, `SHARE_CHECK_MS` line |
|
||||
| Pool hashrate from shares | 168 kH/s | 89 kH/s (miners' own STATUS: 0.088 + 0.043 + 0.043 = 0.174 MH/s in run 1; 0.061 + 0.030 + 0.030 = 0.121 MH/s in run 2, which the carrier's 8 threads depressed) | `/api/stats`, miner STATUS `now=` |
|
||||
| Blocks found / confirmed / orphaned | 604 / 604 / 0 | 289 / 288 / 0 (one pending at the end) | `/api/stats` pool.blocks_* |
|
||||
| Orphan rate | 0 of 604 | 0 of 289 at 49.1% of the network's 589 blocks (the carrier found 288 in the same window, 444 over its run) | `ORPHAN` lines, `/api/blocks` |
|
||||
| Pool's share of network blocks | 100% | 49.1% | pool.blocks_total / network.block_count |
|
||||
| First block: found to confirmed | | 3.7 s (found 21:20:24.953, confirmed 21:20:28.678, daa 3, effort 1.00, one payee at fraction 1) | `/api/blocks` first_confirmed_block |
|
||||
| Reward per block credited | 2.5351 IGN (80% of the ramp's 3.169 IGN at day 0) | same | `block_subsidy(daa, 1)` x 80% |
|
||||
| PPLNS in dry run | 60 payment records computed, nothing sent, balances kept: 792.14 / 365.30 / 360.01 IGN after 600 s | 59 records; 389.16 / 164.54 / 169.85 IGN | `/api/payments` dry_run, `/api/miners` balance_ign |
|
||||
| Pool fee kept | 15.33 IGN of 1,532.77 (1.00%) | 7.31 of 733.39 (1.00%) | pool.pool_fee_total_ign |
|
||||
| Real payout, 240 s: transfers sent / confirmed / failed | 24 / 18 / 0 (6 sent, receipt not yet read at the end) | 24 / 18 / 0 | `/api/payments` status from `eth_getTransactionReceipt` |
|
||||
| Pool EVM balance before / after | 1,532.77 / 180.18 IGN | 733.39 / 111.12 IGN | `eth_getBalance` on the private node |
|
||||
| Miners' EVM balances received | 1,026.10 / 491.09 / 473.22 IGN | 481.99 / 208.06 / 252.36 IGN | `eth_getBalance` before and after |
|
||||
| API at 100 requests per second for 30 s | 3,001 sent, 3,001 ok, 0 errors, p50 0.78 ms, p90 0.97, p99 1.17, max 5.69 | 3,001 / 3,001 / 0, p50 0.85, p90 1.05, p99 2.76, max 11.98 | `fetch()` from Node 22 on the same machine, four endpoints round-robin |
|
||||
| Member checks | 0 jobs refused, 0 CPU mismatches, 16 epoch seed changes followed (60-DAA epochs) by every miner, seed check against its own node passed each time | same | miner logs `POOL SEEDS`, STATUS `refused=` |
|
||||
| Votes | every miner voted through its own node as in solo mode (`VOTE` lines in the miner logs); the pool held no key | same | miner logs |
|
||||
|
||||
What is unusual about this network and why it does not change the reading: at `genesis_bits 0x1e400000` a block is
|
||||
2^18 expected hashes, so a share worth 2^20 hashes (the vardiff's initial size) is harder than a block; the shift sat
|
||||
at 0 and every share was a block. That is why blocks found equals shares accepted less the few found by two miners on
|
||||
the same tip. Vardiff still ran (17 changes in run 2 when the carrier slowed the rigs) and the saturation cap held.
|
||||
On a GPU network (devnet difficulty 0x1d100000, about 2^28 hashes a block, and more as cards join) shares sit far
|
||||
below blocks and the shift moves into its working range; that run needs a GPU worker and is unverified (section 8).
|
||||
|
||||
Consequences, by tier (the standing rule of 5 October 2026):
|
||||
|
||||
| Tier | What the numbers mean | What is done about it |
|
||||
|---|---|---|
|
||||
| A pool operator on one Hetzner box (cx23 2 vCPU or cpx31 4 vCPU) | 1.35 ms per share check in isolation and 2.1 ms under load on an M-series core (the crate's tight-loop bench is 0.44 ms per warp; the check adds the init words and the engine's shared cache path) is one core for 480 to 740 shares a second, which at one share per 10 s per member is 4,800 to 22,700 members per core; the API at 100 rps costs under 3 ms p99, and WhatToMine polls once a minute. The cost that scales first is the template per member per second (section 3): at 1,000 members that is 1,000 `getBlockTemplate` calls a second on the box's node | `--verify-threads` sizes the check pool; the template RPC that takes a list of keys is the planned fix before a thousand members |
|
||||
| A home miner on one card (8, 12, 16, 24 or 32 GB, NVIDIA, AMD or Apple, any OS) | pool mode changes nothing on the card: the same worker, the same 256 MiB cache plus program, the pool's share target in the job's target field. Income: 80% of each block over the PPLNS window less 1%, paid when the balance passes 1 IGN; at 2.5 IGN a block (ramp day 0) a miner holding 1% of a pool that finds a block a minute is paid about every 40 minutes, and a miner at 0.01% of a large pool every 3 days. A miner that also proves sends about 4% fewer shares and the page says so | the `--proving` flag and the Proving column; `--min-payout` is the operator's lever for small miners |
|
||||
| A rig (several cards) | one `igneum-miner --pool` per card, one vote key per rig (`IDENTITIES` is ignored in pool mode), the worker name labels each card on the page | the Hive hooks' `pool://` mode |
|
||||
| A pool user without a node of their own | hashes and is paid, does not vote and does not check the pool's seeds (spec 9.7 item 2); their finality weight still accrues to their own key through the blocks the pool finds under it | `NODE=local` in the Flight Sheet runs the bundled node as the verifier |
|
||||
| The network | pool concentration does not become vote concentration: every block carried the finding member's key, 3 keys under one payout address in both runs (F10, O-9.1) | the explorer's keys-per-payout-address count is the public check |
|
||||
|
||||
Harness note: run 1's node "answered" nine minutes after it started because the harness waited for `igneum-miner watch 1`
|
||||
with an 8 s timeout, and `watch 1` takes one sample, sleeps 10 s and then prints: every attempt was killed and the loop ran
|
||||
out. Fixed in `measure.mjs` (15 s timeout). The same shape elsewhere: `packaging/hive/h-run.sh` runs `watch 1` with no
|
||||
timeout (fine); no other script in `tools/` wraps `watch` in a timeout under 11 s (grep, 5 October 2026).
|
||||
|
||||
## 6. Listing checklist: WhatToMine's form against this pool
|
||||
|
||||
WhatToMine's "add a coin" form (as the project lead read it on 5 October 2026 and `docs/plans/explorer.md` summarises: an
|
||||
explorer or pool with an API, the block reward and block time sources, a source for total coins) and what
|
||||
MiningPoolStats asks of a pool (hashrate, miners, workers, blocks with heights and times, fee, minimum payout, luck):
|
||||
|
||||
| They ask for | Where it is | State |
|
||||
|---|---|---|
|
||||
| Algorithm name | `igneum` (`/api/stats` pool.algorithm, network.name) | ready |
|
||||
| Network hashrate and difficulty | `/api/stats` network.hashrate, network.difficulty; also the site's `/api/stats` | ready |
|
||||
| Block reward and block time | `/api/stats` network.block_reward_ign, miner_reward_ign (80%), block_time_target_s 1; the site's `/api/stats` block_reward with the halving table | ready |
|
||||
| Total and circulating supply | the site's `/api/supply` | ready (explorer branch) |
|
||||
| An explorer | the site's `/explorer`, `/block/<hash>`, `/address/<addr>` | ready (explorer branch) |
|
||||
| A pool with an API | this pool: `/api/stats`, `/api/blocks`, `/api/miners/<address>`, `/api/payments`, `/api/pool-stats` | ready, not deployed |
|
||||
| Pool hashrate, miners, workers | `/api/stats` pool.hashrate (10 min), pool.miners, pool.workers | ready |
|
||||
| Blocks found with heights and times | `/api/blocks`: DAA score, blue score, ISO time, status | ready |
|
||||
| Fee, minimum payout, scheme | `/api/stats` pool.fee_percent, pool.min_payout_ign, pool.scheme PPLNS | ready |
|
||||
| Luck | `/api/stats` pool.luck_24h, pool.effort_current | ready, definition in section 3 |
|
||||
| A public pool address and page | `--public-url`, the page at `/` | needs a box and a hostname (pool.igneum.network is held) |
|
||||
| Exchange or price | none; nothing here is a token sale | not applicable |
|
||||
| Mining software | igneum-miner with `--pool`; HiveOS custom miner package with `pool://` | ready, HiveOS untested on a rig |
|
||||
|
||||
What is still missing for a listing: a deployed pool with a hostname and TLS on the page, a public testnet for it to
|
||||
mine, and the explorer branch merged so `/api/supply` is live. In that order.
|
||||
|
||||
## 7. App: "Mine to a pool" (design only)
|
||||
|
||||
Settings, under the rewards address field (`app/igneum-app/ui/index.html`, the `#settings` panel), one new field:
|
||||
|
||||
```
|
||||
<div class="field">
|
||||
<div class="k">mine to a pool</div>
|
||||
<label class="switch"><input type="checkbox" id="s-pool"><span class="track"></span><span>Mine to a pool instead of solo</span></label>
|
||||
<div class="row"><input type="text" class="addr-input mono" id="s-pool-url" placeholder="pool host:4463" spellcheck="false"><button class="btn small" id="s-pool-save">Apply</button></div>
|
||||
<p class="note" id="s-pool-note">Your vote key stays on this machine and the pool names it in every block you find. The pool pays your rewards address by its own rule and fee (shown here once connected). The software dev fee is off in pool mode. Your node keeps running as the verifier.</p>
|
||||
<div class="box mono" id="s-pool-terms" hidden></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Engine side (`config.rs` `Settings`): `pool_url: String` (empty = solo). `engine.rs` passes `--pool <url>
|
||||
--worker-name <display_name>-gpuN [--proving]` to each card's miner instead of `--identities`/`--dev-fee`, keeps the
|
||||
node as the first argument, and shows the pool's `welcome` terms (fee, scheme, minimum payout) in `#s-pool-terms`
|
||||
from the miner's `POOL WELCOME` line. The dashboard's "found" counters become "shares accepted" and "blocks credited"
|
||||
in pool mode. Not implemented tonight.
|
||||
|
||||
## 8. Unverified
|
||||
|
||||
- The GPU worker path in pool mode (`--worker`): written against the worker protocol and the solo miner's re-check,
|
||||
not run on a card tonight (the measurement used CPU threads). The CUDA and OpenCL workers take the share target in
|
||||
the job line's target field unchanged, so nothing on their side changes.
|
||||
- HiveOS hooks in pool mode: `h-config.sh` exercised by hand in both modes (pool and solo), `h-run.sh` syntax-checked;
|
||||
never run on a Hive rig (the package as a whole is untested there, `packaging/hive/README.md`).
|
||||
- Testnet and mainnet ports and chain ids: set from the design, not run.
|
||||
- The Hetzner deploy in `pool/README.md`: written from the seed-node scripts, not executed.
|
||||
- The exact reward per block the execution layer credits against the pool's expected figure: the pool computes
|
||||
`producer_share(block_subsidy(block daa))`; the executor uses the merging chain block's subsidy; equal on the
|
||||
private run (section 5), may differ by the ramp slope across a day on a live chain. The pool's EVM balance is the
|
||||
truth and the page shows both.
|
||||
|
||||
## 9. 6 October 2026: the rebase onto 0.3.14 and the program class
|
||||
|
||||
The fleet agent's load run (18:14Z to 18:24Z, ten GPU members, vardiff working): 0 shares accepted in ten minutes. Every
|
||||
member's log: `WORKER MISMATCH nonce=... gpu=... cpu=...: share not sent`. The pool fork's miner re-checked every GPU
|
||||
share with its own CPU hasher, and that hasher (`release-0.3.6`'s kaspa-pow) predates program class v3: it hashed a fixed
|
||||
class v2 program while the 0.3.13 and 0.3.14 CUDA workers hashed the epoch's class v3 program, so the two never agreed and
|
||||
the member refused every share before it left the machine. Everything else in the pool worked.
|
||||
|
||||
What changed (branch `pool-v0-rebase` on master, fork `pool-v0-rebase` on `release-0.3.14-node` 4c6b129d):
|
||||
|
||||
| Where | Change | Forced by the rebase? |
|
||||
|---|---|---|
|
||||
| Fork `igneum/miner/src/pool.rs` | `EpochSeeds` carries `class` and `era` on 0.3.14; the member builds them from the job's `program_class` and `era_seed` (`job_seeds`), refuses a job without a class or with one this fork does not know (`job_refused`, code `seeds`), checks class and era against its own node's template beside the epoch seed (`seeds_agree`, through `crate::template`, which installs the node's schedule, genesis day and class v3 activation), installs the same three from the pool's `seeds` line when it has no node (`install_from_pool_line`), and ends every worker `job` and `prepare` line with the class tokens (`class=v3 era=<hex>`); `prepare` writes a checked pack under `--prepare-packs` as the solo miner does; a worker `error ... program class mismatch` gets a fresh prepare | yes (the struct changed) and the fix itself |
|
||||
| Fork `igneum/miner/src/main.rs` | `class_tokens`, `write_pack_checked`, `seeds_from_info` are `pub(crate)` | yes |
|
||||
| Pool `node.rs` | seeds from the template with the class and the era; the node's genesis day, dataset size and class v3 activation installed beside the schedule; `seeds` and `job` carry the new fields | yes (the struct changed) |
|
||||
| Pool `protocol.rs` | `Seeds` + 7 fields, `Job` + 2 fields (additive: a member ignores unknown fields, so the line still parses on the old build; the old build then hashes class v2 as before and keeps mismatching, so every member moves to this build) | yes |
|
||||
| Pool `verify.rs` | the tests build seeds through `EpochSeeds::v2` | yes |
|
||||
| Protocol, vardiff, PPLNS, stats API, page | unchanged | |
|
||||
| `packaging/hive` | the pool:// mode merged with master's per-card `IDENTITIES=auto` and `OVERRIDE` handling; `selftest.sh` passes, the pool-mode config exercised by hand (NODE=local, OVERRIDE set) | merge only |
|
||||
|
||||
Tests (every suite on igneum-build-1, `IGNEUM_AGENT=pool`):
|
||||
|
||||
| Suite | Result | What it pins |
|
||||
|---|---|---|
|
||||
| `igneum-pow` `tests/recheck.rs` | 2 of 2 | class v3 (`packs-ca2-mixer/mx8-devnet-epoch0`) and class v4 (`packs-ca3-v4/v4-devnet-epoch0`): the pack's program (generator, id) reproduces the pack's 96 vectors (the worker's reference), the chain seam the engine calls (`Epoch::chain_program`, `chain_dataset_day`) builds the same program, and one known nonce (prehash 0x5a..., nonce 0x12345678_00000bb7) hashes equal through both paths for each class; v3 and v4 differ from each other and from the fixed-v2 program of the same seed (the fleet's mismatch); the v3 id 73bcbfe8ccf988f1 and v4 id c120d7963abdcd96 as pinned |
|
||||
| fork `igneum-miner` | 21 of 21 | `pool::tests::cpu_recheck_equals_the_worker_reference_for_class_v3`: a job line of the pool's shape through `job_seeds`, then the real `IgneumEngine::epoch_for(seeds).hash_bound` against the pack's reference on the known nonce, the v2 re-check shown to disagree, the worker tokens checked; `a_class_v4_job_is_refused_until_the_fork_knows_class_v4`: this fork's `ProgramClass` has no V4, so a class 4 job is refused rather than hashed on a guess, and the test FAILS the day V4 lands (ca3-v4-node), which is when it becomes the v3 test's shape on the v4 pack; `pool_seeds_are_checked_against_the_verifier_class_and_era` |
|
||||
| pool `igneum-pool` | 22 of 22 | unchanged suites on the new seeds |
|
||||
|
||||
Consequences per tier: a pool member on any card hashes the program its pool's node names, so the fix costs no hash rate
|
||||
and no memory on any tier (the day cache size is the node's, 1 GiB on the devnet); a member without a verifier node now
|
||||
gets the genesis day and dataset size from the pool's `seeds` line, which it had no source for before (it would have built
|
||||
a wrong-size cache and mismatched silently); a pool on a class v4 network needs a fork with `ProgramClass::V4`
|
||||
(0.3.15, ca3-v4-node); until then its members refuse class 4 jobs loudly (`JOB ... REFUSED (seeds)`), never hash on a guess.
|
||||
|
||||
Building the pool crate on igneum-build-1: the pool reads the fork through the `vendor/igneum-node` symlink; lib.sh syncs
|
||||
the fork worktree it points at (`vendor/igneum-node-pr`) as a whole repository, and the symlink itself is created once on
|
||||
the box by hand (`ln -s igneum-node-pr /srv/builds/<worktree>/vendor/igneum-node`), the one step the scripts do not do.
|
||||
137
igneum-pow/tests/recheck.rs
Normal file
137
igneum-pow/tests/recheck.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
//! The miner's CPU re-check against the worker's reference path, class v3 and class v4 (6 October 2026).
|
||||
//!
|
||||
//! The fleet's 10-member pool run (18:14Z to 18:24Z): 0 shares accepted in ten minutes, every GPU answer refused as
|
||||
//! `WORKER MISMATCH`, because the pool fork's miner re-hashed each share with a fixed class v2 program while the 0.3.14
|
||||
//! CUDA worker hashed the class v3 program of the epoch. The rule since: the re-check builds its program through the
|
||||
//! chain seam (`Epoch::chain_program` and `Epoch::chain_dataset_day`, what the node's `IgneumEngine` calls) from the
|
||||
//! template's `(epoch seed, day, program class, era seed)`, never from a fixed class.
|
||||
//!
|
||||
//! This test pins that seam against the worker's reference for both classes the packs carry: the program the pack's
|
||||
//! kernels were emitted from (program.json, generator 3 or 4, with its id) and the pack's 96 vectors; then one known
|
||||
//! nonce through the worker's path (`block_init_words` plus `interpret_warp_init` on the pack's program) and through
|
||||
//! the re-check's path (the same on the seam's program and dataset) must agree, and the other class's program and
|
||||
//! the fixed-v2 program of the same seed must disagree (the mismatch the fleet saw).
|
||||
|
||||
use igneum_pow::bind::{block_init_words, lane_nonce, unhex};
|
||||
use igneum_pow::verify::DatasetSource;
|
||||
use igneum_pow::{interpret_warp_init, DatasetMode, Epoch, ProgramClass, Shape};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn pack_dir(rel: &str) -> PathBuf {
|
||||
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../proto-cuda").join(rel);
|
||||
assert!(p.join("program.json").is_file(), "pack {} is missing; this test never skips", p.display());
|
||||
p
|
||||
}
|
||||
|
||||
fn json(dir: &std::path::Path, f: &str) -> Value {
|
||||
serde_json::from_str(&std::fs::read_to_string(dir.join(f)).unwrap_or_else(|e| panic!("{}: {e}", dir.join(f).display()))).unwrap()
|
||||
}
|
||||
|
||||
fn hex64(v: &Value) -> u64 {
|
||||
u64::from_str_radix(v.as_str().unwrap().trim_start_matches("0x"), 16).unwrap()
|
||||
}
|
||||
|
||||
struct PackRef {
|
||||
class: ProgramClass,
|
||||
seed_label: String,
|
||||
seed_bytes: Vec<u8>,
|
||||
era: Vec<u8>,
|
||||
day_bytes: Vec<u8>,
|
||||
log2: u32,
|
||||
/// The worker's reference: the pack's program and day dataset, checked against the pack's vectors
|
||||
reference: Epoch,
|
||||
}
|
||||
|
||||
/// The pack as the worker sees it: program.json's program (generator, id and class as recorded) over the pack's
|
||||
/// day dataset, trusted only after its 96 vectors reproduce.
|
||||
fn load(rel: &str, want: ProgramClass) -> PackRef {
|
||||
let dir = pack_dir(rel);
|
||||
let j = json(&dir, "program.json");
|
||||
let class = match j["program_class"].as_str().unwrap() {
|
||||
"v3" => ProgramClass::V3,
|
||||
"v4" => ProgramClass::V4,
|
||||
other => panic!("{rel}: class {other}"),
|
||||
};
|
||||
assert_eq!(class, want, "{rel}: the pack's class");
|
||||
let seed_label = j["seed"].as_str().unwrap().to_string();
|
||||
let seed_bytes = unhex(j["seed_bytes"].as_str().unwrap()).unwrap();
|
||||
let era = unhex(j["era_seed_bytes"].as_str().unwrap()).unwrap();
|
||||
let day_bytes = unhex(j["dataset"]["day_bytes"].as_str().unwrap()).unwrap();
|
||||
let log2 = j["dataset"]["log2_words"].as_u64().unwrap() as u32;
|
||||
let program = igneum_pow::generate_from_seed_bytes_program_class(&seed_label, &seed_bytes, class, Some(&era));
|
||||
assert_eq!(program.generator, j["generator"].as_u64().unwrap() as u32, "{rel}: generator");
|
||||
assert_eq!(program.generator, class.generator_version(), "{rel}: the generator is the class's");
|
||||
assert_eq!(program.program_id(), hex64(&j["program_id"]), "{rel}: program id");
|
||||
let shape = Shape::for_class(&program.class);
|
||||
let mut dataset = DatasetSource::from_key_shape(igneum_pow::seed::seed_words_from_bytes(&day_bytes), DatasetMode::MemoryHard, log2, shape);
|
||||
dataset.key_bytes = day_bytes.clone();
|
||||
let reference = Epoch { program, dataset };
|
||||
let v = json(&dir, "vectors.json");
|
||||
let warps = v["warps"].as_array().unwrap();
|
||||
assert_eq!(warps.len(), 3, "{rel}: three vector warps");
|
||||
for w in warps {
|
||||
let base = w["base_nonce"].as_u64().unwrap() as u32;
|
||||
let got = reference.hash_warp(base);
|
||||
for (lane, e) in w["expected"].as_array().unwrap().iter().enumerate() {
|
||||
assert_eq!(got[lane], hex64(e), "{rel}: vector base {base} lane {lane}");
|
||||
}
|
||||
}
|
||||
PackRef { class, seed_label, seed_bytes, era, day_bytes, log2, reference }
|
||||
}
|
||||
|
||||
/// The re-check's epoch: the chain seam the node engine and the miner call, from the template's four values.
|
||||
/// The packs are day-0 caches, so days since genesis is 0 and the genesis dataset size is the pack's.
|
||||
fn recheck_epoch(p: &PackRef) -> Epoch {
|
||||
Epoch { program: Epoch::chain_program(&p.seed_bytes, Some(&p.era), p.class, &p.seed_label), dataset: Epoch::chain_dataset_day(&p.day_bytes, p.class, 0, p.log2) }
|
||||
}
|
||||
|
||||
const PREHASH: [u8; 32] = [0x5a; 32];
|
||||
const NONCE: u64 = 0x1234_5678_0000_0bb7; // high word 0x12345678, lane 0x0bb7 (warp base 0x0ba0, lane 23)
|
||||
|
||||
/// The bound lane hash of one nonce on an epoch, the way a worker computes it (init words from the prehash and the
|
||||
/// nonce's high word, the warp at the lane's base) and the way `EpochRef::hash_bound` re-checks it.
|
||||
fn bound(e: &Epoch, prehash: &[u8; 32], nonce: u64) -> u64 {
|
||||
let init = block_init_words(prehash, nonce);
|
||||
let lane = lane_nonce(nonce);
|
||||
interpret_warp_init(&e.program, &init, lane & !31, &e.dataset).hashes[(lane & 31) as usize]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_recheck_equals_the_worker_reference_for_class_v3_and_class_v4() {
|
||||
let v3 = load("packs-ca2-mixer/mx8-devnet-epoch0", ProgramClass::V3);
|
||||
let v4 = load("packs-ca3-v4/v4-devnet-epoch0", ProgramClass::V4);
|
||||
assert_eq!(v3.seed_bytes, v4.seed_bytes, "the two packs share the epoch seed (devnet genesis), so only the class differs");
|
||||
assert_eq!(v3.era, v4.era);
|
||||
let mut hashes = Vec::new();
|
||||
for p in [&v3, &v4] {
|
||||
let re = recheck_epoch(p);
|
||||
assert_eq!(re.program.program_id(), p.reference.program.program_id(), "{:?}: the seam's program is the pack's", p.class);
|
||||
assert_eq!(re.program.generator, p.reference.program.generator);
|
||||
assert_eq!(re.program.instrs.len(), p.reference.program.instrs.len());
|
||||
let worker = bound(&p.reference, &PREHASH, NONCE);
|
||||
let cpu = bound(&re, &PREHASH, NONCE);
|
||||
assert_eq!(cpu, worker, "{:?}: CPU re-check {cpu:016x} against the worker's reference {worker:016x}", p.class);
|
||||
// the same nonce through a second prehash, so the agreement is not one lucky lane
|
||||
let (w2, c2) = (bound(&p.reference, &[0xa5; 32], NONCE ^ 0x1f), bound(&re, &[0xa5; 32], NONCE ^ 0x1f));
|
||||
assert_eq!(c2, w2, "{:?}: second nonce", p.class);
|
||||
hashes.push(cpu);
|
||||
}
|
||||
assert_ne!(hashes[0], hashes[1], "class v3 and class v4 programs of one seed hash a nonce differently");
|
||||
// the fleet's mismatch: a fixed class v2 program on the class v3 epoch's seed
|
||||
let v2 = Epoch { program: Epoch::chain_program(&v3.seed_bytes, None, ProgramClass::V2, &v3.seed_label), dataset: Epoch::chain_dataset_day(&v3.day_bytes, ProgramClass::V2, 0, v3.log2) };
|
||||
assert_ne!(bound(&v2, &PREHASH, NONCE), hashes[0], "a class v2 re-check refuses a class v3 share");
|
||||
assert_ne!(bound(&v2, &PREHASH, NONCE), hashes[1], "a class v2 re-check refuses a class v4 share");
|
||||
}
|
||||
|
||||
/// The generator-4 program id rule (counter-asic-3-status.md, the blocker closed 6 October 2026): the v4 program of
|
||||
/// a seed carries another id than the v3 program of the same seed, so a stale worker across the activation sees the
|
||||
/// mismatch, while v2 and v3 ids stay as the packs pinned them.
|
||||
#[test]
|
||||
fn program_ids_differ_between_class_v3_and_class_v4_of_one_seed() {
|
||||
let v3 = load("packs-ca2-mixer/mx8-devnet-epoch0", ProgramClass::V3);
|
||||
let v4 = load("packs-ca3-v4/v4-devnet-epoch0", ProgramClass::V4);
|
||||
assert_eq!(v3.reference.program.program_id(), 0x73bc_bfe8_ccf9_88f1, "the v3 control's id as pinned");
|
||||
assert_eq!(v4.reference.program.program_id(), 0xc120_d796_3abd_cd96, "the v4 candidate's id as pinned");
|
||||
assert_ne!(v3.reference.program.program_id(), v4.reference.program.program_id());
|
||||
}
|
||||
168
infra/build-server/hands/install-hands.sh
Executable file
168
infra/build-server/hands/install-hands.sh
Executable file
|
|
@ -0,0 +1,168 @@
|
|||
#!/usr/bin/env bash
|
||||
# Install the two devnet hands (node 1 and the observer node) and the observer process on igneum-build-1 as systemd units.
|
||||
# the project lead's decision of 6 October 2026: the Mac runs nothing the network depends on. Run over ssh as root, idempotent, and
|
||||
# INERT: it writes users, directories, run scripts and units and enables them, but starts nothing; infra/build-server/move-hand.sh
|
||||
# on the Mac moves one hand at a time (data dir, binary, env, then start) so one hand always serves. Plan: docs/plans/hands-on-build-1.md.
|
||||
#
|
||||
# ssh root@<ip> 'bash -s' < infra/build-server/hands/install-hands.sh
|
||||
#
|
||||
# Layout (all owned by user build, the same uid the builds use; separation is by unit, not by uid):
|
||||
# /srv/hands/bin/igneumd-<version>-<commit> the Linux igneumd from the box's own build (move-hand.sh copies it from /srv/builds)
|
||||
# /srv/hands/hands.env IGNEUMD (binary path), OVERRIDE, SNAPSHOT (path,sha256), EXTERNAL_IP, PEERS
|
||||
# /srv/hands/override.json the consensus override object (the Mac's /tmp/igneum-devnet/override-v3.json)
|
||||
# /srv/hands/node1, /srv/hands/observer-node the data dirs (--appdir), rsynced from the Mac's /tmp/igneum-devnet/{node1,observer-v4}
|
||||
# /srv/hands/node1-copy-snapshot.bin the exec snapshot both nodes may resume from (--igneum-exec-snapshot)
|
||||
# /srv/observer/igneum a clone of /srv/igneum.git (master) for tools/observer and site/lib
|
||||
# /srv/observer/env DATABASE_URL and the observer's variables, mode 600 (copied by move-hand.sh, never in the repo)
|
||||
# Units: igneum-node1.service, igneum-observer-node.service (igneumd, Restart=always, journald, SyslogIdentifier igneumd-node1 /
|
||||
# igneumd-observer), igneum-observer.service (node tools/observer/observer.mjs, Restart=always), igneum-observer-sync.timer
|
||||
# (every 5 min: fast-forward the clone from the mirror and restart the observer when tools/observer or site/lib changed, the
|
||||
# box's version of tools/observer/autosync.sh).
|
||||
# Ports (docs/plans/hands-on-build-1.md section 3): node 1 p2p 0.0.0.0:26611 (ufw open), gRPC 127.0.0.1:26610, EVM 127.0.0.1:26791;
|
||||
# observer node p2p 127.0.0.1:26641, gRPC 127.0.0.1:26640, wRPC JSON 127.0.0.1:28640, EVM 127.0.0.1:26840. Nothing new in ufw.
|
||||
set -euo pipefail
|
||||
U=build; H=/srv/hands; O=/srv/observer
|
||||
log() { printf '%s install-hands: %s\n' "$(date -u +%H:%M:%S)" "$*"; }
|
||||
[ "$(id -u)" = 0 ] || { echo "run as root" >&2; exit 1; }
|
||||
id -u $U >/dev/null 2>&1 || { echo "user $U missing: run infra/build-server/provision.sh first" >&2; exit 1; }
|
||||
[ -x /usr/local/bin/node ] || { echo "no /usr/local/bin/node: provision.sh installs Node 22" >&2; exit 1; }
|
||||
|
||||
for d in $H $H/bin $H/node1 $H/observer-node $O; do [ -d $d ] || install -d -m 755 -o $U -g $U $d; done
|
||||
if [ ! -d $O/igneum/.git ]; then su - $U -c "git clone -q /srv/igneum.git $O/igneum"; log "cloned /srv/igneum.git to $O/igneum"; else log "clone ok"; fi
|
||||
[ -f $H/hands.env ] || { cat > $H/hands.env <<'ENV'
|
||||
# igneum-build-1 hands (infra/build-server/hands/install-hands.sh). move-hand.sh rewrites IGNEUMD, SNAPSHOT and OVERRIDE.
|
||||
IGNEUMD=/srv/hands/bin/igneumd
|
||||
OVERRIDE=/srv/hands/override.json
|
||||
SNAPSHOT=
|
||||
EXTERNAL_IP=188.40.146.49
|
||||
# peers beside the seeds: the live seed and (until it moves) nothing else; the Mac's LAN peer 192.168.68.67 is unreachable from here
|
||||
PEERS=188.245.5.161:26611
|
||||
ENV
|
||||
chown $U:$U $H/hands.env; log "wrote $H/hands.env"; }
|
||||
|
||||
cat > $H/bin/run-node1.sh <<'RUN'
|
||||
#!/usr/bin/env bash
|
||||
# node 1 on igneum-build-1: the flags of the Mac's launchd agent network.igneum.devnet.node1 (6 Oct 2026), RPC on loopback,
|
||||
# --externalip so peers learn this address. Edit /srv/hands/hands.env, never this file.
|
||||
set -euo pipefail
|
||||
. /srv/hands/hands.env
|
||||
args=(--devnet --nodnsseed --disable-upnp --enable-unsynced-mining --appdir=/srv/hands/node1
|
||||
--rpclisten=127.0.0.1:26610 --rpclisten-json=127.0.0.1:28610 --listen=0.0.0.0:26611 --externalip="$EXTERNAL_IP" --evm-rpclisten=127.0.0.1:26791
|
||||
--override-params-file="$OVERRIDE" --nologfiles --yes --maxinpeers=128 --outpeers=8)
|
||||
IFS=',' read -r -a peers <<< "${PEERS:-}"; for p in "${peers[@]}"; do [ -n "$p" ] && args+=(--addpeer="$p"); done
|
||||
[ -n "${SNAPSHOT:-}" ] && args+=(--igneum-exec-snapshot="$SNAPSHOT")
|
||||
[ -n "${EXTRA_ARGS_NODE1:-}" ] && args+=($EXTRA_ARGS_NODE1)
|
||||
exec "$IGNEUMD" "${args[@]}"
|
||||
RUN
|
||||
cat > $H/bin/run-observer-node.sh <<'RUN'
|
||||
#!/usr/bin/env bash
|
||||
# the observer's own non-mining node on igneum-build-1: the Mac's network.igneum.devnet.observer flags plus an EVM listener
|
||||
# on 26840 for the observer's proving feed (the Mac used the Miner app's node for that). Peers: node 1 on this box, the seed.
|
||||
set -euo pipefail
|
||||
. /srv/hands/hands.env
|
||||
args=(--devnet --nodnsseed --disable-upnp --appdir=/srv/hands/observer-node
|
||||
--rpclisten=127.0.0.1:26640 --rpclisten-json=127.0.0.1:28640 --listen=127.0.0.1:26641 --evm-rpclisten=127.0.0.1:26840
|
||||
--addpeer=127.0.0.1:26611 --override-params-file="$OVERRIDE" --nologfiles --yes)
|
||||
IFS=',' read -r -a peers <<< "${PEERS:-}"; for p in "${peers[@]}"; do [ -n "$p" ] && args+=(--addpeer="$p"); done
|
||||
[ -n "${SNAPSHOT:-}" ] && args+=(--igneum-exec-snapshot="$SNAPSHOT")
|
||||
[ -n "${EXTRA_ARGS_OBSERVER:-}" ] && args+=($EXTRA_ARGS_OBSERVER)
|
||||
exec "$IGNEUMD" "${args[@]}"
|
||||
RUN
|
||||
cat > $H/bin/observer-sync.sh <<'RUN'
|
||||
#!/usr/bin/env bash
|
||||
# the box's tools/observer/autosync.sh: fast-forward /srv/observer/igneum from the mirror /srv/igneum.git (fed by every
|
||||
# build-remote.sh and run-from-mac.sh push from the Mac), restart igneum-observer when tools/observer or site/lib changed.
|
||||
set -uo pipefail
|
||||
cd /srv/observer/igneum || exit 1
|
||||
before=$(git rev-parse HEAD:tools/observer HEAD:site/lib 2>/dev/null | tr '\n' ' ')
|
||||
su - build -c "git -C /srv/observer/igneum pull -q --ff-only origin master" 2>&1 | head -2
|
||||
after=$(git rev-parse HEAD:tools/observer HEAD:site/lib 2>/dev/null | tr '\n' ' ')
|
||||
if [ "$before" != "$after" ]; then echo "observer files changed ($(git rev-parse --short HEAD)); restarting igneum-observer"; systemctl restart igneum-observer; else echo "observer up to date at $(git rev-parse --short HEAD)"; fi
|
||||
RUN
|
||||
chmod 755 $H/bin/*.sh; chown $U:$U $H/bin/*.sh
|
||||
|
||||
unit() { local f="/etc/systemd/system/$1" tmp; tmp=$(mktemp); cat > "$tmp"; if ! cmp -s "$tmp" "$f" 2>/dev/null; then install -m 644 "$tmp" "$f"; echo "$1: written"; else echo "$1: ok"; fi; rm -f "$tmp"; }
|
||||
unit igneum-node1.service <<UNIT
|
||||
[Unit]
|
||||
Description=Igneum devnet hand: node 1 (igneumd, p2p open, RPC on loopback)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$U
|
||||
Group=$U
|
||||
ExecStart=$H/bin/run-node1.sh
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
LimitNOFILE=1048576
|
||||
MemoryMax=24G
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=igneumd-node1
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
unit igneum-observer-node.service <<UNIT
|
||||
[Unit]
|
||||
Description=Igneum devnet hand: the observer's node (igneumd, loopback only, wRPC JSON 28640, EVM 26840)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$U
|
||||
Group=$U
|
||||
ExecStart=$H/bin/run-observer-node.sh
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
LimitNOFILE=1048576
|
||||
MemoryMax=24G
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=igneumd-observer
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
unit igneum-observer.service <<UNIT
|
||||
[Unit]
|
||||
Description=Igneum devnet observer (tools/observer/observer.mjs -> Neon)
|
||||
After=network-online.target igneum-observer-node.service
|
||||
Wants=network-online.target
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$U
|
||||
Group=$U
|
||||
WorkingDirectory=$O/igneum
|
||||
EnvironmentFile=$O/env
|
||||
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
||||
ExecStart=/usr/local/bin/node tools/observer/observer.mjs
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=igneum-observer
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
unit igneum-observer-sync.service <<UNIT
|
||||
[Unit]
|
||||
Description=Igneum observer source sync from the mirror (one pass)
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=$H/bin/observer-sync.sh
|
||||
UNIT
|
||||
unit igneum-observer-sync.timer <<UNIT
|
||||
[Unit]
|
||||
Description=Igneum observer source sync, every 5 minutes
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=5min
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
UNIT
|
||||
systemctl daemon-reload
|
||||
systemd-analyze verify /etc/systemd/system/igneum-node1.service /etc/systemd/system/igneum-observer-node.service /etc/systemd/system/igneum-observer.service /etc/systemd/system/igneum-observer-sync.service /etc/systemd/system/igneum-observer-sync.timer 2>&1 | grep -v 'Unit is bound to inactive' || true
|
||||
# enabled so they come back after a reboot, started only by move-hand.sh
|
||||
for u in igneum-node1 igneum-observer-node igneum-observer igneum-observer-sync.timer; do systemctl enable -q "$u" 2>/dev/null || true; done
|
||||
log "units: $(for u in igneum-node1 igneum-observer-node igneum-observer igneum-observer-sync.timer; do printf '%s=%s ' "$u" "$(systemctl is-active "$u" 2>/dev/null)"; done)"
|
||||
log "env: $( [ -f $O/env ] && stat -c '%U %a' $O/env || echo 'MISSING (move-hand.sh observer copies it)' ); binary: $( [ -x "$(. $H/hands.env; echo "$IGNEUMD")" ] && echo present || echo 'MISSING (move-hand.sh copies it)' )"
|
||||
log "done (nothing started)"
|
||||
123
infra/build-server/hands/move-hand.sh
Executable file
123
infra/build-server/hands/move-hand.sh
Executable file
|
|
@ -0,0 +1,123 @@
|
|||
#!/usr/bin/env bash
|
||||
# Move one devnet hand from this Mac to igneum-build-1, one at a time so one hand always serves (the project lead, 6 October 2026: the Mac
|
||||
# runs nothing the network depends on). Plan and order: docs/plans/hands-on-build-1.md. Dry run by default; --go executes.
|
||||
#
|
||||
# infra/build-server/hands/move-hand.sh binary --node <fork worktree> build 0.3.15's Linux igneumd on the box, install it to
|
||||
# /srv/hands/bin, write the override and snapshot (step 1)
|
||||
# infra/build-server/hands/move-hand.sh observer-node [--go] hot rsync, stop the Mac's observer node (launchd), final rsync,
|
||||
# start igneum-observer-node, print its first executing line (step 2)
|
||||
# infra/build-server/hands/move-hand.sh observer [--go] copy ~/.config/igneum/env to /srv/observer/env (600), stop the
|
||||
# Mac's run.sh + autosync + observer.mjs, start igneum-observer (step 3)
|
||||
# infra/build-server/hands/move-hand.sh node1 [--go] the same as observer-node for node 1 (step 4)
|
||||
# infra/build-server/hands/move-hand.sh unload [--go] bootout the Mac's two launchd agents for good (step 5, last)
|
||||
# infra/build-server/hands/move-hand.sh status both sides: units, pids, tips, peers
|
||||
#
|
||||
# Needs ~/.config/igneum/build-server (build@<ip>) and the ops key; root ssh to the box for systemctl, scp of the env file and chown.
|
||||
# Nothing here prints a secret: the env file travels by scp and is only ever stat'ed.
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO="$(cd "$HERE/../../.." && pwd)"
|
||||
# shellcheck disable=SC2034
|
||||
BS_TOOL=move-hand
|
||||
# shellcheck source=../lib.sh
|
||||
. "$HERE/../lib.sh"
|
||||
bs_host
|
||||
IP="${BS_HOST#*@}"
|
||||
ROOT_SSH=(ssh -i "$BS_KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new "root@$IP")
|
||||
MAC_OV=/tmp/igneum-devnet/override-v3.json
|
||||
MAC_SNAP=/tmp/igneum-devnet/node1-copy-snapshot.bin
|
||||
MAC_SNAP_SHA=ac101f13576179fd7d7f5e8ee902c9a7b6cc47730e3a3c069f389f0ca46d9221 # the launchd agents' value (6 Oct 2026); recomputed below
|
||||
H=/srv/hands
|
||||
MODE="${1:-}"; shift || true
|
||||
GO=0; NODE_WT=""
|
||||
while [ $# -gt 0 ]; do case "$1" in --go) GO=1; shift ;; --node) NODE_WT="$2"; shift 2 ;; *) bs_die "unknown argument $1" ;; esac; done
|
||||
say() { bs_log "$*"; }
|
||||
run() { if [ "$GO" = 1 ]; then "$@"; else say "DRY RUN: $*"; fi; }
|
||||
rssh() { "${ROOT_SSH[@]}" "$@"; }
|
||||
first_exec_line() { # <unit>: wait up to 180 s for the node's exec line, print it (the proof main wants) and the chain tip
|
||||
local u="$1" line=""
|
||||
for _ in $(seq 1 36); do
|
||||
line=$(rssh "journalctl -u $u --no-pager -o cat --since '5 min ago' | grep -m1 -E 'igneum-exec\] exec (sync: resumed|state loaded)'" 2>/dev/null || true)
|
||||
[ -n "$line" ] && break; sleep 5
|
||||
done
|
||||
if [ -n "$line" ]; then say "$u first executing line: ${line:0:220}"; else say "$u: no exec line in 180 s; last lines:"; rssh "journalctl -u $u --no-pager -o cat -n 5" | sed 's/^/ /'; fi
|
||||
rssh "journalctl -u $u --no-pager -o cat --since '5 min ago' | grep -E 'Accepted [0-9]+ blocks|PoW accepted|IBD|Consensus params digest' | tail -3" | cut -c1-200 | sed 's/^/ /' || true
|
||||
}
|
||||
sync_dir() { # <mac dir> <box dir>: rsync a data dir (hot or final), keeping RocksDB files whole
|
||||
bs_rsync -a --delete --exclude '*.out' "$1/" "$BS_HOST:$2/"
|
||||
}
|
||||
mac_stop_agent() { # <label>: bootout the launchd agent (KeepAlive would restart a killed process), wait for the pid to end
|
||||
local label="$1" pid
|
||||
pid=$(launchctl print "gui/$(id -u)/$label" 2>/dev/null | awk '/^\s*pid = /{ print $3 }' | head -1 || true)
|
||||
launchctl bootout "gui/$(id -u)/$label" 2>/dev/null || true
|
||||
if [ -n "$pid" ]; then while kill -0 "$pid" 2>/dev/null; do sleep 1; done; fi
|
||||
say "launchd $label unloaded (igneumd pid ${pid:-none} ended)"
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
binary)
|
||||
[ -n "$NODE_WT" ] || bs_die "binary needs --node <fork worktree> (the release-0.3.15-node tree)"
|
||||
[ -f "$NODE_WT/Cargo.toml" ] || bs_die "no Cargo.toml in $NODE_WT"
|
||||
ver=$(grep -m1 '^version' "$NODE_WT/Cargo.toml" | sed 's/.*"\(.*\)".*/\1/'); sha=$(git -C "$NODE_WT" rev-parse --short HEAD)
|
||||
say "building igneumd $ver ($sha) on the box from $NODE_WT"
|
||||
out=$(mktemp -d)
|
||||
(cd "$NODE_WT" && IGNEUM_AGENT="${IGNEUM_AGENT:-hands}" "$REPO/tools/build-remote.sh" --out "$out" --artefacts target/release/igneumd -- build --release -p kaspad -p igneum-miner --features kaspad/igneum-pow) || bs_die "the box build failed"
|
||||
bin_sha=$(bs_sha256 "$out/release/igneumd"); rm -rf "$out"
|
||||
# the binary is already on the box; copy it there, never back and forth
|
||||
ctx=$(cd "$NODE_WT" && BS_TOOL=move-hand bash -c '. "$0"; bs_host >/dev/null; bs_context; echo "$BS_REMOTE_CRATE"' "$HERE/../lib.sh")
|
||||
run rssh "install -m 755 -o build -g build '$ctx/target/release/igneumd' '$H/bin/igneumd-$ver-$sha' && ln -sfn '$H/bin/igneumd-$ver-$sha' '$H/bin/igneumd' && sha256sum '$H/bin/igneumd-$ver-$sha' | cut -c1-16 && '$H/bin/igneumd' --version"
|
||||
say "box binary sha256 $bin_sha; checking its commit string on the box"
|
||||
run rssh "[ \$(strings '$H/bin/igneumd' | grep -c '$sha') -gt 0 ] && echo 'commit $sha in the binary' || { echo 'NO commit string in the binary'; exit 1; }"
|
||||
[ -f "$MAC_OV" ] || bs_die "no override file at $MAC_OV"
|
||||
say "override: $(cat "$MAC_OV" | cut -c1-200)"
|
||||
run bs_rsync -p "$MAC_OV" "$BS_HOST:$H/override.json"
|
||||
if [ -f "$MAC_SNAP" ]; then
|
||||
snap_sha=$(bs_sha256 "$MAC_SNAP"); [ "$snap_sha" = "$MAC_SNAP_SHA" ] || say "WARNING: snapshot sha256 is $snap_sha, the agents say $MAC_SNAP_SHA; the box gets the file's own"
|
||||
say "snapshot: $MAC_SNAP ($(bs_size "$MAC_SNAP") bytes) -> $H/node1-copy-snapshot.bin,$snap_sha"
|
||||
run bs_rsync -p "$MAC_SNAP" "$BS_HOST:$H/node1-copy-snapshot.bin"
|
||||
run bs_ssh "sed -i 's|^SNAPSHOT=.*|SNAPSHOT=$H/node1-copy-snapshot.bin,$snap_sha|; s|^IGNEUMD=.*|IGNEUMD=$H/bin/igneumd|' $H/hands.env && grep -E '^(IGNEUMD|SNAPSHOT|OVERRIDE)=' $H/hands.env"
|
||||
fi
|
||||
say "binary step done; next: move-hand.sh observer-node --go" ;;
|
||||
|
||||
observer-node|node1)
|
||||
if [ "$MODE" = node1 ]; then mac_dir=/tmp/igneum-devnet/node1; label=network.igneum.devnet.node1; unit=igneum-node1; else mac_dir=/tmp/igneum-devnet/observer-v4; label=network.igneum.devnet.observer; unit=igneum-observer-node; fi
|
||||
[ -d "$mac_dir" ] || bs_die "no data dir $mac_dir on the Mac"
|
||||
bs_ssh "[ -x $H/bin/igneumd ] && [ -f $H/override.json ]" || bs_die "the box has no binary or override yet: run move-hand.sh binary --node <wt> first"
|
||||
say "$MODE: hot rsync of $mac_dir ($(du -sh "$mac_dir" | cut -f1)) while the Mac node runs"
|
||||
run sync_dir "$mac_dir" "$H/$MODE"
|
||||
say "$MODE: stopping the Mac's $label, then the final rsync (the delta, seconds), then the unit on the box"
|
||||
run mac_stop_agent "$label"
|
||||
run sync_dir "$mac_dir" "$H/$MODE"
|
||||
run rssh "systemctl start $unit && sleep 3 && systemctl is-active $unit"
|
||||
[ "$GO" = 1 ] && first_exec_line "$unit"
|
||||
say "$MODE moved; the Mac's agent stays unloaded (step 5 removes the plist from the login)" ;;
|
||||
|
||||
observer)
|
||||
[ -f "$HOME/.config/igneum/env" ] || bs_die "no ~/.config/igneum/env on the Mac"
|
||||
grep -q '^DATABASE_URL=' "$HOME/.config/igneum/env" || bs_die "$HOME/.config/igneum/env has no DATABASE_URL line"
|
||||
tmp=$(mktemp); chmod 600 "$tmp"
|
||||
{ grep -E '^(DATABASE_URL|LIVE_RETAIN_HOURS|LIVE_TABLE_PREFIX)=' "$HOME/.config/igneum/env"; printf 'IGNEUM_RPC=ws://127.0.0.1:28640\nIGNEUM_EVM_RPC=http://127.0.0.1:26840\n'; } > "$tmp"
|
||||
say "observer env: $(grep -c . "$tmp") lines (names: $(cut -d= -f1 "$tmp" | tr '\n' ' ')) -> root@$IP:/srv/observer/env mode 600 owner build"
|
||||
run scp -q -i "$BS_KEY" -o BatchMode=yes "$tmp" "root@$IP:/srv/observer/env.new"; rm -f "$tmp"
|
||||
run rssh "chown build:build /srv/observer/env.new && chmod 600 /srv/observer/env.new && mv /srv/observer/env.new /srv/observer/env && stat -c '%U %a %s bytes' /srv/observer/env"
|
||||
run rssh "systemctl is-active igneum-observer-node" || bs_die "igneum-observer-node is not active on the box; move it first"
|
||||
say "stopping the Mac's observer: autosync.sh, run.sh, observer.mjs (two writers to Neon would duplicate rows, so the Mac stops first)"
|
||||
for pat in 'tools/observer/autosync.sh' 'tools/observer/run.sh' 'tools/observer/observer.mjs'; do
|
||||
for p in $(pgrep -f "$pat" || true); do run kill -TERM "$p"; done
|
||||
done
|
||||
run rssh "systemctl start igneum-observer && sleep 5 && systemctl is-active igneum-observer && journalctl -u igneum-observer --no-pager -o cat -n 6"
|
||||
say "observer moved: /api/live reads Neon, which the box's observer now writes" ;;
|
||||
|
||||
unload)
|
||||
say "removing the Mac's launchd agents for good (bootout and the plists moved aside); the hands are on the box"
|
||||
for label in network.igneum.devnet.observer network.igneum.devnet.node1; do
|
||||
run launchctl bootout "gui/$(id -u)/$label" 2>/dev/null || true
|
||||
run mv -f "$HOME/Library/LaunchAgents/$label.plist" "$HOME/Library/LaunchAgents/$label.plist.moved-to-build-1-$(date -u +%Y%m%d)"
|
||||
done
|
||||
say "Mac igneumd processes now: $(pgrep -fl 'igneumd --' | grep -v Wallet | wc -l | tr -d ' ') (the wallet's own node is not a hand)" ;;
|
||||
|
||||
status)
|
||||
echo "--- box:"; rssh "for u in igneum-node1 igneum-observer-node igneum-observer igneum-observer-sync.timer; do printf '%-26s %s\n' \$u \$(systemctl is-active \$u); done; journalctl -u igneum-node1 -o cat -n 2 --no-pager 2>/dev/null | cut -c1-160; journalctl -u igneum-observer -o cat -n 2 --no-pager 2>/dev/null | cut -c1-160"
|
||||
echo "--- mac:"; launchctl print "gui/$(id -u)/network.igneum.devnet.node1" 2>/dev/null | grep -E 'state|pid' | head -2; launchctl print "gui/$(id -u)/network.igneum.devnet.observer" 2>/dev/null | grep -E 'state|pid' | head -2; pgrep -fl 'observer.mjs|observer/run.sh|autosync.sh' || echo "no observer processes on the Mac" ;;
|
||||
*) sed -n '2,20p' "$0"; exit 2 ;;
|
||||
esac
|
||||
|
|
@ -83,22 +83,49 @@ bs_context() {
|
|||
BS_REMOTE_CRATE="$BS_REMOTE_WT/$BS_CRATE_REL"
|
||||
# every local (path) package of the crate's dependency graph, as directories relative to the worktree root; the ones inside
|
||||
# BS_TOP are covered by the git checkout plus the overlay of BS_TOP itself (node kind) or synced one by one (repo kind)
|
||||
# A path dependency that lives inside another git repository under vendor/ (6 October 2026, the shipper's proving build:
|
||||
# proving/igneum-prove -> vendor/igneum-node-exec/igneum/evm-types, a MEMBER of the fork's workspace that inherits
|
||||
# `thiserror` from the fork's root manifest) is not a directory to copy: it needs its whole repository on the box, checked
|
||||
# out at the Mac's commit, as BS_TOP gets. Such repositories are listed in BS_VENDOR_REPOS (relative to the worktree root),
|
||||
# synced whole by bs_sync_sources, and dropped from BS_LOCAL_DIRS.
|
||||
BS_VENDOR_REPOS=""
|
||||
BS_LOCAL_DIRS=$(cd "$BS_CRATE" && "${CARGO_HOME:-$HOME/.cargo}/bin/cargo" metadata --format-version 1 2>/dev/null | python3 -c '
|
||||
import json, os, sys
|
||||
import json, os, subprocess, sys
|
||||
d = json.load(sys.stdin); root = sys.argv[1]; top = sys.argv[2]; kind = sys.argv[3]
|
||||
dirs = set()
|
||||
def toplevel(m):
|
||||
try: return subprocess.run(["git", "-C", m, "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=True).stdout.strip()
|
||||
except subprocess.CalledProcessError: return None
|
||||
dirs, repos = set(), set()
|
||||
for p in d["packages"]:
|
||||
if p["source"] is not None: continue
|
||||
m = os.path.dirname(p["manifest_path"])
|
||||
if kind == "node" and (m == top or m.startswith(top + "/")): dirs.add(top); continue
|
||||
# a repository under vendor/ first: on disk it also lies under the igneum top level (vendor/ is ignored, not a
|
||||
# submodule), so the path test alone would take it for a directory of BS_TOP (the first run of this detector did)
|
||||
t = toplevel(m)
|
||||
if t and t != top and t.startswith(root + "/vendor/"):
|
||||
repos.add(t); continue
|
||||
if m == top or m.startswith(top + "/"):
|
||||
dirs.add(top if kind == "node" else m); continue
|
||||
dirs.add(m)
|
||||
out = []
|
||||
for m in sorted(dirs):
|
||||
def rel(m):
|
||||
r = os.path.relpath(m, root)
|
||||
if r.startswith(".."): sys.exit("path dependency %s is outside the worktree root %s" % (m, root))
|
||||
out.append(r)
|
||||
print("\n".join(out))' "$BS_WT_ROOT" "$BS_TOP" "$BS_KIND") || bs_die "cargo metadata failed in $BS_CRATE"
|
||||
[ -n "$BS_LOCAL_DIRS" ] || bs_die "cargo metadata listed no local packages in $BS_CRATE"
|
||||
return r
|
||||
print("\n".join(rel(m) for m in sorted(dirs)))
|
||||
print("VENDOR_REPOS " + " ".join(rel(t) for t in sorted(repos)))' "$BS_WT_ROOT" "$BS_TOP" "$BS_KIND") || bs_die "cargo metadata failed in $BS_CRATE"
|
||||
BS_VENDOR_REPOS=$(printf '%s\n' "$BS_LOCAL_DIRS" | sed -n 's/^VENDOR_REPOS //p')
|
||||
BS_LOCAL_DIRS=$(printf '%s\n' "$BS_LOCAL_DIRS" | grep -v '^VENDOR_REPOS ' || true)
|
||||
[ -n "$BS_LOCAL_DIRS$BS_VENDOR_REPOS" ] || bs_die "cargo metadata listed no local packages in $BS_CRATE"
|
||||
}
|
||||
|
||||
# which bare mirror on the box holds a repository under vendor/: a worktree of the fork (its common git dir is
|
||||
# vendor/igneum-node/.git) or the fork itself -> /srv/igneum-node.git; any other repository of its own -> /srv/<name>.git,
|
||||
# created on the box on first use (run-from-mac.sh wires and pushes the ones the Cargo.toml files reach)
|
||||
bs_mirror_for() {
|
||||
local dir="$1" common
|
||||
common=$(cd "$dir" && git rev-parse --git-common-dir 2>/dev/null) || { echo ""; return; }
|
||||
common=$(cd "$dir" && cd "$common" && pwd -P)
|
||||
case "$common" in */vendor/igneum-node/.git) echo "$BS_MIRROR_NODE" ;; *) echo "/srv/$(basename "$(dirname "$common")").git" ;; esac
|
||||
}
|
||||
|
||||
# push the crate repo's HEAD to its bare mirror on the box (fast after the first time), then check the remote tree out at that
|
||||
|
|
@ -106,15 +133,23 @@ print("\n".join(out))' "$BS_WT_ROOT" "$BS_TOP" "$BS_KIND") || bs_die "cargo meta
|
|||
# directory AND HEAD is a symbolic ref to a loose branch file; a detached HEAD or a worktree's .git file gives an empty hash
|
||||
# (which is why the Mac's worktree builds print "igneumd 2.1.0" with no commit, 6 Oct 2026). The mirror doubles as the CI
|
||||
# runner's source later.
|
||||
# bs_push_and_checkout the crate's own repository (BS_TOP) at BS_SHA on BS_BRANCH
|
||||
# bs_push_and_checkout <local repo> <rel> another repository under vendor/ (a path dependency's), at its own HEAD
|
||||
bs_push_and_checkout() {
|
||||
local url="$BS_HOST:$BS_MIRROR" remote_top="$BS_REMOTE_WT/$BS_TOP_REL"
|
||||
[ "$BS_TOP_REL" = . ] && remote_top="$BS_REMOTE_WT"
|
||||
bs_log "push $BS_TOP HEAD $BS_SHA ($BS_BRANCH) -> $url"
|
||||
GIT_SSH_COMMAND="$BS_SSH_CMD" git -C "$BS_TOP" push -q --force "$url" "HEAD:refs/heads/$BS_BRANCH" || bs_die "push to the mirror failed"
|
||||
local top="${1:-$BS_TOP}" rel="${2:-$BS_TOP_REL}" mirror branch sha url remote_top
|
||||
if [ -z "${1:-}" ]; then mirror="$BS_MIRROR"; branch="$BS_BRANCH"; sha="$BS_SHA"; else
|
||||
mirror=$(bs_mirror_for "$top"); [ -n "$mirror" ] || bs_die "$top is not a git repository"
|
||||
sha=$(git -C "$top" rev-parse HEAD); branch=$(git -C "$top" branch --show-current 2>/dev/null || true); [ -n "$branch" ] || branch="detached-$(git -C "$top" rev-parse --short HEAD)"
|
||||
[ "$mirror" = "$BS_MIRROR_NODE" ] || [ "$mirror" = "$BS_MIRROR_REPO" ] || bs_ssh "[ -d '$mirror' ] || git init -q --bare -b master '$mirror'" || bs_die "cannot create the mirror $mirror on the box"
|
||||
fi
|
||||
url="$BS_HOST:$mirror"; remote_top="$BS_REMOTE_WT/$rel"
|
||||
[ "$rel" = . ] && remote_top="$BS_REMOTE_WT"
|
||||
bs_log "push $top HEAD $sha ($branch) -> $url"
|
||||
GIT_SSH_COMMAND="$BS_SSH_CMD" git -C "$top" push -q --force "$url" "HEAD:refs/heads/$branch" || bs_die "push to the mirror failed"
|
||||
# the checkout runs as remote-run.sh's `checkout` mode: discard the previous overlay (tracked edits and untracked files,
|
||||
# target dirs kept), then the branch at the commit. 6 October 2026, PC 1 worker's first use: the overlay of an earlier
|
||||
# commit's uncommitted files stayed in the box's tree and `git checkout -B` refused with "local changes would be overwritten".
|
||||
BR_MODE=checkout BR_CO_DIR="$remote_top" BR_CO_MIRROR="$BS_MIRROR" BR_CO_BRANCH="$BS_BRANCH" BR_CO_SHA="$BS_SHA" BR_CO_WT="$BS_REMOTE_WT" \
|
||||
BR_MODE=checkout BR_CO_DIR="$remote_top" BR_CO_MIRROR="$mirror" BR_CO_BRANCH="$branch" BR_CO_SHA="$sha" BR_CO_WT="$BS_REMOTE_WT" \
|
||||
bash -c '
|
||||
for v in BR_MODE BR_CO_DIR BR_CO_MIRROR BR_CO_BRANCH BR_CO_SHA BR_CO_WT; do printf "export %s=%q\n" "$v" "${!v}"; done
|
||||
cat "$0"' "$(dirname "${BASH_SOURCE[0]}")/remote-run.sh" | bs_ssh 'bash -s' || bs_die "remote checkout at $remote_top failed"
|
||||
|
|
@ -144,10 +179,30 @@ bs_overlay_dir() {
|
|||
rm -f "$list"
|
||||
}
|
||||
|
||||
# one run per worktree at a time on the box (the shipper, 6 October 2026, 18:48:56Z: a second run's checkout replaced the first's
|
||||
# sources mid-cargo and both died). The lock is a directory under /srv/builds/_locks made atomically with mkdir and holding the
|
||||
# Mac's pid, time and label; it spans sync, build and fetch (bs_wt_unlock on EXIT). A waiter polls every 10 s for up to 2 h and
|
||||
# takes over a lock older than 3 h (a Mac that died mid-run).
|
||||
bs_wt_lock() {
|
||||
local d="$BS_ROOT_REMOTE/_locks/wt-$BS_WT" t0 holder
|
||||
t0=$(date +%s)
|
||||
while :; do
|
||||
if bs_ssh "mkdir '$d' 2>/dev/null && printf 'pid %s since %sZ: %s\n' '$$' \"\$(date -u +%H:%M:%S)\" '${BS_TOOL:-build} $BS_CRATE_REL' > '$d/holder'"; then BS_WT_LOCKED="$d"; trap 'bs_wt_unlock' EXIT; return 0; fi
|
||||
holder=$(bs_ssh "cat '$d/holder' 2>/dev/null; find '$d' -maxdepth 0 -mmin +180 -print 2>/dev/null | grep -q . && echo STALE" 2>/dev/null || true)
|
||||
case "$holder" in *STALE*) bs_log "worktree lock $d is older than 3 h; taking it over"; bs_ssh "rm -rf '$d'"; continue ;; esac
|
||||
[ $(( $(date +%s) - t0 )) -lt 7200 ] || bs_die "gave up after 2 h waiting for the worktree lock $d (held: $holder)"
|
||||
[ $(( ($(date +%s) - t0) % 60 )) -lt 10 ] && bs_log "waiting for another run on worktree $BS_WT: ${holder:-?}"
|
||||
sleep 10
|
||||
done
|
||||
}
|
||||
bs_wt_unlock() { [ -n "${BS_WT_LOCKED:-}" ] && bs_ssh "rm -rf '$BS_WT_LOCKED'" 2>/dev/null; BS_WT_LOCKED=""; }
|
||||
|
||||
bs_sync_sources() {
|
||||
bs_wt_lock
|
||||
local d
|
||||
bs_push_and_checkout
|
||||
for d in $BS_LOCAL_DIRS; do bs_overlay_dir "$d"; done
|
||||
for d in $BS_VENDOR_REPOS; do bs_push_and_checkout "$BS_WT_ROOT/$d" "$d"; bs_overlay_dir "$d"; done
|
||||
}
|
||||
|
||||
bs_sha256() { shasum -a 256 "$1" | awk '{ print $1 }'; }
|
||||
|
|
|
|||
|
|
@ -118,11 +118,13 @@ step_os_check() {
|
|||
ok os "$PRETTY_NAME, $(nproc) threads, $(awk '/MemTotal/ { printf "%d GB", $2 / 1024 / 1024 }' /proc/meminfo)"
|
||||
}
|
||||
|
||||
# libprotobuf-dev: the well-known .proto files (google/protobuf/empty.proto) that sp1-prover-types's build script imports
|
||||
# (6 October 2026, the first proving build on the box: "protoc failed: google/protobuf/empty.proto: File not found").
|
||||
# the proven Ubuntu 24.04 set: the PC build job's APT lists (app/igneum-app/src/jobbuild.rs: mingw-w64 posix threads so
|
||||
# libstdc++ has std::thread for rocksdb, clang for librocksdb-sys's bindgen, protoc for the node's proto crates) plus the
|
||||
# task's list (build-essential, clang, lld, pkg-config, libssl-dev, cmake, git, tmux) and what the scripts here call
|
||||
APT_PACKAGES=(
|
||||
build-essential clang lld llvm libclang-dev pkg-config libssl-dev cmake protobuf-compiler
|
||||
build-essential clang lld llvm libclang-dev pkg-config libssl-dev cmake protobuf-compiler libprotobuf-dev
|
||||
gcc-mingw-w64-x86-64 g++-mingw-w64-x86-64 binutils-mingw-w64-x86-64 mingw-w64-x86-64-dev mingw-w64-tools
|
||||
git tmux curl ca-certificates xz-utils zstd unzip rsync jq python3 ufw htop file caddy
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ checkout_tree() {
|
|||
[ -n "$wt" ] && mkdir -p "$wt"
|
||||
if [ ! -d "$dir/.git" ]; then rm -rf "$dir"; git clone -q --no-checkout "$mirror" "$dir"; fi
|
||||
cd "$dir"
|
||||
# a run killed mid-git (two runs on one worktree, 18:48:56Z the same day) leaves .git/index.lock; stale when no git runs here
|
||||
if [ -f .git/index.lock ] && ! pgrep -x git >/dev/null 2>&1; then rm -f .git/index.lock; echo "checkout: removed a stale .git/index.lock in $dir" >&2; fi
|
||||
git checkout -q -- . 2>/dev/null || true
|
||||
git clean -qfd -e target -e 'target-*' -e '.build-remote-sha-*' -e '.cross-remote-sha-*' -e sccache
|
||||
git fetch -q origin '+refs/heads/*:refs/remotes/origin/*'
|
||||
|
|
@ -41,7 +43,10 @@ checkout_tree() {
|
|||
# what may remain untracked: target dirs, the sha stamps of build-remote.sh and cross-remote.sh (kept so a build after the
|
||||
# checkout knows whether to clean kaspa-build-info), sccache; the first live run after this mode was added failed on a
|
||||
# stamp it had itself kept (6 October 2026, 18:14 UTC: the self-test had no stamp file; it has one now)
|
||||
local left; left=$(git status --porcelain | grep -vE '^\?\? (target|target-|sccache|\.build-remote-sha-|\.cross-remote-sha-)' | head -3 || true)
|
||||
# ... at any depth: a repo-kind crate (pool/, igneum-pow/, app/igneum-app/) writes its stamp in its own directory, and the
|
||||
# root-anchored pattern of the first version failed the second build of every such crate (6 October 2026, 18:51 UTC, the
|
||||
# pool build: "tree not clean after reset: ?? pool/.build-remote-sha-target"; the self-test has the subdirectory case now)
|
||||
local left; left=$(git status --porcelain --untracked-files=all | grep -vE '^\?\? (.*/)?(target|target-|sccache|\.build-remote-sha-|\.cross-remote-sha-)' | head -3 || true)
|
||||
[ -z "$left" ] || { echo "checkout: tree not clean after reset at $dir: $left" >&2; return 1; }
|
||||
set +e
|
||||
}
|
||||
|
|
@ -56,6 +61,9 @@ if [ "${1:-}" = --self-test ]; then
|
|||
checkout_tree "$t/box" "$t/mirror.git" master "$sha1" || { echo "self-test: first checkout failed"; exit 1; }
|
||||
# the overlay of a build: a tracked file edited, an untracked file added, a target dir that must survive
|
||||
echo edited > "$t/box/a.txt"; echo new > "$t/box/b.txt"; mkdir -p "$t/box/target/release"; echo bin > "$t/box/target/release/x"; echo "$sha1" > "$t/box/.build-remote-sha-target"
|
||||
# a crate in a subdirectory: its stamp and target dir must survive, its untracked overlay file must not
|
||||
mkdir -p "$t/box/sub/target/release"; echo bin > "$t/box/sub/target/release/y"; echo "$sha1" > "$t/box/sub/.build-remote-sha-target"; echo new > "$t/box/sub/c.txt"
|
||||
: > "$t/box/.git/index.lock" # a run killed mid-git leaves this; the checkout mode removes it when no git runs here
|
||||
# the Mac moves on: a new commit that changes a.txt
|
||||
echo a2 > "$t/src/a.txt"; git -C "$t/src" -c user.name=t -c user.email=t@t commit -qam three; git -C "$t/src" push -q "$t/mirror.git" master
|
||||
sha2=$(git -C "$t/src" rev-parse HEAD)
|
||||
|
|
@ -66,7 +74,15 @@ if [ "${1:-}" = --self-test ]; then
|
|||
[ ! -e "$t/box/b.txt" ] || { echo "self-test: untracked overlay file survived"; exit 1; }
|
||||
[ -f "$t/box/target/release/x" ] || { echo "self-test: target dir was cleaned"; exit 1; }
|
||||
[ -f "$t/box/.build-remote-sha-target" ] || { echo "self-test: the sha stamp was cleaned"; exit 1; }
|
||||
echo "self-test: checkout mode lands on the new commit with a clean tree, target dir and sha stamp kept"; exit 0
|
||||
[ -f "$t/box/sub/.build-remote-sha-target" ] || { echo "self-test: a subdirectory crate's sha stamp was cleaned"; exit 1; }
|
||||
[ -f "$t/box/sub/target/release/y" ] || { echo "self-test: a subdirectory crate's target dir was cleaned"; exit 1; }
|
||||
[ ! -e "$t/box/sub/c.txt" ] || { echo "self-test: a subdirectory's untracked overlay file survived"; exit 1; }
|
||||
# the known-failed case: an untracked file the overlay left that no rule keeps must fail the check
|
||||
echo stray > "$t/box/sub/stray.txt"
|
||||
if (cd "$t/box" && left=$(git status --porcelain --untracked-files=all | grep -vE '^\?\? (.*/)?(target|target-|sccache|\.build-remote-sha-|\.cross-remote-sha-)' | head -3); [ -n "$left" ]); then :; else echo "self-test: the clean-tree check did NOT fire on a stray untracked file"; exit 1; fi
|
||||
rm -f "$t/box/sub/stray.txt"
|
||||
[ ! -f "$t/box/.git/index.lock" ] || { echo "self-test: the stale index.lock survived"; exit 1; }
|
||||
echo "self-test: checkout mode lands on the new commit with a clean tree, target dirs and sha stamps kept at any depth, stale index.lock removed, and fires on a stray file"; exit 0
|
||||
fi
|
||||
|
||||
if [ "${BR_MODE:-run}" = checkout ]; then
|
||||
|
|
|
|||
|
|
@ -57,6 +57,16 @@ wire_remote() { # repo-dir mirror label
|
|||
}
|
||||
wire_remote "$MAIN_REPO" "$BS_MIRROR_REPO" "igneum"
|
||||
wire_remote "$MAIN_REPO/vendor/igneum-node" "$BS_MIRROR_NODE" "igneum-node (the fork)"
|
||||
# every repository under vendor/ that a Cargo.toml of the repo reaches by path (6 October 2026: proving/igneum-prove ->
|
||||
# vendor/igneum-node-exec, a worktree of the fork, so already on /srv/igneum-node.git; a repository of its own gets its own
|
||||
# mirror /srv/<name>.git here, and lib.sh checks it out whole on the box)
|
||||
grep -rhoE 'path *= *"\.\./[^"]*vendor/[^/"]+' --include=Cargo.toml "$MAIN_REPO/app" "$MAIN_REPO/proving" "$MAIN_REPO/igneum-pow" "$MAIN_REPO/igneum-census" "$MAIN_REPO/proto-vdf" 2>/dev/null \
|
||||
| sed -E 's|.*vendor/||' | sort -u | while read -r name; do
|
||||
dir="$MAIN_REPO/vendor/$name"; [ -d "$dir" ] || { bs_log "vendor/$name is reached by a path dependency but missing on this Mac"; continue; }
|
||||
mirror=$(bs_mirror_for "$dir")
|
||||
if [ "$mirror" = "$BS_MIRROR_NODE" ]; then bs_log "vendor/$name: a worktree of the fork ($(git -C "$dir" branch --show-current)), on $BS_MIRROR_NODE already"
|
||||
else bs_ssh "[ -d '$mirror' ] || git init -q --bare -b master '$mirror'"; wire_remote "$dir" "$mirror" "vendor/$name"; fi
|
||||
done
|
||||
|
||||
bs_log "ssh line: ssh -i $BS_KEY build@$IP"
|
||||
bs_log "next: cd <crate> && $REPO/tools/build-remote.sh (cross: tools/cross-remote.sh)"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
172
pool/README.md
Normal file
172
pool/README.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# 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. Measured on a private fast-time network
|
||||
(`docs/plans/pool.md` section 5): three CPU miners of different identities, 0 rejected and 0 stale shares in the
|
||||
second run, 289 blocks found and 0 orphaned at 49% of the network, PPLNS paid in dry run and then for real (24
|
||||
transfers, 0 failed, the miners' EVM balances rose by what the ledger said), the API at 100 requests per second with
|
||||
p99 under 3 ms, share checks 2.1 ms each under load (0.44 ms idle, the crate bench).
|
||||
|
||||
## 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 |
|
||||
|
||||
## Building on igneum-build-1 (6 October 2026)
|
||||
|
||||
Every Linux build and suite runs on the box through `tools/build-remote.sh` from this directory (`IGNEUM_AGENT=pool`). The
|
||||
crate reads the fork through the `vendor/igneum-node` symlink; the build library syncs the fork worktree it points at as a
|
||||
whole repository, and the symlink is made once on the box by hand: `ln -s <fork worktree name> /srv/builds/<worktree>/vendor/igneum-node`.
|
||||
Artefacts land in `pool/target-remote/release/igneum-pool` (x86_64 Linux); the Mac builds no Linux binary.
|
||||
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}");
|
||||
}
|
||||
}
|
||||
409
pool/src/node.rs
Normal file
409
pool/src/node.rs
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
//! 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_genesis, install_pow_schedule, install_program_class_v3_activation, pow_genesis_dataset_log2, pow_genesis_day_index, pow_schedule, producer_share, program_class_v3_activation_daa, 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")?;
|
||||
// The node's PoW schedule, genesis day and dataset size, and class v3 activation are the pool's (what the solo
|
||||
// miner's `template()` installs): the share verifier's day cache and program must be the node's exactly
|
||||
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);
|
||||
}
|
||||
if (info.genesis_day_index, info.genesis_dataset_log2) != (pow_genesis_day_index(), pow_genesis_dataset_log2()) {
|
||||
install_pow_genesis(info.genesis_day_index, info.genesis_dataset_log2);
|
||||
eprintln!("{} node genesis day index {} and genesis dataset 2^{} words: the day cache follows them", now(), info.genesis_day_index, info.genesis_dataset_log2);
|
||||
}
|
||||
if info.program_class_v3_activation_daa != program_class_v3_activation_daa() {
|
||||
install_program_class_v3_activation(info.program_class_v3_activation_daa);
|
||||
eprintln!("{} node program class v3 activation: {} (epoch {} is class {}, the next epoch class {})", now(), info.program_class_v3_activation_daa, info.epoch_index, info.class().name(), info.next_class().name());
|
||||
}
|
||||
// Counter ASIC 2.0: the class and the era seed of the epoch ride with the template; the verifier hashes the
|
||||
// program they name, the same one the members' workers compile (6 October 2026: a fixed class here or on the
|
||||
// member refused every GPU share of the fleet run)
|
||||
let seeds = EpochSeeds { epoch: info.epoch_seed, day: day_index(header.timestamp), class: info.class(), era: info.era_seed.unwrap_or(kaspa_hashes::ZERO_HASH) };
|
||||
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,
|
||||
program_class: info.program_class,
|
||||
next_program_class: info.next_program_class,
|
||||
era_seed: info.era_seed.map(|h| h.to_string()),
|
||||
era_index: info.era_index,
|
||||
genesis_day_index: info.genesis_day_index,
|
||||
genesis_dataset_log2: info.genesis_dataset_log2,
|
||||
program_class_v3_activation_daa: info.program_class_v3_activation_daa,
|
||||
}
|
||||
.line(),
|
||||
);
|
||||
println!("{} SEEDS for member {} ({}): epoch {} day {} class {} era {}", now(), member.id, member.worker, seeds.epoch, seeds.day, seeds.class.name(), seeds.era);
|
||||
}
|
||||
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,
|
||||
program_class: seeds.class.generator_version(),
|
||||
era_seed: (seeds.class != kaspa_consensus_core::igneum::ProgramClass::V2).then(|| seeds.era.to_string()),
|
||||
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);
|
||||
}
|
||||
}
|
||||
245
pool/src/protocol.rs
Normal file
245
pool/src/protocol.rs
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
//! 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`) and the program class and era seed
|
||||
//! (Counter ASIC 2.0) 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,
|
||||
/// Counter ASIC 2.0 (added 6 October 2026, after the fleet's 0-share run): the program class of the current
|
||||
/// epoch as the node's `pow_epoch.program_class` reports it (the generator number, 2 or 3), the next epoch's
|
||||
/// class, and the era seed class v3 programs are drawn under. A member hashes the program these name.
|
||||
program_class: u32,
|
||||
next_program_class: u32,
|
||||
era_seed: Option<String>,
|
||||
era_index: u64,
|
||||
/// The node's genesis day index, genesis dataset size (log2 words) and class v3 activation DAA: what a
|
||||
/// member without a verifier node installs so its day cache is built at the network's size
|
||||
genesis_day_index: u64,
|
||||
genesis_dataset_log2: u32,
|
||||
program_class_v3_activation_daa: 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,
|
||||
/// The program class (generator number) and era seed of the job's epoch: the member's CPU re-check and its
|
||||
/// worker lines use exactly these (never a fixed class)
|
||||
program_class: u32,
|
||||
era_seed: Option<String>,
|
||||
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::v2(Hash::from_bytes([5u8; 32]), 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::v2(Hash::from_bytes([6u8; 32]), 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::v2(Hash::from_bytes([3u8; 32]), 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);
|
||||
}
|
||||
}
|
||||
131
pool/tests/fixtures/blocks.json
vendored
Normal file
131
pool/tests/fixtures/blocks.json
vendored
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
{
|
||||
"blocks": [
|
||||
{
|
||||
"blue_score": 874,
|
||||
"confirmed_ms": 1791236308242,
|
||||
"daa_score": 873,
|
||||
"effort": 0.5,
|
||||
"fee_ign": 0.025427313900000255,
|
||||
"finder": "0x3333333333333333333333333333333333333333",
|
||||
"hash": "544905a8873efb3664c3b45d19587de1474a1d818da23537661c54c8914e8f4a",
|
||||
"nonce": "b6a1c91600000f7b",
|
||||
"payees": [
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"fraction": 0.5
|
||||
},
|
||||
{
|
||||
"address": "0x3333333333333333333333333333333333333333",
|
||||
"fraction": 0.5
|
||||
}
|
||||
],
|
||||
"reward_ign": 2.54273139,
|
||||
"shift": 1,
|
||||
"status": "confirmed",
|
||||
"time": "2026-10-05T21:34:51Z",
|
||||
"ts_ms": 1791236091852,
|
||||
"worker": "rig3"
|
||||
},
|
||||
{
|
||||
"blue_score": 872,
|
||||
"confirmed_ms": 1791236308242,
|
||||
"daa_score": 871,
|
||||
"effort": 2,
|
||||
"fee_ign": 0.0254271378,
|
||||
"finder": "0x1111111111111111111111111111111111111111",
|
||||
"hash": "58dd649447d58a8481703262951e8480538096bd2c48bdc7c217140d2311699c",
|
||||
"nonce": "99f91934c000075f",
|
||||
"payees": [
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"fraction": 0.5
|
||||
},
|
||||
{
|
||||
"address": "0x3333333333333333333333333333333333333333",
|
||||
"fraction": 0.5
|
||||
}
|
||||
],
|
||||
"reward_ign": 2.54271378,
|
||||
"shift": 0,
|
||||
"status": "confirmed",
|
||||
"time": "2026-10-05T21:34:51Z",
|
||||
"ts_ms": 1791236091394,
|
||||
"worker": "rig1"
|
||||
},
|
||||
{
|
||||
"blue_score": 870,
|
||||
"confirmed_ms": 1791236088606,
|
||||
"daa_score": 869,
|
||||
"effort": 1,
|
||||
"fee_ign": 0.0254269618,
|
||||
"finder": "0x1111111111111111111111111111111111111111",
|
||||
"hash": "29bf331640173b0875239bf18d493adb03f3b5a16d9b8c63d12194d98026bde7",
|
||||
"nonce": "92ecef5ac0001d15",
|
||||
"payees": [
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"fraction": 0.5
|
||||
},
|
||||
{
|
||||
"address": "0x2222222222222222222222222222222222222222",
|
||||
"fraction": 0.5
|
||||
}
|
||||
],
|
||||
"reward_ign": 2.54269618,
|
||||
"shift": 0,
|
||||
"status": "confirmed",
|
||||
"time": "2026-10-05T21:34:46Z",
|
||||
"ts_ms": 1791236086976,
|
||||
"worker": "rig1"
|
||||
},
|
||||
{
|
||||
"blue_score": 869,
|
||||
"confirmed_ms": 1791236088606,
|
||||
"daa_score": 868,
|
||||
"effort": 1,
|
||||
"fee_ign": 0.0254268737,
|
||||
"finder": "0x2222222222222222222222222222222222222222",
|
||||
"hash": "90b4c109bbf77472f832d519536337c3cc68bf14fdd4b64ac687976e6a87b5cf",
|
||||
"nonce": "81468abc80002882",
|
||||
"payees": [
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"fraction": 0.5
|
||||
},
|
||||
{
|
||||
"address": "0x2222222222222222222222222222222222222222",
|
||||
"fraction": 0.5
|
||||
}
|
||||
],
|
||||
"reward_ign": 2.54268737,
|
||||
"shift": 0,
|
||||
"status": "confirmed",
|
||||
"time": "2026-10-05T21:34:46Z",
|
||||
"ts_ms": 1791236086408,
|
||||
"worker": "rig2"
|
||||
},
|
||||
{
|
||||
"blue_score": 868,
|
||||
"confirmed_ms": 1791236088606,
|
||||
"daa_score": 867,
|
||||
"effort": 1,
|
||||
"fee_ign": 0.0254267857,
|
||||
"finder": "0x1111111111111111111111111111111111111111",
|
||||
"hash": "ccd29bf5174975c1d86dfd6a1b403b4562f4bfd703694cc0df83f00e6702b1f4",
|
||||
"nonce": "05ab61ae40000079",
|
||||
"payees": [
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"fraction": 1
|
||||
}
|
||||
],
|
||||
"reward_ign": 2.54267857,
|
||||
"shift": 0,
|
||||
"status": "confirmed",
|
||||
"time": "2026-10-05T21:34:45Z",
|
||||
"ts_ms": 1791236085686,
|
||||
"worker": "rig1"
|
||||
}
|
||||
],
|
||||
"ok": true
|
||||
}
|
||||
326
pool/tests/fixtures/miner.json
vendored
Normal file
326
pool/tests/fixtures/miner.json
vendored
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"balance_ign": 2.5172953591500002,
|
||||
"blocks": 218,
|
||||
"first_seen_ms": 1791235224950,
|
||||
"hashrate": 0,
|
||||
"hashrate_1h": 0,
|
||||
"hashrate_reported": 0,
|
||||
"last_share_ms": 1791236091390,
|
||||
"ok": true,
|
||||
"online": false,
|
||||
"paid_ign": 561.7725350816701,
|
||||
"payments": [
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 26.93371660551,
|
||||
"dry_run": false,
|
||||
"nonce": 24,
|
||||
"status": "sent",
|
||||
"time": "2026-10-05T21:34:52Z",
|
||||
"ts_ms": 1791236092474,
|
||||
"tx_hash": "0xed5ea6468346f986209279ced558042eea9218f371b94aaf745df9bc0df35d86"
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 31.4606845773,
|
||||
"dry_run": false,
|
||||
"nonce": 21,
|
||||
"status": "confirmed",
|
||||
"time": "2026-10-05T21:34:22Z",
|
||||
"ts_ms": 1791236062470,
|
||||
"tx_hash": "0x29e06b1e402ca65eb932fdb76a0080f8525ca9789496e7baec36c5a62325e5ac"
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 21.390914952,
|
||||
"dry_run": false,
|
||||
"nonce": 18,
|
||||
"status": "confirmed",
|
||||
"time": "2026-10-05T21:33:52Z",
|
||||
"ts_ms": 1791236032465,
|
||||
"tx_hash": "0xe8fdf9d895c016187f2fd0339b33b0ea63cfece7b5bea32a1bf48eaad46ebc8d"
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 3.774487761,
|
||||
"dry_run": false,
|
||||
"nonce": 15,
|
||||
"status": "confirmed",
|
||||
"time": "2026-10-05T21:33:22Z",
|
||||
"ts_ms": 1791236002461,
|
||||
"tx_hash": "0x6f15b730287636d8b907ebf287d3a5234ba62e4ee0cfe85a16fdb40fcf3189b8"
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 33.71603559885,
|
||||
"dry_run": false,
|
||||
"nonce": 12,
|
||||
"status": "confirmed",
|
||||
"time": "2026-10-05T21:32:52Z",
|
||||
"ts_ms": 1791235972456,
|
||||
"tx_hash": "0xbdcc6272a4b7456f503049775521007e9de3a9d01312dacc1ffa649e1485afea"
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 12.07564113492,
|
||||
"dry_run": false,
|
||||
"nonce": 9,
|
||||
"status": "confirmed",
|
||||
"time": "2026-10-05T21:32:22Z",
|
||||
"ts_ms": 1791235942450,
|
||||
"tx_hash": "0x3501217795d9a9548e7d31936faf724867c57dcb6b7e838f9328f4ffa60c4172"
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 13.58402802372,
|
||||
"dry_run": false,
|
||||
"nonce": 6,
|
||||
"status": "confirmed",
|
||||
"time": "2026-10-05T21:31:52Z",
|
||||
"ts_ms": 1791235912442,
|
||||
"tx_hash": "0x872769e4609527c4d735806cf583cccf7663af5f71143849d08b1c9ea711c5e8"
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 15.84617872167,
|
||||
"dry_run": false,
|
||||
"nonce": 3,
|
||||
"status": "confirmed",
|
||||
"time": "2026-10-05T21:31:22Z",
|
||||
"ts_ms": 1791235882437,
|
||||
"tx_hash": "0x47734d3e4f14f307589446f08f99aa341325a442df4fdd8adf370417afa848af"
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 402.9908477067,
|
||||
"dry_run": false,
|
||||
"nonce": 0,
|
||||
"status": "confirmed",
|
||||
"time": "2026-10-05T21:30:52Z",
|
||||
"ts_ms": 1791235852433,
|
||||
"tx_hash": "0xe8610b31f633b17b09d852de885e9e7eaa0b356d70c876f18d9a4f908d306483"
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 389.1584895858,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:30:18Z",
|
||||
"ts_ms": 1791235818731,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 382.87169535780004,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:29:48Z",
|
||||
"ts_ms": 1791235788730,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 358.98321407355,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:29:18Z",
|
||||
"ts_ms": 1791235758728,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 331.32614454224995,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:28:48Z",
|
||||
"ts_ms": 1791235728724,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 321.52158185004004,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:28:18Z",
|
||||
"ts_ms": 1791235698721,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 306.43914649689,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:27:48Z",
|
||||
"ts_ms": 1791235668717,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 286.33167121779,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:27:18Z",
|
||||
"ts_ms": 1791235638714,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 256.92731694135,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:26:48Z",
|
||||
"ts_ms": 1791235608712,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 229.53659767236,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:26:18Z",
|
||||
"ts_ms": 1791235578709,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 215.46542448783,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:25:48Z",
|
||||
"ts_ms": 1791235548706,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 196.62218703963,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:25:18Z",
|
||||
"ts_ms": 1791235518704,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 151.65450469422,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:24:48Z",
|
||||
"ts_ms": 1791235488701,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 129.29929374483,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:24:18Z",
|
||||
"ts_ms": 1791235458697,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 110.71361565693,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:23:48Z",
|
||||
"ts_ms": 1791235428694,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 103.43085358221,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:23:18Z",
|
||||
"ts_ms": 1791235398691,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 84.59795120436,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:22:48Z",
|
||||
"ts_ms": 1791235368688,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 62.00025999651,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:22:18Z",
|
||||
"ts_ms": 1791235338685,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 55.723901056559995,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:21:48Z",
|
||||
"ts_ms": 1791235308682,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 36.64512423198,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:21:18Z",
|
||||
"ts_ms": 1791235278678,
|
||||
"tx_hash": null
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 7.529255231850001,
|
||||
"dry_run": true,
|
||||
"nonce": null,
|
||||
"status": "dry-run",
|
||||
"time": "2026-10-05T21:20:48Z",
|
||||
"ts_ms": 1791235248675,
|
||||
"tx_hash": null
|
||||
}
|
||||
],
|
||||
"proving": false,
|
||||
"shares": {
|
||||
"accepted": 226,
|
||||
"rejected": 0,
|
||||
"stale": 0
|
||||
},
|
||||
"workers": [
|
||||
{
|
||||
"accepted": 226,
|
||||
"blocks": 218,
|
||||
"hashrate": 0,
|
||||
"hashrate_reported": null,
|
||||
"last_share_ms": 1791236091390,
|
||||
"name": "rig1",
|
||||
"online": false,
|
||||
"proving": false,
|
||||
"rejected": 0,
|
||||
"shift": null,
|
||||
"stale": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
55
pool/tests/fixtures/payments.json
vendored
Normal file
55
pool/tests/fixtures/payments.json
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"ok": true,
|
||||
"payments": [
|
||||
{
|
||||
"address": "0x3333333333333333333333333333333333333333",
|
||||
"amount_ign": 8.5580521884,
|
||||
"dry_run": false,
|
||||
"nonce": 26,
|
||||
"status": "sent",
|
||||
"time": "2026-10-05T21:34:52Z",
|
||||
"ts_ms": 1791236092476,
|
||||
"tx_hash": "0x191e7e0f95b344172a8f082a18cf613afff0413875e5b8a620b212fc2169726e"
|
||||
},
|
||||
{
|
||||
"address": "0x2222222222222222222222222222222222222222",
|
||||
"amount_ign": 7.2998907507899995,
|
||||
"dry_run": false,
|
||||
"nonce": 25,
|
||||
"status": "sent",
|
||||
"time": "2026-10-05T21:34:52Z",
|
||||
"ts_ms": 1791236092475,
|
||||
"tx_hash": "0x713fe5de31fcd562871d24abc6cc018c9dbe47ab44504183e09d78325c3b31c9"
|
||||
},
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"amount_ign": 26.93371660551,
|
||||
"dry_run": false,
|
||||
"nonce": 24,
|
||||
"status": "sent",
|
||||
"time": "2026-10-05T21:34:52Z",
|
||||
"ts_ms": 1791236092474,
|
||||
"tx_hash": "0xed5ea6468346f986209279ced558042eea9218f371b94aaf745df9bc0df35d86"
|
||||
},
|
||||
{
|
||||
"address": "0x3333333333333333333333333333333333333333",
|
||||
"amount_ign": 11.326007840399999,
|
||||
"dry_run": false,
|
||||
"nonce": 23,
|
||||
"status": "confirmed",
|
||||
"time": "2026-10-05T21:34:22Z",
|
||||
"ts_ms": 1791236062472,
|
||||
"tx_hash": "0xd1f5009295072bf4b8b900b7f5e5902876e715d0dfe9b5959a1784276082c156"
|
||||
},
|
||||
{
|
||||
"address": "0x2222222222222222222222222222222222222222",
|
||||
"amount_ign": 17.6181308127,
|
||||
"dry_run": false,
|
||||
"nonce": 22,
|
||||
"status": "confirmed",
|
||||
"time": "2026-10-05T21:34:22Z",
|
||||
"ts_ms": 1791236062471,
|
||||
"tx_hash": "0xe99d01acdad5fceb541281425d0ded9280657ca2dc7d50784996cd046752e82f"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
pool/tests/fixtures/pool-stats.json
vendored
Normal file
15
pool/tests/fixtures/pool-stats.json
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"algo": "igneum",
|
||||
"blocks": 431,
|
||||
"difficulty": 98244.11853002977,
|
||||
"fee": 1,
|
||||
"hashrate": 0,
|
||||
"height": 875,
|
||||
"lastBlock": 1791236091852,
|
||||
"minPayout": 1,
|
||||
"miners": 0,
|
||||
"networkHashrate": null,
|
||||
"scheme": "PPLNS",
|
||||
"symbol": "IGN",
|
||||
"workers": 0
|
||||
}
|
||||
92
pool/tests/fixtures/stats.json
vendored
Normal file
92
pool/tests/fixtures/stats.json
vendored
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
{
|
||||
"network": {
|
||||
"block_count": 875,
|
||||
"block_reward_ign": 3.17843623,
|
||||
"block_time_target_s": 1,
|
||||
"blue_score": 875,
|
||||
"chain_id": 4463,
|
||||
"daa_score": 875,
|
||||
"difficulty": 98244.11853002977,
|
||||
"epoch_index": 0,
|
||||
"epoch_seed": "",
|
||||
"hashrate": null,
|
||||
"hashrate_unit": "H/s",
|
||||
"miner_reward_ign": 2.54274899,
|
||||
"name": "devnet-3040",
|
||||
"node_version": "2.1.0",
|
||||
"synced": true,
|
||||
"updated_ms": 1791236308123
|
||||
},
|
||||
"now": "2026-10-05T21:38:31Z",
|
||||
"ok": true,
|
||||
"pool": {
|
||||
"address": "0x72849c9a53e7c80154ab63c8620a6033c676ad07",
|
||||
"algorithm": "igneum",
|
||||
"blocks_24h": 431,
|
||||
"blocks_confirmed_24h": 431,
|
||||
"blocks_confirmed_total": 431,
|
||||
"blocks_orphaned_24h": 0,
|
||||
"blocks_orphaned_total": 0,
|
||||
"blocks_total": 431,
|
||||
"dry_run": true,
|
||||
"effort_current": 0,
|
||||
"fee_percent": 1,
|
||||
"hashrate": 0,
|
||||
"hashrate_1h": 0,
|
||||
"hashrate_unit": "H/s",
|
||||
"last_block": {
|
||||
"blue_score": 874,
|
||||
"confirmed_ms": 1791236308242,
|
||||
"daa_score": 873,
|
||||
"effort": 0.5,
|
||||
"fee_ign": 0.025427313900000255,
|
||||
"finder": "0x3333333333333333333333333333333333333333",
|
||||
"hash": "544905a8873efb3664c3b45d19587de1474a1d818da23537661c54c8914e8f4a",
|
||||
"nonce": "b6a1c91600000f7b",
|
||||
"payees": [
|
||||
{
|
||||
"address": "0x1111111111111111111111111111111111111111",
|
||||
"fraction": 0.5
|
||||
},
|
||||
{
|
||||
"address": "0x3333333333333333333333333333333333333333",
|
||||
"fraction": 0.5
|
||||
}
|
||||
],
|
||||
"reward_ign": 2.54273139,
|
||||
"shift": 1,
|
||||
"status": "confirmed",
|
||||
"time": "2026-10-05T21:34:51Z",
|
||||
"ts_ms": 1791236091852,
|
||||
"worker": "rig3"
|
||||
},
|
||||
"luck_24h": 1.0058,
|
||||
"min_payout_ign": 1,
|
||||
"miners": 0,
|
||||
"name": "Igneum pool (measure)",
|
||||
"paid_24h_ign": 1078.323837921,
|
||||
"paid_total_ign": 1078.323837921,
|
||||
"pending_balance_ign": 5.0345907183000005,
|
||||
"pool_fee_total_ign": 10.943014430700018,
|
||||
"pplns_window_blocks": 2,
|
||||
"scheme": "PPLNS",
|
||||
"share_check_ms": {
|
||||
"count": 0,
|
||||
"max": 0,
|
||||
"mean": 0,
|
||||
"p50": 0,
|
||||
"p99": 0
|
||||
},
|
||||
"shares": {
|
||||
"accepted": 474,
|
||||
"rejected": 0,
|
||||
"stale": 0,
|
||||
"window_shares": 3,
|
||||
"window_weight_blocks": 2
|
||||
},
|
||||
"uptime_s": 8,
|
||||
"url": "127.0.0.1:30463",
|
||||
"workers": 0
|
||||
},
|
||||
"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"
|
||||
}
|
||||
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>
|
||||
|
|
@ -50,6 +50,7 @@ while [ $# -gt 0 ]; do
|
|||
esac
|
||||
done
|
||||
[ "${CARGO_ARGS[0]:-}" = cargo ] && CARGO_ARGS=("${CARGO_ARGS[@]:1}")
|
||||
CARGO_ARGS_GIVEN=""; [ "${#CARGO_ARGS[@]}" -gt 0 ] && CARGO_ARGS_GIVEN=1
|
||||
|
||||
bs_host
|
||||
bs_context
|
||||
|
|
@ -104,9 +105,15 @@ if [ "$FETCH" = 1 ] && [ -n "$ARTEFACTS" ]; then
|
|||
mkdir -p "$OUT"
|
||||
for a in $ARTEFACTS; do
|
||||
rel="${a#"$TARGET_DIR"/}"; dest="$OUT/$rel"; mkdir -p "$(dirname "$dest")"
|
||||
bs_rsync -p "$BS_HOST:$BS_REMOTE_CRATE/$a" "$dest" || bs_die "no $a on the box after the build"
|
||||
if ! bs_rsync -p "$BS_HOST:$BS_REMOTE_CRATE/$a" "$dest" 2>/dev/null; then
|
||||
# a default artefact the caller's own `-p` selection did not build is noted, not fatal (6 Oct 2026: `-p igneum-prove-host`
|
||||
# alone left no igneum-prove-export); a missing artefact the caller NAMED with --artefacts is fatal
|
||||
if [ -n "${ARTEFACTS_SET:-}" ] || [ -z "${CARGO_ARGS_GIVEN:-}" ]; then bs_die "no $a on the box after the build"; fi
|
||||
bs_log "no $a on the box (not built by cargo ${CARGO_ARGS[*]}); skipped"; continue
|
||||
fi
|
||||
bs_log "artefact $dest: $(bs_size "$dest") bytes, sha256 $(bs_sha256 "$dest"), $(file -b "$dest" | cut -c1-60)"
|
||||
# the commit-string gate (rule of 6 October 2026): a node binary without its commit in its strings fails the run
|
||||
case "$BS_KIND:$(basename "$dest")" in node:igneumd) "$HERE/ci/commit-string-check.sh" "$dest" "$BS_SHA" || bs_die "commit-string gate failed for $a" ;; esac # only kaspad depends on kaspa-build-info
|
||||
done
|
||||
fi
|
||||
bs_wt_unlock
|
||||
|
|
|
|||
|
|
@ -122,3 +122,4 @@ if printf '%s\n' "$dlls" | grep -q 'libstdc++-6.dll'; then
|
|||
for dll in libstdc++-6.dll libgcc_s_seh-1.dll libwinpthread-1.dll; do bs_log "runtime $dll: $(bs_size "$OUT/$TARGET/release/$dll") bytes, sha256 $(bs_sha256 "$OUT/$TARGET/release/$dll") (GCC 13 posix, beside the exes)"; done
|
||||
fi
|
||||
bs_log "exes in $OUT/$TARGET/release"
|
||||
bs_wt_unlock
|
||||
|
|
|
|||
Loading…
Reference in a new issue