Merge remote-tracking branch 'origin/master' into testnet-genesis-2
16
.github/workflows/ci.yml
vendored
|
|
@ -11,9 +11,10 @@
|
|||
# Where it runs: `pow` and `sims` go to the box's runner (igneum-build-1, rustc pinned, sccache read-only, 48 jobs)
|
||||
# when the repository variable IGNEUM_CI_RUNNER is `box`, else to ubuntu-latest (docs/plans/ci-self-hosted.md; GitHub
|
||||
# has no fallback in runs-on, the variable is the switch). The `site` job stays on GitHub's machines. The `red` job
|
||||
# runs on the box after any failed master or release-* run and records the failure for the watcher
|
||||
# (tools/ci/red-watch.mjs; infra/build-server/ci-red): one line per run to the hidden updates channel and to
|
||||
# /srv/ci-red/red.jsonl, so nobody opens the Actions page to learn master is red.
|
||||
# runs on the box after any failed run on ANY branch and records the failure for the watcher
|
||||
# (tools/ci/red-watch.mjs; infra/build-server/ci-red): one line per run, naming the branch, the commit, the red check
|
||||
# and the pushing author, to the hidden updates channel and to /srv/ci-red/red.jsonl, so nobody opens the Actions page
|
||||
# to learn a branch is red (master and release-* only until 7 October 2026, when eight red runs on ca3-v4-node went unseen).
|
||||
#
|
||||
# What does not run, on purpose: the node fork (vendor/igneum-node*, a rusty-kaspa fork of about 500 crates with
|
||||
# rocksdb, blst and the execution layer) is gitignored here and too big for the free runners today (a cold build is
|
||||
|
|
@ -77,14 +78,14 @@ jobs:
|
|||
run: node tools/ci/public-api-check.mjs https://igneum.network
|
||||
|
||||
red:
|
||||
# Runs only when a master or release-* run has a failed job, on the box's own runner (not a GitHub-hosted machine:
|
||||
# Runs when a run on any branch has a failed job, on the box's own runner (not a GitHub-hosted machine:
|
||||
# the billing block of 6 October 2026, 18:37Z to 20:10Z, failed every hosted job at start and nobody was told).
|
||||
# tools/ci/red-watch.mjs record appends ONE line for this run to /srv/ci-red/red.jsonl (idempotent per run attempt);
|
||||
# the box's igneum-ci-red.timer posts each new line once to the hidden updates channel. Never blocks a release:
|
||||
# it reads the run, writes one line, and ends.
|
||||
name: red watcher (master and release-* only; one line per failed run to the updates channel and the box file)
|
||||
name: red watcher (every branch; one line per failed run, with the branch, commit, red check and pushing author, to the updates channel and the box file)
|
||||
needs: [pow, sims, site]
|
||||
if: ${{ failure() && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-')) }}
|
||||
if: ${{ failure() }}
|
||||
runs-on: [self-hosted, linux, x64, igneum-build-1]
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
|
|
@ -94,8 +95,9 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: tools/ci
|
||||
- name: record this run (one line, the failed jobs and their first failed step, from the run's own API)
|
||||
- name: record this run (one line, the branch, the commit, the failed jobs and their first failed step from the run's own API, the pushing author)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
RED_WATCH_TITLE: ${{ github.event.head_commit.message }}
|
||||
RED_WATCH_AUTHOR: ${{ github.event.head_commit.author.name }}
|
||||
run: node tools/ci/red-watch.mjs record --file /srv/ci-red/red.jsonl
|
||||
|
|
|
|||
|
|
@ -2521,6 +2521,25 @@ same once; a pool user nothing; a fleet operator gets a node that keeps its only
|
|||
every peer (13,354 lines of work it did not need in seven minutes). Owed: the fleet agent's synced-node reading; a receiver-side limit on
|
||||
certificates per index per minute as a second belt once the seed is fixed; the formatter's reflow of `finality.rs` (taken out of the commit).
|
||||
|
||||
## 6 October 2026, Counter ASIC 3.0: the class v4 rehearsal
|
||||
|
||||
A fleet of real GPU boxes ran a fleet-only chain from the devnet's genesis state and crossed a class v4 activation by miner signal, in the shape the live devnet will see. Numbers only; the per-box record is `docs/plans/counter-asic-3-gate/class-v4-20261006-rehearsal-PASS.json`.
|
||||
|
||||
| Number | Value |
|
||||
|---|---|
|
||||
| Nodes on the chain | 16 (15 mining, 1 seed) plus 37 more miners joining after the flip; 1 box left on the old binary as the stale case |
|
||||
| Object | 17 fields, epochs of 600 DAA, the signal window 600 DAA, the threshold 9,500 bps, the floor at DAA 2,400; one consensus digest on every node |
|
||||
| First block | 18:41:10 UTC |
|
||||
| The flip | DAA 1,202, 19:00:5x UTC, by miner signal at epoch 2: 10,000 bps over the window, 599 of 599 blue blocks, the identical line on all 52 signalling nodes |
|
||||
| Program ids | one id per epoch on every box for epochs 2, 3 and 4, each equal to the CPU verifier's class v4 id for that seed and era and different from the class v3 id of the same seed; the pre-flip epoch's id is a class v3 id |
|
||||
| Blocks rejected for proof of work | 0 on every node over the whole run; every miner's CPU re-check mismatched 0 |
|
||||
| The old-binary box | refused at every connect by consensus digest (11 refusals, 22 mismatch lines), never a peer; given the full object it exits at parse |
|
||||
| The floor | crossed at DAA 2,400 with v4 already in force; the chain mined through it at about 1.6 blocks/s; one chain, no fork at the 19:34:54 UTC sweep (heights 2,502 to 2,509, blue 2,432 to 2,440) |
|
||||
| Verifier against the GPU on the first v4 pack | 1,024 of 1,024 nonces at target ff..ff found by the GPU worker and re-derived by the CPU verifier, one digest on both sides |
|
||||
| The stall | 90 s at the flip and then a stop: the shipped worker of the previous release refuses a generator-4 pack by design; the fleet resumed on the release tree's generator-4 worker 15 minutes later |
|
||||
|
||||
What it means per tier: a class change on this chain is decided by the miners' own signal and lands at an epoch boundary with no human release; an old node cannot join the new chain and an old worker cannot mine the new class, so a home miner, a rig and a pool update the node and the worker together before the signal window closes, which the one-click app does in one update; a chip built for the old class mines nothing from the flip. The live cut carries the one lesson: the new object is published only once every node and every worker runs the release that reads it.
|
||||
|
||||
## 6 October 2026, 16:01Z: Ember run 6 on PC 1 (ember-tune-pc1-6, 0.3.13 + kit-6 = 564bdea, elevated, one click)
|
||||
|
||||
The helper registered inside the run but on the scratch copy (fixed: task_exe, the `reregister` verb; see the plan's
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ eight fields, UK time in the footer with UTC in brackets, never a mention, never
|
|||
| Weekly numbers | #numbers | Monday 09:00 | the reddit kit's template (docs/community/reddit/posts/02-weekly-numbers-template.md) as six fields with the last-week column, a link to the ledger |
|
||||
| Release | #announcements | on a publish, by the shipper | version, three download links with full sha256, node commit, consensus digest, up to five "what changed" lines read from the release plan |
|
||||
| Incident open / resolve | #incidents | by hand, or by the watcher | UTC time, what happened, who is affected per tier, what is being done; then cause, the rule or fix, duration |
|
||||
| Network feed | #network-feed | every hour, when `DISCORD_WEBHOOK_FEED` is set | height and blocks/s over the hour, hash rate and difficulty, keys active and voters, the last lock; the daily hash-origin field once per report date from `/api/live state.hash_origin`; milestones once each (every 100,000 blocks, 10,000 locks, 10,000 paid shards; the first crossing of a vote-key count and of a hash-rate step). Added 7 October 2026; docs/community/discord/structure.md section 4.1 |
|
||||
|
||||
Every number comes from the public API (`/api/stats`, `/api/live?window=300`, `/api/supply`; docs/api/public-stats.md). The
|
||||
Devnet 2 line reads the fleet file and uses its numbers and the gate word only; the seed address and the state text never pass.
|
||||
|
|
@ -35,6 +36,7 @@ The watcher's texts are fixed sentences in the script, each a stated rule, and a
|
|||
|
||||
```
|
||||
node tools/community/discord-hooks.mjs pulse | digest | weekly [--live] [--force]
|
||||
node tools/community/discord-hooks.mjs feed [--live] [--via updates] the hourly feed; --via updates posts it through DISCORD_WEBHOOK_UPDATES until #network-feed has a webhook
|
||||
node tools/community/discord-hooks.mjs release 0.3.14 --windows <url> --windows-sha <hex> --mac <url> --mac-sha <hex> --hive <url> --hive-sha <hex> \
|
||||
--node-commit 4c6b129d --digest "b18ed271 (thirteen fields, unchanged)" --plan docs/plans/release-0.3.14.md --section "1. Why" [--changed "line"]... [--live]
|
||||
node tools/community/discord-hooks.mjs incident open --what "..." --affected "..." --doing "..." [--at 2026-10-06T15:44:00Z] [--id inc-...] [--live]
|
||||
|
|
@ -65,7 +67,9 @@ parenthetical dropped, 160 characters at most; a line that trips the guard is dr
|
|||
## Credentials and deployment
|
||||
|
||||
`~/.config/igneum/discord`, mode 600, KEY=VALUE lines: `DISCORD_WEBHOOK_NUMBERS`, `DISCORD_WEBHOOK_ANNOUNCEMENTS`,
|
||||
`DISCORD_WEBHOOK_INCIDENTS`. Never in the repository, never printed; `check` prints which keys are set.
|
||||
`DISCORD_WEBHOOK_INCIDENTS`; optional `DISCORD_WEBHOOK_FEED` (no key, no feed, one "feed off" note per tick) and
|
||||
`DISCORD_WEBHOOK_UPDATES` (the hidden updates channel, the `--via updates` route). Never in the repository, never printed;
|
||||
`check` prints which keys are set.
|
||||
|
||||
The scheduler runs on igneum-build-1 because the Mac sleeps: `infra/build-server/discord-hooks/{igneum-discord-hooks.service,
|
||||
igneum-discord-hooks.timer, install.sh}`, a tick every minute. `install.sh` copies the script to `/srv/discord-hooks/bin`, the
|
||||
|
|
|
|||
68
docs/community/discord/README.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Discord server assets and boost perks
|
||||
|
||||
Server: Igneum (id 1557085229330464808), vanity https://discord.gg/igneum, boosted to level 3 on 7 October 2026 (20 boosts: 14 on the three levels, 3 on the Server Tag add-on, 3 on the Enhanced Role Styles add-on; 0 spare).
|
||||
|
||||
Every file in `assets/` is built from the brand in this repo: the ember mark from `site/index.html`, the colour tokens from `site/site.css` (obsidian #0C0C0E, ember #F2541B, ember-hi #FF6A2B, molten #FFB35C, bone #F4F1EC, ink-2 #C9C7C2, ash #9A9A9E), the fonts in `site/fonts/` (Unbounded for the wordmark, IBM Plex Sans and Mono for the rest) and the DAG step recording `docs/plans/site-ui-3-shots/after-v2/home-steps-1440-dark.webm`.
|
||||
|
||||
Rebuild everything from the repo root:
|
||||
|
||||
```
|
||||
python3 docs/community/discord/make-assets.py [path to a frame of the recording for the invite backdrop]
|
||||
sh docs/community/discord/make-banner-gif.sh
|
||||
```
|
||||
|
||||
The guide banner (1920x480) was rendered with the same helpers; see the commit that added it for the snippet.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Size | Purpose | Where it is set |
|
||||
| --- | --- | --- | --- |
|
||||
| `server-banner-960x540.gif` | 5.9 MB | Animated server banner: nine seconds of the live DAG scene between two dark bands that carry the lockup and the tagline (level 3 perk, limit 10 MB) | Server Settings, Boost Perks, Server Banner Background |
|
||||
| `server-banner-960x540.png` | 42 KB | Static fallback for the same slot | Not uploaded (the GIF is live) |
|
||||
| `banner-overlay-960x540.png` | 16 KB | Transparent overlay ffmpeg composites onto the recording for the GIF | Build input only |
|
||||
| `invite-background-1920x1080.png` | 368 KB | Invite embed and invite page background: lockup and tagline over a blurred, darkened DAG frame | Server Settings, Boost Perks, Server Invite Background |
|
||||
| `guide-banner-1920x480.png` | 69 KB | Server Guide header (4:1) | Server Settings, Onboarding, Server Guide, Server Guide Banner |
|
||||
| `role-*.png` | 2 to 5 KB each, 64x64 | Role icons, one mark variant per role (limit 256 KB) | Server Settings, Roles, each role's Display tab |
|
||||
| `emoji-*.png` | 2 to 8 KB each, 128x128 | The ten custom emoji | Server Settings, Emoji |
|
||||
| `sticker-*.png` | 9 to 24 KB each, 320x320 | The three stickers (limit 512 KB) | Server Settings, Stickers |
|
||||
| `make-assets.py` | | Generator for every PNG above | |
|
||||
| `make-banner-gif.sh` | | ffmpeg recipe for the animated banner | |
|
||||
|
||||
## Role ladder (7 October 2026)
|
||||
|
||||
| Role | Colour | Style | Icon | Hoisted | Permissions |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Founder | unchanged | solid | `role-founder.png` (molten mark on obsidian) | unchanged | untouched |
|
||||
| Core | unchanged | solid | `role-core.png` (ember-hi mark on obsidian) | unchanged | untouched |
|
||||
| Pool operator | #FF6A2B | gradient ember to molten | `role-pool-operator.png` (mark in a molten ring) | yes | none |
|
||||
| Node runner | #F4F1EC | gradient ember to molten | `role-node-runner.png` (bone mark) | yes | none |
|
||||
| Verified miner | #F2541B | gradient ember to molten | `role-verified-miner.png` (ember mark with a tick) | yes | none |
|
||||
| Miner | #F2541B | gradient ember to molten | `role-miner.png` (ember mark) | yes | unchanged |
|
||||
| Early miner | #FFB35C | solid | `role-early-miner.png` (obsidian mark on molten) | no | none |
|
||||
| Prover | #FFB35C | gradient ember to molten | `role-prover.png` (molten mark) | no | unchanged |
|
||||
| Builder | #E8E4DC | solid | `role-builder.png` (bone mark on graphite) | no | Embed Links, Attach Files |
|
||||
| Researcher | #9A9A9E | solid | `role-researcher.png` (ash mark) | no | Embed Links, Attach Files |
|
||||
| Community | #C9C7C2 | solid | `role-community.png` (outlined mark) | no | none |
|
||||
| Bot | #9A9A9E | solid | `role-bot.png` (ash mark on row) | no | unchanged |
|
||||
|
||||
"None" means the role grants nothing of its own; members fall back to @everyone. Early miner is for the first 1,000 miners. Gradient roles use the Enhanced Role Styles add-on with start #F2541B and end #FFB35C. Holographic was tried on Founder and left off: it is a fixed pink and blue shimmer with no colour control and does not read as the brand.
|
||||
|
||||
List order (set by drag on 7 October 2026): Founder, Core, Pool operator, Node runner, Verified miner, Prover, Miner, Early miner, Builder, Researcher, Community, Bot, Server Booster.
|
||||
|
||||
## Emoji and stickers
|
||||
|
||||
Emoji names: `ign_ember`, `ign_block`, `ign_shard`, `ign_lock`, `ign_gpu`, `ign_proven`, `ign_hash`, `ign_letter`, `ign_flame`, `ign_ladder`.
|
||||
|
||||
Stickers: Ember (related emoji fire), Proven (white_check_mark), GPU (desktop_computer). Three of the five free slots are used.
|
||||
|
||||
## Onboarding
|
||||
|
||||
Server Guide: welcome sign plus five to-dos (start-here, mining, ledger, announcements, read the rules) and the guide banner. Pre-join question "What do you mine with?" with five answers: NVIDIA, AMD and Apple grant Miner and #mining; "I run a node" grants Node runner and #devnet; "I build" grants Builder and #proving. Multiple answers allowed, not required.
|
||||
|
||||
## Server Tag
|
||||
|
||||
IGNM, enabled 7 October 2026 on the 3-boost add-on. Badge: Fire (Discord offers only its own pixel-art badge set, no custom upload, so the ember mark cannot be the badge; Fire is the nearest). Badge colour: custom, primary #F2541B. Members adopt the tag from Server Settings, Server Tag ("Adopt Tag") or from their own profile; the founder's account has not adopted it yet.
|
||||
|
||||
## Not set, and why
|
||||
|
||||
- Server Profile banner: that field only offers colour presets; it stays on "Server Icon Colour", which derives from the ember icon.
|
||||
BIN
docs/community/discord/assets/banner-overlay-960x540.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
docs/community/discord/assets/emoji-block.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
docs/community/discord/assets/emoji-flame.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
docs/community/discord/assets/emoji-gpu.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
docs/community/discord/assets/emoji-hash.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
docs/community/discord/assets/emoji-igneum.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
docs/community/discord/assets/emoji-ladder.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
docs/community/discord/assets/emoji-letter.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
docs/community/discord/assets/emoji-lock.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
docs/community/discord/assets/emoji-proven.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
docs/community/discord/assets/emoji-shard.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
docs/community/discord/assets/guide-banner-1920x480.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
docs/community/discord/assets/invite-background-1920x1080.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
docs/community/discord/assets/role-bot.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
docs/community/discord/assets/role-builder.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
docs/community/discord/assets/role-community.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
docs/community/discord/assets/role-core.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
docs/community/discord/assets/role-early-miner.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
docs/community/discord/assets/role-founder.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
docs/community/discord/assets/role-miner.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
docs/community/discord/assets/role-node-runner.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
docs/community/discord/assets/role-pool-operator.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
docs/community/discord/assets/role-prover.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
docs/community/discord/assets/role-researcher.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
docs/community/discord/assets/role-verified-miner.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
docs/community/discord/assets/server-banner-960x540.gif
Normal file
|
After Width: | Height: | Size: 5.6 MiB |
BIN
docs/community/discord/assets/server-banner-960x540.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
docs/community/discord/assets/sticker-ember.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
BIN
docs/community/discord/assets/sticker-gpu.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
docs/community/discord/assets/sticker-proven.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
379
docs/community/discord/make-assets.py
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Build the Discord boost assets from the brand (mark, fonts, tokens in site/site.css).
|
||||
|
||||
Run from the repo root: python3 docs/community/discord/make-assets.py
|
||||
Writes into docs/community/discord/assets/. The animated banner is built by ffmpeg from
|
||||
docs/plans/site-ui-3-shots/after-v2/home-steps-1440-dark.webm (see make-banner-gif.sh).
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
OUT = os.path.join(ROOT, "docs", "community", "discord", "assets")
|
||||
FONTS = os.path.join(ROOT, "site", "fonts")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
# site.css tokens (dark theme)
|
||||
OBSIDIAN = (12, 12, 14)
|
||||
GRAPHITE = (22, 22, 26)
|
||||
ROW = (17, 17, 20)
|
||||
LINE = (42, 42, 48)
|
||||
LINE2 = (58, 58, 66)
|
||||
EMBER = (242, 84, 27)
|
||||
EMBER_HI = (255, 106, 43)
|
||||
MOLTEN = (255, 179, 92)
|
||||
BONE = (244, 241, 236)
|
||||
INK2 = (201, 199, 194)
|
||||
ASH = (154, 154, 158)
|
||||
|
||||
# The ember mark, from site/index.html (viewBox 100 units)
|
||||
OUTER = [(50, 4), (74, 34), (67, 58), (80, 54), (61, 96), (39, 96), (20, 54), (33, 58), (26, 34)]
|
||||
INNER = [(50, 42), (59, 58), (50, 82), (41, 58)]
|
||||
SS = 4 # supersample
|
||||
|
||||
|
||||
def font(name, size):
|
||||
return ImageFont.truetype(os.path.join(FONTS, name + ".woff2"), size)
|
||||
|
||||
|
||||
def rgba(color, a=255):
|
||||
return tuple(color) + (a,)
|
||||
|
||||
|
||||
def mark(size, color=EMBER, cut=None, box=None):
|
||||
"""Return an RGBA image of the ember mark. `cut` fills the inner diamond (None = transparent)."""
|
||||
W = size * SS
|
||||
im = Image.new("RGBA", (W, W), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(im)
|
||||
if box is not None:
|
||||
d.rounded_rectangle([0, 0, W - 1, W - 1], radius=int(W * 0.16), fill=rgba(box))
|
||||
pad = 0.12 if box is None else 0.19
|
||||
s = W * (1 - 2 * pad) / 100.0
|
||||
o = W * pad
|
||||
pts = [(o + x * s, o + y * s) for x, y in OUTER]
|
||||
d.polygon(pts, fill=rgba(color))
|
||||
inner = [(o + x * s, o + y * s) for x, y in INNER]
|
||||
if cut is None:
|
||||
hole = Image.new("L", (W, W), 0)
|
||||
ImageDraw.Draw(hole).polygon(inner, fill=255)
|
||||
alpha = im.getchannel("A")
|
||||
alpha = Image.composite(Image.new("L", (W, W), 0), alpha, hole)
|
||||
im.putalpha(alpha)
|
||||
else:
|
||||
d.polygon(inner, fill=rgba(cut))
|
||||
return im.resize((size, size), Image.LANCZOS)
|
||||
|
||||
|
||||
def text_size(d, txt, f):
|
||||
l, t, r, b = d.textbbox((0, 0), txt, font=f)
|
||||
return r - l, b - t, l, t
|
||||
|
||||
|
||||
def lockup(width, mark_px, word_px, word_color=BONE, mark_color=EMBER, gap=None):
|
||||
"""Mark + IGNEUM wordmark on a transparent strip, returned as RGBA with the strip height."""
|
||||
f = font("unbounded-900", word_px)
|
||||
probe = ImageDraw.Draw(Image.new("RGBA", (10, 10)))
|
||||
tw, th, tl, tt = text_size(probe, "IGNEUM", f)
|
||||
gap = gap or int(mark_px * 0.32)
|
||||
H = max(mark_px, th + 8)
|
||||
im = Image.new("RGBA", (mark_px + gap + tw + 4, H), (0, 0, 0, 0))
|
||||
m = mark(mark_px, mark_color)
|
||||
im.alpha_composite(m, (0, (H - mark_px) // 2))
|
||||
d = ImageDraw.Draw(im)
|
||||
d.text((mark_px + gap - tl, (H - th) // 2 - tt), "IGNEUM", font=f, fill=rgba(word_color))
|
||||
return im
|
||||
|
||||
|
||||
def glow(base, mark_px, cx, cy, color=EMBER, spread=1.6, alpha=70):
|
||||
g = Image.new("RGBA", base.size, (0, 0, 0, 0))
|
||||
r = int(mark_px * spread / 2)
|
||||
ImageDraw.Draw(g).ellipse([cx - r, cy - r, cx + r, cy + r], fill=rgba(color, alpha))
|
||||
g = g.filter(ImageFilter.GaussianBlur(mark_px * 0.45))
|
||||
base.alpha_composite(g)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- banners
|
||||
def banner_static(W, H, name, mark_px, word_px, tag_px, sub_px, backdrop=None):
|
||||
im = Image.new("RGBA", (W, H), rgba(OBSIDIAN))
|
||||
if backdrop is not None:
|
||||
bd = Image.open(backdrop).convert("RGB")
|
||||
# crop the recording frame to 16:9 and darken it
|
||||
bw, bh = bd.size
|
||||
ch = int(bw * H / W)
|
||||
bd = bd.crop((0, 60, bw, 60 + ch)).resize((W, H), Image.LANCZOS)
|
||||
bd = bd.filter(ImageFilter.GaussianBlur(W * 0.004))
|
||||
bd = Image.blend(Image.new("RGB", (W, H), OBSIDIAN), bd, 0.34)
|
||||
im.paste(bd, (0, 0))
|
||||
scrim = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
sd = ImageDraw.Draw(scrim)
|
||||
for y in range(H):
|
||||
a = int(255 * (0.25 + 0.55 * (y / H) ** 1.4))
|
||||
sd.line([(0, y), (W, y)], fill=rgba(OBSIDIAN, min(255, a)))
|
||||
im.alpha_composite(scrim)
|
||||
lk = lockup(W, mark_px, word_px)
|
||||
cx = (W - lk.width) // 2
|
||||
cy = int(H * 0.40) - lk.height // 2
|
||||
glow(im, mark_px, cx + mark_px // 2, cy + lk.height // 2)
|
||||
im.alpha_composite(lk, (cx, cy))
|
||||
d = ImageDraw.Draw(im)
|
||||
f_tag = font("unbounded-700", tag_px)
|
||||
tw, th, tl, tt = text_size(d, "Mined by GPUs. Proven by fire.", f_tag)
|
||||
ty = cy + lk.height + int(H * 0.06)
|
||||
d.text(((W - tw) // 2 - tl, ty - tt), "Mined by GPUs. Proven by fire.", font=f_tag, fill=rgba(MOLTEN))
|
||||
f_sub = font("plex-sans-500", sub_px)
|
||||
sub = "The GPU-mined layer 1 · igneum.network"
|
||||
sw, sh, sl, st = text_size(d, sub, f_sub)
|
||||
d.text(((W - sw) // 2 - sl, ty + th + int(H * 0.035) - st), sub, font=f_sub, fill=rgba(INK2))
|
||||
path = os.path.join(OUT, name)
|
||||
im.convert("RGB").save(path, optimize=True)
|
||||
return path
|
||||
|
||||
|
||||
def banner_overlay(W, H, name, mark_px, word_px, band):
|
||||
"""Transparent overlay for the animated banner: the graph sits between two dark bands
|
||||
(ffmpeg pads it); the lockup goes in the top band, the tagline in the bottom band."""
|
||||
im = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(im)
|
||||
lk = lockup(W, mark_px, word_px)
|
||||
x = int(W * 0.035)
|
||||
im.alpha_composite(lk, (x, (band - lk.height) // 2))
|
||||
f_tag = font("unbounded-700", int(word_px * 0.62))
|
||||
tw, th, tl, tt = text_size(d, "Mined by GPUs. Proven by fire.", f_tag)
|
||||
d.text((x - tl, H - band + (band - th) // 2 - tt), "Mined by GPUs. Proven by fire.", font=f_tag, fill=rgba(MOLTEN))
|
||||
f_sub = font("plex-sans-500", int(word_px * 0.5))
|
||||
sw, sh, sl, st = text_size(d, "igneum.network", f_sub)
|
||||
d.text((W - x - sw - sl, H - band + (band - sh) // 2 - st), "igneum.network", font=f_sub, fill=rgba(INK2))
|
||||
path = os.path.join(OUT, name)
|
||||
im.save(path, optimize=True)
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- role icons
|
||||
def role_icons():
|
||||
out = {}
|
||||
spec = {
|
||||
"founder": dict(color=MOLTEN, box=OBSIDIAN),
|
||||
"core": dict(color=EMBER_HI, box=OBSIDIAN),
|
||||
"miner": dict(color=EMBER),
|
||||
"prover": dict(color=MOLTEN),
|
||||
"pool-operator": dict(color=EMBER_HI, ring=True),
|
||||
"node-runner": dict(color=BONE),
|
||||
"verified-miner": dict(color=EMBER, tick=True),
|
||||
"early-miner": dict(color=OBSIDIAN, box=MOLTEN),
|
||||
"builder": dict(color=BONE, box=GRAPHITE),
|
||||
"researcher": dict(color=ASH),
|
||||
"community": dict(color=INK2, outline=True),
|
||||
"bot": dict(color=ASH, box=ROW),
|
||||
}
|
||||
for name, s in spec.items():
|
||||
size = 64
|
||||
W = size * SS
|
||||
if s.get("outline"):
|
||||
im = Image.new("RGBA", (W, W), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(im)
|
||||
pad = 0.12
|
||||
sc = W * (1 - 2 * pad) / 100.0
|
||||
o = W * pad
|
||||
pts = [(o + x * sc, o + y * sc) for x, y in OUTER]
|
||||
d.line(pts + [pts[0]], fill=rgba(s["color"]), width=int(W * 0.055), joint="curve")
|
||||
inner = [(o + x * sc, o + y * sc) for x, y in INNER]
|
||||
d.polygon(inner, fill=rgba(s["color"]))
|
||||
im = im.resize((size, size), Image.LANCZOS)
|
||||
else:
|
||||
im = mark(size, s["color"], cut=(s["box"] if s.get("box") else None), box=s.get("box"))
|
||||
if s.get("ring"):
|
||||
big = im.resize((W, W), Image.LANCZOS)
|
||||
d = ImageDraw.Draw(big)
|
||||
d.ellipse([W * 0.02, W * 0.02, W * 0.98, W * 0.98], outline=rgba(MOLTEN), width=int(W * 0.05))
|
||||
im = big.resize((size, size), Image.LANCZOS)
|
||||
if s.get("tick"):
|
||||
big = im.resize((W, W), Image.LANCZOS)
|
||||
d = ImageDraw.Draw(big)
|
||||
r = W * 0.19
|
||||
cx, cy = W * 0.80, W * 0.80
|
||||
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=rgba(MOLTEN))
|
||||
d.line([(cx - r * 0.5, cy), (cx - r * 0.1, cy + r * 0.42), (cx + r * 0.55, cy - r * 0.42)],
|
||||
fill=rgba(OBSIDIAN), width=int(W * 0.045), joint="curve")
|
||||
im = big.resize((size, size), Image.LANCZOS)
|
||||
path = os.path.join(OUT, "role-%s.png" % name)
|
||||
im.save(path, optimize=True)
|
||||
out[name] = path
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- emoji
|
||||
def emoji_canvas(size=128):
|
||||
W = size * SS
|
||||
return Image.new("RGBA", (W, W), (0, 0, 0, 0)), W
|
||||
|
||||
|
||||
def finish(im, size, path):
|
||||
im.resize((size, size), Image.LANCZOS).save(path, optimize=True)
|
||||
return path
|
||||
|
||||
|
||||
def emoji_set():
|
||||
size = 128
|
||||
paths = {}
|
||||
|
||||
# 1 the ember
|
||||
paths["igneum"] = mark(size, EMBER)
|
||||
paths["igneum"].save(os.path.join(OUT, "emoji-igneum.png"), optimize=True)
|
||||
paths["igneum"] = os.path.join(OUT, "emoji-igneum.png")
|
||||
|
||||
# 2 a block (the DAG square: rounded outline, ember, dark fill)
|
||||
im, W = emoji_canvas()
|
||||
d = ImageDraw.Draw(im)
|
||||
m = W * 0.12
|
||||
d.rounded_rectangle([m, m, W - m, W - m], radius=int(W * 0.14), fill=rgba(GRAPHITE),
|
||||
outline=rgba(EMBER), width=int(W * 0.085))
|
||||
paths["block"] = finish(im, size, os.path.join(OUT, "emoji-block.png"))
|
||||
|
||||
# 3 a shard (a tall molten crystal with one lit facet)
|
||||
im, W = emoji_canvas()
|
||||
d = ImageDraw.Draw(im)
|
||||
p = [(0.50, 0.06), (0.78, 0.40), (0.62, 0.94), (0.38, 0.94), (0.22, 0.40)]
|
||||
d.polygon([(x * W, y * W) for x, y in p], fill=rgba(MOLTEN))
|
||||
d.polygon([(x * W, y * W) for x, y in [(0.50, 0.06), (0.78, 0.40), (0.62, 0.94), (0.50, 0.40)]], fill=rgba(EMBER))
|
||||
paths["shard"] = finish(im, size, os.path.join(OUT, "emoji-shard.png"))
|
||||
|
||||
# 4 a lock (ember body, bone shackle, dark keyhole)
|
||||
im, W = emoji_canvas()
|
||||
d = ImageDraw.Draw(im)
|
||||
sw = int(W * 0.09)
|
||||
d.arc([W * 0.26, W * 0.06, W * 0.74, W * 0.60], 180, 360, fill=rgba(BONE), width=sw)
|
||||
d.line([(W * 0.26 + sw / 2, W * 0.33), (W * 0.26 + sw / 2, W * 0.50)], fill=rgba(BONE), width=sw)
|
||||
d.line([(W * 0.74 - sw / 2, W * 0.33), (W * 0.74 - sw / 2, W * 0.50)], fill=rgba(BONE), width=sw)
|
||||
d.rounded_rectangle([W * 0.14, W * 0.46, W * 0.86, W * 0.94], radius=int(W * 0.10), fill=rgba(EMBER))
|
||||
d.ellipse([W * 0.43, W * 0.60, W * 0.57, W * 0.74], fill=rgba(OBSIDIAN))
|
||||
d.rounded_rectangle([W * 0.465, W * 0.68, W * 0.535, W * 0.84], radius=int(W * 0.03), fill=rgba(OBSIDIAN))
|
||||
paths["lock"] = finish(im, size, os.path.join(OUT, "emoji-lock.png"))
|
||||
|
||||
# 5 a GPU (graphite card, two ember fans, bone bracket)
|
||||
im, W = emoji_canvas()
|
||||
d = ImageDraw.Draw(im)
|
||||
d.rounded_rectangle([W * 0.06, W * 0.24, W * 0.94, W * 0.78], radius=int(W * 0.08), fill=rgba(GRAPHITE),
|
||||
outline=rgba(LINE2), width=int(W * 0.03))
|
||||
d.rectangle([W * 0.06, W * 0.78, W * 0.16, W * 0.90], fill=rgba(BONE))
|
||||
d.rectangle([W * 0.20, W * 0.74, W * 0.80, W * 0.80], fill=rgba(LINE2))
|
||||
for cx in (0.34, 0.66):
|
||||
r = W * 0.17
|
||||
d.ellipse([cx * W - r, W * 0.51 - r, cx * W + r, W * 0.51 + r], outline=rgba(EMBER), width=int(W * 0.045))
|
||||
for k in range(6):
|
||||
a = k * math.pi / 3
|
||||
d.line([(cx * W, W * 0.51), (cx * W + math.cos(a) * r * 0.85, W * 0.51 + math.sin(a) * r * 0.85)],
|
||||
fill=rgba(EMBER), width=int(W * 0.035))
|
||||
d.ellipse([cx * W - r * 0.22, W * 0.51 - r * 0.22, cx * W + r * 0.22, W * 0.51 + r * 0.22], fill=rgba(MOLTEN))
|
||||
paths["gpu"] = finish(im, size, os.path.join(OUT, "emoji-gpu.png"))
|
||||
|
||||
# 6 the proof tick (ember rounded square, bone tick)
|
||||
im, W = emoji_canvas()
|
||||
d = ImageDraw.Draw(im)
|
||||
d.rounded_rectangle([W * 0.08, W * 0.08, W * 0.92, W * 0.92], radius=int(W * 0.18), fill=rgba(EMBER))
|
||||
d.line([(W * 0.28, W * 0.52), (W * 0.44, W * 0.68), (W * 0.74, W * 0.34)], fill=rgba(BONE), width=int(W * 0.10),
|
||||
joint="curve")
|
||||
paths["proven"] = finish(im, size, os.path.join(OUT, "emoji-proven.png"))
|
||||
|
||||
# 7 the hash glyph (Plex Mono)
|
||||
im, W = emoji_canvas()
|
||||
d = ImageDraw.Draw(im)
|
||||
f = font("plex-mono-500", int(W * 0.92))
|
||||
tw, th, tl, tt = text_size(d, "#", f)
|
||||
d.text(((W - tw) / 2 - tl, (W - th) / 2 - tt), "#", font=f, fill=rgba(EMBER))
|
||||
paths["hash"] = finish(im, size, os.path.join(OUT, "emoji-hash.png"))
|
||||
|
||||
# 8 the wordmark letter (bone I on an ember square)
|
||||
im, W = emoji_canvas()
|
||||
d = ImageDraw.Draw(im)
|
||||
d.rounded_rectangle([W * 0.08, W * 0.08, W * 0.92, W * 0.92], radius=int(W * 0.18), fill=rgba(OBSIDIAN))
|
||||
f = font("unbounded-900", int(W * 0.62))
|
||||
tw, th, tl, tt = text_size(d, "I", f)
|
||||
d.text(((W - tw) / 2 - tl, (W - th) / 2 - tt), "I", font=f, fill=rgba(EMBER))
|
||||
paths["letter"] = finish(im, size, os.path.join(OUT, "emoji-letter.png"))
|
||||
|
||||
# 9 a flame (ember outer, molten core)
|
||||
im, W = emoji_canvas()
|
||||
d = ImageDraw.Draw(im)
|
||||
outer = [(0.50, 0.04), (0.64, 0.22), (0.70, 0.40), (0.82, 0.30), (0.88, 0.58), (0.80, 0.84), (0.62, 0.96),
|
||||
(0.38, 0.96), (0.20, 0.84), (0.12, 0.58), (0.20, 0.36), (0.32, 0.44), (0.34, 0.24)]
|
||||
d.polygon([(x * W, y * W) for x, y in outer], fill=rgba(EMBER))
|
||||
core = [(0.50, 0.46), (0.62, 0.60), (0.66, 0.78), (0.58, 0.92), (0.42, 0.92), (0.34, 0.78), (0.38, 0.60)]
|
||||
d.polygon([(x * W, y * W) for x, y in core], fill=rgba(MOLTEN))
|
||||
paths["flame"] = finish(im, size, os.path.join(OUT, "emoji-flame.png"))
|
||||
|
||||
# 10 the ladder (two ember rails, bone rungs)
|
||||
im, W = emoji_canvas()
|
||||
d = ImageDraw.Draw(im)
|
||||
rw = int(W * 0.09)
|
||||
d.line([(W * 0.28, W * 0.06), (W * 0.28, W * 0.94)], fill=rgba(EMBER), width=rw)
|
||||
d.line([(W * 0.72, W * 0.06), (W * 0.72, W * 0.94)], fill=rgba(EMBER), width=rw)
|
||||
for k in range(5):
|
||||
y = W * (0.16 + k * 0.17)
|
||||
d.line([(W * 0.28, y), (W * 0.72, y)], fill=rgba(BONE), width=int(W * 0.07))
|
||||
paths["ladder"] = finish(im, size, os.path.join(OUT, "emoji-ladder.png"))
|
||||
return paths
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- stickers (320x320)
|
||||
def stickers():
|
||||
size = 320
|
||||
paths = {}
|
||||
# 1 the ember, large
|
||||
m = mark(size, EMBER)
|
||||
p = os.path.join(OUT, "sticker-ember.png")
|
||||
m.save(p, optimize=True)
|
||||
paths["ember"] = p
|
||||
|
||||
# 2 "Proven by fire." badge
|
||||
im = Image.new("RGBA", (size * SS, size * SS), (0, 0, 0, 0))
|
||||
W = size * SS
|
||||
d = ImageDraw.Draw(im)
|
||||
d.rounded_rectangle([W * 0.03, W * 0.30, W * 0.97, W * 0.70], radius=int(W * 0.10), fill=rgba(OBSIDIAN),
|
||||
outline=rgba(EMBER), width=int(W * 0.02))
|
||||
f1 = font("unbounded-900", int(W * 0.115))
|
||||
f2 = font("unbounded-700", int(W * 0.062))
|
||||
tw, th, tl, tt = text_size(d, "PROVEN", f1)
|
||||
d.text(((W - tw) / 2 - tl, W * 0.37 - tt), "PROVEN", font=f1, fill=rgba(BONE))
|
||||
tw2, th2, tl2, tt2 = text_size(d, "by fire.", f2)
|
||||
d.text(((W - tw2) / 2 - tl2, W * 0.37 + th + W * 0.04 - tt2), "by fire.", font=f2, fill=rgba(MOLTEN))
|
||||
p = os.path.join(OUT, "sticker-proven.png")
|
||||
im.resize((size, size), Image.LANCZOS).save(p, optimize=True)
|
||||
paths["proven"] = p
|
||||
|
||||
# 3 GPU with the ember rising out of it
|
||||
im = Image.new("RGBA", (W, W), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(im)
|
||||
em = mark(int(size * 0.55), EMBER).resize((int(W * 0.55), int(W * 0.55)), Image.LANCZOS)
|
||||
im.alpha_composite(em, (int(W * 0.225), int(W * 0.02)))
|
||||
d.rounded_rectangle([W * 0.06, W * 0.52, W * 0.94, W * 0.86], radius=int(W * 0.07), fill=rgba(GRAPHITE),
|
||||
outline=rgba(LINE2), width=int(W * 0.02))
|
||||
d.rectangle([W * 0.06, W * 0.86, W * 0.16, W * 0.95], fill=rgba(BONE))
|
||||
for cx in (0.34, 0.66):
|
||||
r = W * 0.12
|
||||
cy = W * 0.69
|
||||
d.ellipse([cx * W - r, cy - r, cx * W + r, cy + r], outline=rgba(EMBER), width=int(W * 0.03))
|
||||
for k in range(6):
|
||||
a = k * math.pi / 3
|
||||
d.line([(cx * W, cy), (cx * W + math.cos(a) * r * 0.85, cy + math.sin(a) * r * 0.85)],
|
||||
fill=rgba(EMBER), width=int(W * 0.025))
|
||||
d.ellipse([cx * W - r * 0.22, cy - r * 0.22, cx * W + r * 0.22, cy + r * 0.22], fill=rgba(MOLTEN))
|
||||
p = os.path.join(OUT, "sticker-gpu.png")
|
||||
im.resize((size, size), Image.LANCZOS).save(p, optimize=True)
|
||||
paths["gpu"] = p
|
||||
return paths
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
frame = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
print(banner_static(960, 540, "server-banner-960x540.png", 150, 96, 30, 20))
|
||||
print(banner_static(1920, 1080, "invite-background-1920x1080.png", 300, 192, 60, 40, backdrop=frame))
|
||||
print(banner_overlay(960, 540, "banner-overlay-960x540.png", 46, 30, 87))
|
||||
for k, v in role_icons().items():
|
||||
print(k, v, os.path.getsize(v))
|
||||
for k, v in emoji_set().items():
|
||||
print(k, v, os.path.getsize(v))
|
||||
for k, v in stickers().items():
|
||||
print(k, v, os.path.getsize(v))
|
||||
11
docs/community/discord/make-banner-gif.sh
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
#!/bin/sh
|
||||
# Animated server banner (level 3 perk): nine seconds of the DAG step recording (from 1 s, after the hero fold), graph only,
|
||||
# padded into two dark bands that carry the lockup and the tagline (banner-overlay-960x540.png).
|
||||
# Run from the repo root after make-assets.py. Output stays under Discord's 10 MB banner limit.
|
||||
set -e
|
||||
A=docs/community/discord/assets
|
||||
SRC=docs/plans/site-ui-3-shots/after-v2/home-steps-1440-dark.webm
|
||||
ffmpeg -v error -y -ss 1 -t 9 -i "$SRC" -i "$A/banner-overlay-960x540.png" -filter_complex \
|
||||
"[0:v]crop=1340:510:50:118,pad=1340:754:0:122:color=#0C0C0E,scale=960:540,fps=12[b];[b][1:v]overlay=0:0,split[a][c];[a]palettegen=max_colors=160:stats_mode=diff[p];[c][p]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle" \
|
||||
"$A/server-banner-960x540.gif"
|
||||
ls -la "$A/server-banner-960x540.gif"
|
||||
333
docs/community/discord/structure.md
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
# Discord server structure: the build sheet and the changelog
|
||||
|
||||
Server Igneum (id 1557085229330464808, https://discord.gg/igneum). This file is the structure half of the server; the
|
||||
cosmetics half (banner, roles ladder, emoji, stickers, server guide, pre-join question, server tag, vanity) is in
|
||||
README.md. Every item below is a row with a "set / read back" state. A row is set only when it has been read back
|
||||
from the server after the change. Nothing is posted that names a number our files do not hold.
|
||||
|
||||
The founder, 7 October 2026, 12:0x UK: "is discord fully setup and optimised to house an amazing community?" Cosmetics yes;
|
||||
structure no. This sheet is the structure.
|
||||
|
||||
## Status, 7 October 2026, 13:1x UK
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Chrome profile | confirmed: the signed-in Google account on the tab is the igneum.network login, the Igneum profile; the Discord account is igneum_network (Founder) |
|
||||
| Discord session | expired at 11:1x UK (nothing typed); the founder signed in at 12:5x UK and the sheet was built in one pass from 12:5x to 13:1x UK |
|
||||
| Channels | 24 channels in 4 categories, every topic set, 19 pinned first posts, slowmode 10 s on every talk and support channel, threads on, read-only where the sheet says (section 1, every row "set, read back") |
|
||||
| Moderation | 7 AutoMod rules live (section 2), verification Medium, explicit filter on all members, raid protection 3 of 3, DM protection 5 of 5, prune restricted to admins, #mod-log private with every alert, rules screen on with the four lines |
|
||||
| Onboarding | pre-join answers re-pointed at the new map, 5 server-guide to-dos, welcome sign rewritten, safety notifications to #mod-log |
|
||||
| Network feed | LIVE from the box every hour: `DISCORD_WEBHOOK_FEED` created (webhook "Igneum feed" on #network-feed, 13:0x UK), written to `~/.config/igneum/discord`, `install.sh` run with the ruling flag at 13:05 UK; the box's first hourly post `feed:2026-10-07:13` landed at 13:06 UK (message 1557363371605622818) and the next tick was "0 due". The Mac's proof posts: `feed:2026-10-07:11` through the updates webhook (11:30 UK) and `feed:2026-10-07:12-proof` through the new webhook (13:05 UK, message 1557363266689171547) |
|
||||
| Not done, owed | 2FA for moderation (the founder account's 2FA is off: Discord greys the switch); the office-hour event (Discord has no undated event; the template is section 5, created the day a time is picked). Done at 13:1x UK: post 1's "How this channel works" paragraph edited through the announcements webhook (PATCH 200, edited 12:11:37 UTC); the release webhook moved to #releases |
|
||||
|
||||
## 1. Channel map
|
||||
|
||||
Order top to bottom as the sidebar shows it. "Threads" means Create Public Threads allowed for @everyone and the
|
||||
channel's own threads kept (auto-archive 3 days). Slowmode is 10 s where set. A pinned post is the channel's first
|
||||
message, posted from the founder's account (the webhook posts only the bot's numbers), then pinned.
|
||||
|
||||
### What changes from the current server
|
||||
|
||||
| Today | Becomes | Why |
|
||||
|---|---|---|
|
||||
| #numbers | #network-feed, read-only, under START HERE's bot row moved to its own place below MINE | one bot channel; the pulse, the digest, the weekly, the hash-origin report and the hourly feed all land here. `DISCORD_WEBHOOK_NUMBERS` keeps working (the webhook follows the channel through a rename); `DISCORD_WEBHOOK_FEED` is set to the same channel's URL (a second webhook named "Igneum feed", or the same URL copied) |
|
||||
| #first-blocks | #first-block | the brief's name; one block per post, so the singular reads right. ladder.md says #first-blocks; this sheet is the later word |
|
||||
| #ask | removed | the four support channels and #mining take questions; the pinned FAQ answers the common ones; #ask's messages are read once for anything worth pinning before deletion |
|
||||
| #wallet | removed | wallet questions go to the support channel of the OS; the wallet page is linked from every support pin |
|
||||
| #incidents | kept, under START HERE after #releases | the watcher already posts there; cause and fix in public is the ledger's rule |
|
||||
| #ledger | kept, under BUILD last | criticism with a ledger id and a date, as announcement 1 says |
|
||||
| #discord-updates | kept, hidden (Founder and Core only) | the CI red watcher and the feed's proof post use its webhook |
|
||||
| #mining, #devnet, #proving, #start-here, #announcements, #rules | kept, topics and pins set below | |
|
||||
|
||||
### START HERE (read-only for @everyone: Send Messages off on the category; Core and Founder post)
|
||||
|
||||
| Channel | Topic | Pinned first post | Settings | Set / read back |
|
||||
|---|---|---|---|---|
|
||||
| #start-here | What Igneum is, what to do first, the ladder. | the welcome post (section 3.3) | read-only; the server guide's first to-do | set, read back |
|
||||
| #rules | Three rules. Read once. | the rules post (section 2.6, the same words as the rules screen) | read-only | set, read back |
|
||||
| #announcements | Releases and chain events only. Announcement channel (followable). | announcement 1 is already here (announcement-01.md); edit its "How this channel works" line to: "Releases in #releases, numbers every hour in #network-feed from the labelled bot. Incidents, with cause and fix, in #incidents. Questions in #mining or the support channel for your OS, criticism in #ledger, where it gets a ledger id and a date." | read-only; Announcement channel type | set, read back |
|
||||
| #releases | One post per release: version, downloads, sha256, what changed. The app updates itself. (The "Igneum" release webhook was moved here from #announcements at 13:13 UK; read back from Discord: channel 1557346271726141471.) | "Every release lands here from the bot: the version, the three downloads with their sha256, the node commit, up to five lines on what changed. The app updates itself over the air at a safe moment; a fresh install is at igneum.network/miner. A release is posted after it has crossed the staging chain, never before." | read-only; `DISCORD_WEBHOOK_ANNOUNCEMENTS` moves here (Channel settings, Integrations, Webhooks, edit the channel of the "Igneum" webhook); #announcements keeps human posts only | set, read back |
|
||||
| #incidents | What broke, who it affects, what is being done. Then the cause and the fix. | "An incident is posted when it opens and when it resolves: the UTC time, what happened, who is affected per tier, what is being done; then the cause, the fix and how long it lasted. Three are automatic from the public API: finality paused over five minutes, proving over fifteen minutes behind, the live numbers stale over three minutes. Everything else a person wrote." | read-only | set, read back |
|
||||
|
||||
### MINE
|
||||
|
||||
| Channel | Topic | Pinned first post | Settings | Set / read back |
|
||||
|---|---|---|---|---|
|
||||
| #mining | Mining talk: cards, rates, settings, what your card is doing. | "Mining questions go here. Say the card, the OS and the app version; the Cards page shows all three. Every rate the project publishes has a row in the bench table (igneum.network/miners) with the command and the hardware. Nothing here is estimated earnings. No price talk: there is no market and nothing is for sale." | slowmode 10 s; threads on | set, read back |
|
||||
| #rig-photos | Your rig, your card, your first block card. Photos only, a line of text. | "Post the rig. One photo or the saved block card, one line: the cards, the OS, the rate the app shows. No serial numbers, no machine names, no payout addresses in the shot. Threads for the replies, so the photos stay a wall." | threads on; slowmode 10 s; Attach Files on for @everyone in this channel only | set, read back |
|
||||
| #first-block | Every first block, posted by the bot. Your reply under it. | "When a key finds its first blue block the bot posts it here: the short key id and the block link, and the card if the miner switched 'Make my page public' on in the app. Reply in the thread under it. The next rungs are in #start-here." | threads on; Send Messages off for @everyone, Send Messages in Threads on; the ladder bot posts (section 4.2) | set, read back |
|
||||
| #benchmarks | Your card's rate and watts, the command, the app version. The bench table is built from rows like these. | "A benchmark is a row: card, driver, OS, app version, MH/s, W, and the command or the screen it came from. The project's own rows are at igneum.network/miners with the hardware named. Say the watts; a row without them is not used." | slowmode 10 s; threads on | set, read back |
|
||||
| #support-windows | Windows: install, SmartScreen, the firewall prompt, drivers. | the FAQ post (section 1.5) with the Windows line first: "SmartScreen shows 'Windows protected your PC' on a new build: More info, then Run anyway. The firewall prompt comes 20 to 50 seconds in; it is the app opening its port." | slowmode 10 s; threads on by default | set, read back |
|
||||
| #support-mac | macOS: Gatekeeper, Open Anyway, Apple silicon. | the FAQ post with the Mac line first: "macOS refuses an app it has not seen: System Settings, Privacy and Security, Open Anyway. Apple silicon mines; it proves on the CPU, slowly." | slowmode 10 s; threads on by default | set, read back |
|
||||
| #support-linux-hiveos | Linux and HiveOS: the tarball, the flight sheet, drivers. | the FAQ post with the Linux line first: "The Linux and HiveOS tarball and the flight sheet are at igneum.network/miner. Say the distribution and the driver version with every question." | slowmode 10 s; threads on by default | set, read back |
|
||||
| #pools | Pools: the project's pool-0 when it opens, and every outside pool with a signed statement. | "Until the public testnet every machine mines solo on its own keys, and a card runs several. Pools come with the testnet; the project runs pool-0 at 1 percent, the same as the optional software fee, and an outside pool is listed here once it publishes a signed key list. A pool never holds your vote weight: the member's own vote key is in every header." | slowmode 10 s; threads on | set, read back |
|
||||
|
||||
### #network-feed (its own row under MINE; the bot's only voice)
|
||||
|
||||
| Channel | Topic | Pinned first post | Settings | Set / read back |
|
||||
|---|---|---|---|---|
|
||||
| #network-feed | Numbers from the public API, every hour. Read-only. | "Every hour: height, hash rate, keys and the last lock, from igneum.network/api/live. Four times a day the fuller pulse, at 09:00 UK the daily digest, Monday the weekly numbers, daily the hash-origin report (who found the blocks). Milestones once each. The bot never replies; questions go to #mining." | Send Messages off for @everyone and every role; webhooks "Igneum" (numbers) and "Igneum feed" (feed) | set, read back; two feed posts in the channel at 13:05 and 13:06 UK |
|
||||
|
||||
### BUILD
|
||||
|
||||
| Channel | Topic | Pinned first post | Settings | Set / read back |
|
||||
|---|---|---|---|---|
|
||||
| #devnet | The devnet: resets, activations, what the node is doing. | "The devnet has mined since 3 October 2026. Coins on it have no value and the chain may be reset. A consensus change flips when 95 percent of mining weight signals it, with a floor height as the backstop; it is announced here first. The staging chain crosses first, every time." | slowmode 10 s; threads on | set, read back |
|
||||
| #proving | Proving: shards, the proof pool, what your card can prove. | "Every block is proven in shards by miners' cards and paid from the block. The Prove page of the app says in one sentence what your card can do: the full shard needs a 24 GB NVIDIA card, a 32 GB card mines and proves at once, Apple silicon proves on the CPU. Proof lag and shards paid are in #network-feed." | slowmode 10 s; threads on | set, read back |
|
||||
| #node-runners | Running a node: sync, peers, the RPC, the snapshot. | "A node runner's channel. Say the version, the height and the peer count; the node prints all three. A node that pauses finality reports it itself. Snapshots are refused below the node's tip; a reorg never resets execution. Node problems that look like consensus go to #devnet." | slowmode 10 s; threads on; the pre-join "I run a node" lands here | set, read back |
|
||||
| #dev | Building on or with Igneum: the client, the API, the explorer, tools. | "For people writing code. The public API is igneum.network/api/stats, /api/live and /api/supply, every field named. The repository opens with the public testnet. A claim about another chain cites the repo and file or is labelled approximate." | slowmode 10 s; threads on; the pre-join "I build" lands here | set, read back |
|
||||
| #spec | The spec, line by line. An issue on the spec is the way to report a flaw. | "The litepaper is at igneum.network/litepaper and the spec pages under it. Quote the line you mean. A flaw goes to hello@igneum.network or an issue on the spec; it gets a ledger id and a date in #ledger." | slowmode 10 s; threads on | set, read back |
|
||||
| #ledger | Every criticism, with a ledger id and a date, and what was done. | "Criticism lands here and at igneum.network/ledger with an id and a date. Nothing is deleted; a resolved item says how. The founder is one person, pseudonymous, building with AI systems; say so if that is your criticism, it has an entry." | slowmode 10 s; threads on | set, read back |
|
||||
|
||||
### COMMUNITY
|
||||
|
||||
| Channel | Topic | Pinned first post | Settings | Set / read back |
|
||||
|---|---|---|---|---|
|
||||
| #general | Everything else about Igneum. | "General talk. The three rules: be kind, no seed phrases ever, nobody from Igneum will DM you first. No price talk: there is no market and nothing is for sale. Regional threads open here when a language has ten people asking for one." | slowmode 10 s; threads on | set, read back |
|
||||
| #off-topic | Not Igneum. GPUs, games, the weather. | "Anything but Igneum and anything but prices. Same three rules." | slowmode 10 s | set, read back |
|
||||
|
||||
### Hidden (Founder and Core)
|
||||
|
||||
| Channel | Topic | Settings | Set / read back |
|
||||
|---|---|---|---|
|
||||
| #mod-log | AutoMod alerts and moderation notes. | private; every AutoMod rule's alert channel | set, read back |
|
||||
| #discord-updates | CI red runs, the feed's proof posts, tooling notes. | private; unchanged; holds the `DISCORD_WEBHOOK_UPDATES` webhook | exists |
|
||||
|
||||
Regional threads: not now. A regional thread opens in #general when ten members ask for one language; it is a thread,
|
||||
not a channel, until it carries a week of talk (the no-empty-channels rule applies to channels, not threads).
|
||||
|
||||
### 1.5 The support FAQ pin (the same post in the three support channels, the OS line first)
|
||||
|
||||
From the miner page's "Before you start" section, verbatim where it is quoted:
|
||||
|
||||
```
|
||||
Before you start. The questions worth asking.
|
||||
|
||||
Does this website use my GPU to mine?
|
||||
No. Mining happens only in the app you install, and only when you press Start.
|
||||
|
||||
Does my card mine and prove?
|
||||
Every card mines. Proving the full shard needs a 24 GB NVIDIA card; a 32 GB card does both at once. Apple silicon proves on the CPU, slowly. The Prove page of the app says in one sentence what your card can do.
|
||||
|
||||
Is the devnet paying real money?
|
||||
No. Devnet coins have no value and the chain may reset. The app's pounds row reads 0.00 on devnet and says why.
|
||||
|
||||
Is there a fee?
|
||||
Not in the protocol: no dev fund, no fee to any team. The app takes an optional 1% software fee, the norm for GPU miners, and one flag turns it off.
|
||||
|
||||
Can I run it on a rig or in a pool?
|
||||
The Linux and HiveOS tarball is on the miner page with the flight sheet. Pools are part of the public testnet; until then every machine mines solo on its own keys, and a card runs several.
|
||||
|
||||
Where do the numbers come from?
|
||||
Every rate has a row in the bench table and an entry in the engineering log with the command and the hardware. Nothing is estimated earnings.
|
||||
|
||||
Ask here with the card, the OS and the app version. Nobody from Igneum will DM you first. igneum.network/miner
|
||||
```
|
||||
|
||||
## 2. Moderation
|
||||
|
||||
| Setting | Value | Where | Set / read back |
|
||||
|---|---|---|---|
|
||||
| Verification level | Medium (was already Medium) | Safety Setup, DM and Spam Protection | read back |
|
||||
| Explicit image filter | Filter messages from all members (was already on) | Safety Setup, AutoMod, Sensitive content filters | read back |
|
||||
| 2FA requirement for moderation | off: the switch is greyed until the founder account enables 2FA | Server Settings, Safety Setup, Permissions | OWED (needs the founder) |
|
||||
| @everyone role | Mention @everyone, @here and All Roles: off. Also off: Manage Messages, Manage Threads, Create Private Threads, Use External Emoji stays on, Create Invite on | Server Settings, Roles, Default Permissions | set, read back |
|
||||
| Bot role | View Channels, Send Messages, Embed Links, Attach Files, Read Message History. Nothing else (no Manage, no Mention Everyone, no Administrator). Webhooks are not members and carry no role; this role is for the ladder bot's user only until that bot gets its own role (section 4.2) | Server Settings, Roles, Bot | set, read back |
|
||||
| #mod-log | private channel, Founder and Core; the alert channel of every AutoMod rule | Channel create | set, read back |
|
||||
| Rules screen | Server Rules on (Access tab), four lines: the three rules and the no-price plus report-a-flaw line | Server Settings, Access, Server Rules | set, read back |
|
||||
| DM spam | DM and Spam Protection 5 of 5 on; Raid Protection and CAPTCHA 3 of 3 on; activity alerts to #discord-updates; member prune restricted to admins (set today) | Safety Setup | read back |
|
||||
|
||||
### 2.1 to 2.5 AutoMod rules, as built (Server Settings, Safety Setup, AutoMod; every alert to #mod-log; Core exempt; #mod-log and #discord-updates exempt where a channel field exists)
|
||||
|
||||
Discord's keyword rule takes words and wildcards; the regex field was not needed. Seven rules are live:
|
||||
|
||||
| # | Rule (Discord name) | Trigger | Action | Read back |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Invite and spam links (14 words) | `*discord.com/invite/*, *discord.gg/*, *discordapp.com/invite/*, *discord.com/invite*, *bit.ly/*, *tinyurl.com/*, *cutt.ly/*, *t.me/*, *wa.me/*, *.xyz/*, *.top/*, *.click/*, *.buzz/*, *.icu/*` (the old "Igneum words and invites" rule, renamed and rewritten; its price words moved to rule 3) | block, alert #mod-log | enabled |
|
||||
| 2 | Seed phrases and wallet scams (19 words) | `seed phrase, seed phrases, recovery phrase, secret phrase, 12 words, 24 words, private key, send to, send me, dm me, message me first, validate your wallet, sync your wallet, connect your wallet, claim your, airdrop, giveaway, support ticket, open a ticket` | block, alert, time out 10 min | enabled |
|
||||
| 3 | Price pumping (alert only) (22 words) | `100x, 1000x, to the moon, wen moon, pump, pumping, buy now, buy the dip, price prediction, price target, listing soon, when binance, wen binance, wen lambo, presale, ico, ido, wen listing, wtb, wts, for sale, otc` | alert only, no block (a miner asking is a conversation; a mod answers with the pin) | enabled |
|
||||
| 4 | Impersonation of the team (10 words) | `igneum team, igneum support, official igneum, igneum admin, igneum mod, igneum staff, from igneum, igneum official, igneum helpdesk, igneum customer service` | block, alert | enabled |
|
||||
| 5 | Block Words in Member Profile Names | `*igneum*, *admin*, *moderator*, *support*, *official*, *helpdesk*, *team*` in display names | block member interactions, alert | enabled |
|
||||
| 6 | Block Commonly Flagged Words (Discord preset) | Severe Profanity, Insults and Slurs, Sexual Content, all three lists | block, alert | enabled |
|
||||
| 7 | Block Suspected Spam Content (Discord preset) | Discord's spam model | block, alert | enabled |
|
||||
| 8 | Block Mention Spam (Discord preset) | was already on | block | enabled |
|
||||
|
||||
Owed: the test from a non-Core account (one blocked message per rule in #mod-log, one legitimate message passing: a payout address, a sha256, "2x per joule"). The founder's account is Core-exempt and the server has one member, so the test waits for a second account.
|
||||
|
||||
### 2.6 The rules text (the rules screen and #rules, the same words)
|
||||
|
||||
```
|
||||
Three rules.
|
||||
|
||||
1. Be kind. Argue the claim.
|
||||
2. No seed phrases ever. Not yours, not anyone's, not in a DM, not "to check".
|
||||
3. Nobody from Igneum will DM you first. Anyone who does is not from Igneum.
|
||||
|
||||
No price talk: there is no market and nothing is for sale.
|
||||
|
||||
Report a flaw: hello@igneum.network or an issue on the spec. Nobody from Igneum will ask for your seed.
|
||||
```
|
||||
|
||||
## 3. Onboarding
|
||||
|
||||
### 3.1 Server guide to-dos (Server Settings, Onboarding, Server Guide), aligned with the map
|
||||
|
||||
| # | To-do | Channel |
|
||||
|---|---|---|
|
||||
| 1 | Read what Igneum is and the ladder | #start-here |
|
||||
| 2 | Read the three rules | #rules |
|
||||
| 3 | Pick your lane: Miner, Node runner, Builder (the question you answered on joining; change it in Channels and Roles) | #mining |
|
||||
| 4 | Post your first block when it lands | #first-block |
|
||||
| 5 | Follow releases | #releases |
|
||||
|
||||
As built: to-do 1 "Read what Igneum is and the ladder" in #start-here (visit), 2 "Say hello in mining: your card, the OS, the app version" in #mining (message), 3 "Post your first block when it lands" in #first-block (visit), 4 "Follow releases: version, downloads, what changed" in #releases (visit), 5 "Read the rules" (Discord's built-in). Welcome sign: "Welcome. Three rules in #rules: be kind, no seed phrases ever, nobody from Igneum will DM you first. #start-here has the ladder: your first block lands in #first-block. Nothing is for sale and devnet coins have no value." Author igneum_network. The guide banner is the one in README.md.
|
||||
|
||||
### 3.2 The pre-join question (Server Settings, Onboarding, Default Channels and Questions)
|
||||
|
||||
Question "What do you mine with?" stays. Answers and what each hands out:
|
||||
|
||||
| Answer | Role | Channels joined |
|
||||
|---|---|---|
|
||||
| NVIDIA | Miner | #mining, #first-block, #rig-photos, #support-windows, #support-linux-hiveos |
|
||||
| AMD | Miner | #mining, #first-block, #rig-photos, #support-windows, #support-linux-hiveos |
|
||||
| Apple | Miner | #mining, #first-block, #rig-photos, #support-mac |
|
||||
| I run a node | Node runner | #node-runners, #devnet |
|
||||
| I build | Builder | #dev, #spec, #proving |
|
||||
|
||||
Multiple answers allowed, not required. As built (13:0x UK): the five answers re-pointed exactly as the table above; Discord reports 22 of 22 public channels assignable and no public channel missing from Questions and Default Channels (22 default channels, unchanged).
|
||||
|
||||
### 3.3 The welcome post in #start-here (pinned, from the founder's account)
|
||||
|
||||
```
|
||||
Welcome to Igneum.
|
||||
|
||||
A proof-of-work chain built for graphics cards. The miners also prove every block and are paid for it from the block. No premine, no stake, no fee to any team in the protocol. One founder, pseudonymous, building with AI systems; every criticism we know of is at igneum.network/ledger.
|
||||
|
||||
Start here
|
||||
1. Install the miner: igneum.network/miner. Windows, macOS, Linux and HiveOS.
|
||||
2. Press Start. The app says what your card can do.
|
||||
3. Your first block lands in #first-block. Reply under it.
|
||||
|
||||
The ladder. The chain computes it, nobody hands it out.
|
||||
First block: one blue block with your key. The bot posts it in #first-block.
|
||||
Your key has a vote: 100 blocks in the 30-day window (the dust line). The Voter role.
|
||||
Your signature is in a checkpoint: the first lock carrying your vote.
|
||||
Full window: 30 of 30 days with blocks. The Window role.
|
||||
Rank: your place by weight among all keys, on igneum.network/live.
|
||||
Your card proved a shard: a paid shard record. The Prover role.
|
||||
|
||||
Questions in #mining or the support channel for your OS. Numbers every hour in #network-feed. Rules in #rules; there are three.
|
||||
Devnet coins have no value and the chain may be reset. Nothing is for sale.
|
||||
```
|
||||
|
||||
The rung names and rules are reinvent.md section 3.7 and ladder.md; on the devnet the dust line is the chain's
|
||||
`params.dust` (5 today), and the bot's rung 1 post says the number it read, never the constant.
|
||||
|
||||
## 4. Bots
|
||||
|
||||
Two bots. Neither replies, neither mentions, both post from the public API only, both go through the forbidden-string
|
||||
guard in `tools/community/discord-hooks.mjs` (the founder's name, hosts, machine ids, paths, IPs, 32-hex tokens,
|
||||
webhook URLs, any mention).
|
||||
|
||||
### 4.1 The network feed bot (webhook; BUILT and LIVE from the box since 13:06 UK, 7 October 2026)
|
||||
|
||||
What it is: `tools/community/discord-hooks.mjs feed`, added 7 October 2026 beside pulse, digest and weekly. A webhook
|
||||
needs no bot user, no token and no Discord permission beyond the webhook itself (Channel settings, Integrations,
|
||||
Webhooks, "Igneum feed" on #network-feed, or the existing "Igneum" webhook's URL copied into the key).
|
||||
|
||||
| Post | When | Exact shape |
|
||||
|---|---|---|
|
||||
| Network feed | every hour, on the London clock, through `tick` | title "Network feed" linking igneum.network/live; line 1 "igneum-devnet. Devnet coins have no value. https://igneum.network/live"; fields Height (`state.height`, blocks/s over the hour from `block_count`), Hash rate (`state.hashes_per_second_estimate`, difficulty), Keys (`state.miners_10m` "active in 10 min (a card runs several)", `finality.weights.voters` "voters in the 30-day window"), Last lock (the newest locked checkpoint: index, share of weight, age, voters); footer the UK time with UTC in brackets and "every hour from /api/live; this channel is read-only, questions go to #mining" |
|
||||
| Hash origin, daily | in the first feed of the day that sees a new `state.hash_origin.date` (the launch pack's job writes it at 08:30 UTC) | one extra field "Hash origin, <date>": "N keys found a block, M above dust, ten largest X% of blocks, project fleet Y%, P attested pools. Full report in #numbers." A field the report lacks is left out, never estimated |
|
||||
| Milestone | once each, when a feed crosses it | title "Milestone": every 100,000 chain blocks ("Chain block 100,000. igneum.network/block/100000"), every 10,000 locks, every 10,000 paid shards, the first crossing of 100, 250, 500, 1,000, 2,500, 5,000, 10,000 vote keys active at once, the first crossing of 1, 10, 100 GH/s and 1, 10, 100 TH/s. The first feed seeds and posts none |
|
||||
|
||||
Read back today (7 October 2026, 11:30 UK, dry run against the live API, then one live post through the updates
|
||||
webhook as the proof of the path): "Height 162,099, 1.47 blocks/s last 60 s; Hash rate 444.7 MH/s, difficulty
|
||||
210,368,365; Keys 17 active in 10 min, 16 voters in the 30-day window; Last lock checkpoint 8,723 at 72.1% of weight,
|
||||
16 s ago, 16 voters". 413 characters. Tests: 37 pass (`node --test tools/community/discord-hooks.test.mjs`).
|
||||
|
||||
What the numbers mean per tier (the consequences rule): 17 keys and 444.7 MH/s is the project's own machines on the
|
||||
devnet, so the feed names no outside miner yet; a home miner with one 8 GB card at the measured rates sees a first
|
||||
block inside the hour at this size, and the Keys line is the one that tells them when that stops being true (the
|
||||
pool is the answer from about 1 TH/s, reinvent.md 3.5).
|
||||
|
||||
Turned on 7 October 2026, 13:05 UK: the webhook "Igneum feed" was created on #network-feed (Channel settings,
|
||||
Integrations, Webhooks), its URL copied straight from Discord's button into `~/.config/igneum/discord` as
|
||||
`DISCORD_WEBHOOK_FEED` (the clipboard was cleared after; the URL was never printed), then
|
||||
`IGNEUM_SECRET_ON_BOX_OK=1 infra/build-server/discord-hooks/install.sh` copied the new script and the file to the box.
|
||||
The box's tick posted `feed:2026-10-07:13` at 13:06 UK (height 163,299, 310.5 MH/s, 14 keys active, 12 voters, lock
|
||||
8,910 at 80.3 percent) and the next tick read "0 due". Two webhooks now post to #network-feed: "Igneum" (numbers:
|
||||
pulse, digest, weekly, hash-origin) and "Igneum feed" (the hour and milestones).
|
||||
|
||||
What the numbers mean per tier (the consequences rule): 14 keys and 311 MH/s is the project's own machines on the
|
||||
devnet, so the feed names no outside miner yet; a home miner with one 8 GB card at the measured untuned rates finds a
|
||||
first block inside the hour at this size, and the Keys line is the one that tells them when that stops being true
|
||||
(the pool is the answer from about 1 TH/s, reinvent.md 3.5).
|
||||
|
||||
### 4.2 The ladder bot (a bot user; SPECIFIED, not built)
|
||||
|
||||
What it posts, from ladder.md (the shapes are fixed there; the words are repeated here so this file stands alone):
|
||||
|
||||
| Post | Channel | Trigger | Exact shape |
|
||||
|---|---|---|---|
|
||||
| First block (rung 0) | #first-block | a key's `blocks` goes 0 to 1 in `finality.weights.keys[]` and a block in `/api/live blocks[]` names it | "First block: key 6ad0e117" / "Block 1,284,117 · igneum.network/block/<hash>" / "RTX 4070 (the miner chose to show the card)". Without the opt-in the third line is absent. Embed: ember, title "First block", fields Key and Block, UK footer |
|
||||
| The vote (rung 1) | #first-block | `keys[].voter` goes false to true | "Your key has a vote: 6ad0e117" / "100 blocks in the window (the line is 100) · the key now signs every checkpoint"; the number is `params.dust` as read |
|
||||
| The ladder, yesterday | #network-feed | 09:00 UK beside the digest | the seven-line summary in ladder.md (first blocks, votes, signatures, full window, rank 1, signing streak, shards); a line whose field the node does not expose is left out |
|
||||
| Roles | | daily read of `getFinalityWeights` through `/api/live` | Voter granted when a message signed with the vote key names a key whose `voter` is true; revoked at a daily read where `voter` is false. Window when `keys[].daysMined` spans the window (owed from the node; by hand until then). Prover when a paid shard record names the key; never revoked |
|
||||
|
||||
Opt-in, per miner: the key id and the block link post for every key (a chain fact); the card model and the "(you)"
|
||||
link only when the miner switched "Make my page public" on in the app (`settings.profile_public`). The Discord role
|
||||
needs the miner to prove the key: a `/key <id> <signature>` slash command, the signature over a nonce the bot gives,
|
||||
verified against `getFinalityWeights`; the app's "prove my key" button is owed (ladder.md).
|
||||
|
||||
Permissions the bot user needs, minimal: View Channels; Send Messages and Embed Links in #first-block and #network-feed
|
||||
only (channel overrides, not server-wide); Read Message History; Manage Roles, with the bot's role placed above Voter,
|
||||
Window and Prover and below everything else; `applications.commands` scope for the slash command. Not: Administrator,
|
||||
Mention Everyone, Manage Channels, Manage Webhooks, Moderate Members.
|
||||
|
||||
Rungs 2 to 5 and P are never posted singly; they are counted in the daily summary (one a minute at scale).
|
||||
|
||||
### 4.3 The two webhooks that exist and where they point
|
||||
|
||||
| Key | Channel | Used by |
|
||||
|---|---|---|
|
||||
| DISCORD_WEBHOOK_NUMBERS | #network-feed (the channel was renamed; the webhook followed) | pulse, digest, weekly, the hash-origin report |
|
||||
| DISCORD_WEBHOOK_ANNOUNCEMENTS | #releases (moved 7 Oct 2026, 13:13 UK; the webhook keeps its URL and name "Igneum") | release posts |
|
||||
| DISCORD_WEBHOOK_INCIDENTS | #incidents | incident open and resolve, the watcher |
|
||||
| DISCORD_WEBHOOK_UPDATES | #discord-updates (hidden) | the CI red watcher; the feed's proof post today (`feed --via updates`) |
|
||||
| DISCORD_WEBHOOK_FEED | #network-feed, webhook "Igneum feed" (created 13:0x UK) | the hourly feed and milestones |
|
||||
|
||||
## 5. Events
|
||||
|
||||
One template, "Devnet office hour", weekly. Discord cannot hold an event without a start time, so nothing is created on the server until the founder picks a day and an hour; the fields below are the template, created in one minute on that day.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Name | Devnet office hour |
|
||||
| Where | a voice channel "office-hour" under COMMUNITY, created with the first event (not before: no empty channels) |
|
||||
| Description | "An hour on the devnet, every week. Bring the card, the log line or the question. Numbers from the public API only; nothing is for sale and there is no price." |
|
||||
| Recurrence | weekly, same day and hour, UK time |
|
||||
| Needs the founder | the day and the hour |
|
||||
|
||||
## 6. What needs the founder
|
||||
|
||||
| Item | Why |
|
||||
|---|---|
|
||||
| A day and an hour for the office hour | section 5; Discord has no undated event |
|
||||
| 2FA on the founder account, then the "Require 2FA for moderator actions" switch | Discord greys the switch until the account has 2FA; one click after that |
|
||||
| The hosting word for the ladder bot | a bot token with Manage Roles is a secret the box would hold; the 6 October exception covers webhook URLs only. Options: the box under a second exception, or a separate small host that holds only the bot token and reads the public API |
|
||||
| A second account for the AutoMod test | the founder's account is Core-exempt; the seven rules are enabled but untested against a real message |
|
||||
|
||||
## Changelog
|
||||
|
||||
| When (UK) | What |
|
||||
|---|---|
|
||||
| 7 Oct 2026, 11:1x | Sheet opened on branch discord-structure. Chrome profile confirmed as the igneum.network login. Discord session found expired; stopped at the log-in form, nothing typed |
|
||||
| 7 Oct 2026, 11:2x | `feed` subcommand, milestones, the daily hash-origin field, `--via updates`, `DISCORD_WEBHOOK_FEED` (optional) and the tick wiring added to tools/community/discord-hooks.mjs; six tests added, 37 pass |
|
||||
| 7 Oct 2026, 11:30 | One live feed posted through the updates webhook: key `feed:2026-10-07:11`, message id 1557339585166581846, 413 characters |
|
||||
| 7 Oct 2026, 12:5x | Founder signed in. Categories renamed INFO, CHAIN, LEDGER, TALK to START HERE, MINE, BUILD, COMMUNITY. #ask read (no messages beyond the welcome; the r/igneum FAQ line is in the support pins) and renamed to #off-topic; #finality read (no messages) and renamed to #first-block; #proving read (no messages) and renamed to #rig-photos; #devnet (no messages) renamed to #benchmarks; #breaks (no messages) renamed to #spec; #numbers renamed to #network-feed (both webhooks follow). Created #releases, #mod-log (private, Core), #support-windows, #support-mac, #support-linux-hiveos, #pools, #devnet, #proving, #node-runners; #dev moved to BUILD. Every topic and slowmode set; #first-block Send Messages denied, threads allowed; #network-feed Send Messages denied. The 0.3.15 and 0.3.16 posts and the two incidents stay where they were |
|
||||
| 7 Oct 2026, 12:2x to 12:3x | 19 first posts sent from the founder's account and pinned: start-here (the welcome and ladder), releases, incidents, network-feed, mining, rig-photos, first-block, benchmarks, support-windows, support-mac, support-linux-hiveos (the FAQ, OS line first), pools, devnet, proving, node-runners, dev, spec, ledger, off-topic |
|
||||
| 7 Oct 2026, 12:4x | AutoMod: seven rules enabled (section 2), prune restricted to admins, safety notifications to #mod-log |
|
||||
| 7 Oct 2026, 12:5x | Onboarding: the five answers re-pointed, four to-dos rewritten, welcome sign rewritten; Server Rules screen on with four lines; Bot role set to View Channels, Send Messages, Embed Links, Attach Files, Read Message History (read back 13:1x UK: exactly those five on, 46 off) |
|
||||
| 7 Oct 2026, 13:05 | Webhook "Igneum feed" created on #network-feed, key written, box installed; 13:06 the box's first hourly feed |
|
||||
| 7 Oct 2026, 13:1x | Post 1's "How this channel works" line edited by webhook PATCH (message 1557154683779547138); the "Igneum" release webhook moved from #announcements to #releases (Channel settings, Integrations, Webhooks, Channel), read back from each webhook's own endpoint: announcements → 1557346271726141471 (#releases), numbers and feed → 1557089967807926322 (#network-feed) |
|
||||
|
|
@ -2338,6 +2338,30 @@ Evidence: `site/index.html`; `site/litepaper.html`; `tools/ci/ledger-text-check.
|
|||
- **E5, L9** (the client dev fee), evening: the fee is built, switchable and audited (E18). The homepage's miner section now carries the litepaper's sentence (`site/index.html`, under the download buttons: "The protocol carries no fee. The Igneum Miner software takes an optional 1% dev fee, like other GPU miners, off with one flag"), and the litepaper's "What a miner's hour looks like" states the mechanism, the flag and the audit. The "no value" and "may be reset" devnet wording of L9 is still open.
|
||||
- **D2** (the app share). The "Why build here" text is applied at `site/litepaper.html:355` and states the number; two gaps noted under E17.
|
||||
|
||||
## Node findings (6 October 2026, evening): the 0.3.15 canary and the sync crash, fixed before the cut
|
||||
|
||||
Two faults found on the live devnet by the fleet's canary boxes and the hub while 0.3.15 (class v4) was held for its cut. Both are conceded as ours, both are fixed on the fork branch `ca3-v4-order-fix` for release-0.3.15-node (the shipper's tree), both carry a test and a gate that every future node cut runs (`infra/fast-time/node-compat.mjs`).
|
||||
|
||||
### N1. A 0.3.15 node on the live file wrote blocks every 0.3.14 node rejected
|
||||
|
||||
"p2-3090-1 on 713ef876 with the live thirteen-field file, at every reconnect since its 19:42Z restart: P2P, got reject message: wrong block version: got 1026 but expected 2 from peer 188.245.5.161:26611; blocks 122,630, synced false, peers 0."
|
||||
|
||||
Status: Fixed (6 October 2026, 20:0xZ, fork commit 17c60367 on ca3-v4-order-fix, the stamp; and 20:4xZ, f1ea7a38, the receive side, after the clean canary of 21:3x UK showed a 0.3.15 node ACCEPTING and relaying a version-1026 block it would never write, off a poisoned peer, and being disconnected by every 0.3.14 peer in turn; in 0.3.15). Conceded: the node lane's fault, twice.
|
||||
|
||||
Answer: the class v4 signal (PROPOSED, `docs/plans/counter-asic-3-node.md` section 6) is the producer's object version in the high byte of the header version; the first 0.3.15 build stamped it from the binary alone, so on the live file (no window, no floor) a new node wrote version 1026 and every 0.3.14 node, whose rule is version 2 or reject, refused its blocks and its relays, although the digest compat rule (the same evening) made the handshake peer. A new miner lost every block; a lagging new node could not re-sync. The fix gates the stamp, the signal read AND the header version rule on publish 2's object (both `program_class_v4_signal_window_daa` and `program_class_v4_activation_daa` set): on the thirteen-field file the header is byte for byte what 0.3.14 writes, and a header carrying the signal bit is refused with 0.3.14's own `WrongBlockVersion(1026, 2)` before the engine and never relayed, so a poisoned peer cannot poison a new node. What no new-node change can do: a 0.3.14 node whose datadir holds version-1026 blocks is disconnected by its 0.3.14 peers by THEIR rule when it relays them, until those blocks leave the relay window; the fleet wipes the poisoned datadirs. Why the gates missed it: the digest compat harness peered the two binaries but neither mined; the new gate mines on both sides, joins a clean node through each, restarts the new node and re-syncs it.
|
||||
|
||||
Evidence: unit tests `block_template_uses_current_block_version` (version 2 with no window and no floor, 1026 with both) and `program_class_signal_rule`; the gate `infra/fast-time/node-compat.mjs` on the fixed binary beside 0.3.14 on the live object: one digest on all four nodes, the new miner's 73 blocks accepted by the old hub, counts 169/169/169/169, header versions {0: the genesis, 2: 169}, no reject line, the clean join and the restart re-sync (PASS, 20:06Z; `docs/plans/counter-asic-3-gate/node-compat-20261006-fixed.json`); the same gate on the canary binary 713ef876 reproduces the fault (`HandleRelayInvsFlow flow error: wrong block version: got 1026 but expected 2, disconnecting`, counts 138 against 101, the restarted node never re-synced: FAIL, `node-compat-20261006-canary-failed-case.json`).
|
||||
|
||||
### N2. Any peer could crash any pruned node with a sync request below its retention
|
||||
|
||||
"thread 'tokio-rt-worker' panicked at consensus/src/processes/sync/mod.rs:87:62: called Result::unwrap() on an Err value: KeyNotFound(GhostdagCompact/0/edc4fa84...) then Exiting..." (the hub, a pruned 0.3.14 node, 19:57Z, when p2-3090-1, cut off since 19:42Z, began a sync from the genesis against it).
|
||||
|
||||
Status: Fixed (6 October 2026, 20:2xZ, fork commit 7961c5f1 on ca3-v4-order-fix; in 0.3.15). Conceded: a remote crash vector in every release from the first pruned devnet node to 0.3.14; security.
|
||||
|
||||
Answer: `SyncManager::antipast_hashes_between` (the IBD headers path, `RequestHeaders`) unwrapped the GHOSTDAG reads of the requested low block and of every chain block of the walk; a pruned node holds no GHOSTDAG data below its retention, so a request from the genesis killed the serving node, not the requester. The fix makes the walk fallible: a read below the retention is `SyncManagerError::BlockBelowRetention(hash)` (also in `find_highest_common_chain_block` and the pruning-point locator), the consensus API returns it, and the serving flow answers the peer with the error and disconnects it, the node alive; the peer syncs from a node that holds the history. The hub was restarted by hand at 20:00Z (synced in 25 s).
|
||||
|
||||
Evidence: unit test `a_sync_request_below_retention_is_an_error_not_a_panic` (a chain of six headers, the genesis's GHOSTDAG entry deleted as a pruned node holds it, `get_hashes_between(genesis, tip)` returns the error, a request inside the retention still answers); the gate's served-join case (a clean old node syncing from the genesis through the NEW node, which must stay alive and serve it) in `node-compat.mjs`; the box's kaspa-consensus and consensus-core suites on the commit. A truly pruned serving node in a harness needs hours of fast-time chain (pruning depth 13,838 DAA) and is owed; the unit test stands in for it.
|
||||
|
||||
## Status updates, 5 October 2026 (ledger sweep, night of 4 to 5 October)
|
||||
|
||||
`docs/review/ledger-sweep-2026-10-05.md` holds the runs, the commands and the running table. Every Open, Proposed, Unmeasured or pending entry was read against its experiment line; the status lines above carry the evidence inline, marked "Sweep (5 October 2026)" where a note was added and "Was:" where the status changed. Items owned by the other two night branches (F23, F24, G12, X18, the flood memory growth; the base-fee floor, testnet parameters, G13, G14, public text) were left to them.
|
||||
|
|
|
|||
|
|
@ -357,3 +357,23 @@ service. The layer tracks the repo branch `master`; until this work merges, the
|
|||
tree, so install writes a drop-in pinning `CAP_REPO_BRANCH=box-capacity` and removes it once `master` carries
|
||||
`infra/build-server/capacity/run.sh` (self-healing after the merge). `--no-start` enables without starting;
|
||||
`--smoke <job>` installs then runs one job's 10-minute smoke and prints the summary.
|
||||
|
||||
## 7. Spill-over between the boxes (7 October 2026, 15:0x UK)
|
||||
|
||||
the project lead's reading at 15:02 UK: build-1 at load 139 / 114 / 90 with both slots held and a queue (the horizon lane waited 1 h 40 min)
|
||||
while build-2 read 4.5 / 27 / 46 with both slots free. The class router (section 6) pinned each class to its box with no
|
||||
spill-over. Now (lib.sh `bs_route_spill`, master from this commit):
|
||||
|
||||
- The class is a PREFERENCE: a build, check or gate prefers box 1, a suite, bench or attack row box 2, a proving crate box 3.
|
||||
- Before a run, the preferred box is read with one ssh (free slots of its slot count, 1-minute load). It takes the job when it
|
||||
has a free slot and its load is at or under 64 (`BS_SPILL_LOAD`). Otherwise the other box (1 and 2 swap; 3 falls to 1) takes it
|
||||
when THAT one qualifies; when neither does, the job queues on its own box. A box without a host file is never chosen; an
|
||||
unreachable box reads as "down" and is skipped.
|
||||
- The decision is the first route line of the run ("route: class suite prefers box 2; box 2 (free=0 slots=3 load1=70) is full or
|
||||
over load 64: spilled to box 1 (free=1 slots=2 load1=20)"), the slot label carries "; spilled from box N", and the JSONL row
|
||||
carries `"route": {"preferred", "box", "spilled", "reason"}` for the dashboard's job card.
|
||||
- build-2 has THREE slots (its `slots` file reads 3 since 14:0x UK; provision.sh defaults a `-2` hostname to 3). Everything that
|
||||
lands on box 2 runs at the bounded class, nice 10 on a 32-core band with -j 32, builds and gates included, so a suite beside
|
||||
them keeps its number; since this commit a bounded run takes the band its slot owns (slot 0 the last 32 cores, slot 1 the 32
|
||||
below, slot 2 the 32 below that), so three bounded runs never share a core. A gate still takes its slot ahead of queued suites.
|
||||
- `--box N` still pins. Self-test: tools/ci/route-spill-check.sh (thirteen cases through `BS_ROUTE_STATE_<n>`, no ssh), in the gate.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
{
|
||||
"gate": "P1 rehearsal: the class v4 activation on a fleet of real boxes (Counter ASIC 3.0)",
|
||||
"verdict": "PASS",
|
||||
"date": "2026-10-06",
|
||||
"network": "igneum-devnet-400 (fleet-only, from the devnet genesis state)",
|
||||
"object": {
|
||||
"file": "docs/plans/counter-asic-3-gate/override-v4-rehearsal-1bps.json",
|
||||
"fields": 17,
|
||||
"pow_epoch_blocks": 600,
|
||||
"pow_epoch_lead": 100,
|
||||
"program_class_v4_signal_window_daa": 600,
|
||||
"program_class_v4_activation_daa": 2400,
|
||||
"threshold_bps": 9500,
|
||||
"consensus_digest_on_devnet_400": "a15db4f0d6a5cc891582760ef5593e69014a5133261def84647cb7617528764f"
|
||||
},
|
||||
"binaries": {
|
||||
"igneumd": "847ddfd1ba376009 (PC 2 job build-20261006-174823, the signalling fork 0562a7f2)",
|
||||
"igneum-miner": "37178aeb09bc3a24",
|
||||
"worker_first": "0.3.14 hive igneum-worker-cuda 1.0 (4 October): refused the generator-4 pack, the fleet stopped at the flip",
|
||||
"worker_resume": "Linux generator-4 igneum-worker-cuda from the release tree 563485b, sha256 97e036e23f4ace66..., from 19:15:47Z"
|
||||
},
|
||||
"boxes": {
|
||||
"mining": 15,
|
||||
"seed": 1,
|
||||
"stale_old_binary": 1,
|
||||
"wave_pods_joined_as_v4_miners": 37,
|
||||
"signalling_nodes_with_the_flip_line": 52
|
||||
},
|
||||
"timeline_utc": {
|
||||
"first_block": "18:41:10",
|
||||
"flip_daa_1202": "19:00:5x",
|
||||
"stall_at_flip_old_worker_s": 90,
|
||||
"fleet_stopped_old_worker": "19:04:14",
|
||||
"resume_new_worker": "19:15:47",
|
||||
"floor_daa_2400": "19:33:2x",
|
||||
"sweep": "19:34:54"
|
||||
},
|
||||
"flip_line": "Program class v4 by miner signal: epoch 2 (share 10000 bps over 600 DAA ending at seed block f0606e203183798a728488ba8ce38ad5f552f53e3a7ac7b5e65d41c23fac5bf3, threshold 9500 bps, 599 of 599 blue blocks)",
|
||||
"program_ids": {
|
||||
"epoch_1_v3": "b237a661a3c7f7c1",
|
||||
"epoch_2_v4": {
|
||||
"pack": "24304f0788ea9408",
|
||||
"cli_v4": "24304f0788ea9408",
|
||||
"cli_v3": "d8927052c764f00b"
|
||||
},
|
||||
"epoch_3_v4": {
|
||||
"pack": "cc266b4f5dbc3447",
|
||||
"cli_v4": "cc266b4f5dbc3447",
|
||||
"cli_v3": "de3a34f1f2130228"
|
||||
},
|
||||
"epoch_4_v4": {
|
||||
"pack": "634018bab5e5f283",
|
||||
"cli_v4": "634018bab5e5f283",
|
||||
"cli_v3": "170e3aa4b2f69d60"
|
||||
}
|
||||
},
|
||||
"checks": {
|
||||
"flip_by_signal_identical_line_on_every_node": true,
|
||||
"one_program_id_per_epoch_across_boxes_equal_cli_v4_differs_v3": true,
|
||||
"pow_rejected_on_every_node": 0,
|
||||
"miner_mismatched": 0,
|
||||
"blocks_on_v4_on_every_box": true,
|
||||
"stale_box_refused_by_digest": {
|
||||
"refusing_peer_lines": 11,
|
||||
"digest_mismatch_lines": 22,
|
||||
"ever_a_peer": false,
|
||||
"old_digest": "507f802b"
|
||||
},
|
||||
"stale_box_on_full_file": "refuses at parse (unknown field program_class_v4_activation_daa) and exits",
|
||||
"floor_crossed_with_v4_in_force": true,
|
||||
"sweep_19_34_54": {
|
||||
"heights": "2502 to 2509",
|
||||
"blue": "2432 to 2440",
|
||||
"sinks_moving_with_height": 4,
|
||||
"fork": false
|
||||
},
|
||||
"g2_serve_mode_epoch_2_pack": {
|
||||
"found": 1024,
|
||||
"distinct": 1024,
|
||||
"digest": "7bf77506546730973a708ebfcf7d1f908f58ff9687b529e9ccca1d022f81e3e6",
|
||||
"mac_recheck": "MATCH 1024 of 1024"
|
||||
}
|
||||
},
|
||||
"cut_critical": "an old worker refuses a generator-4 pack by design, so publish 2 (the sixteen-field file) waits until every node AND every worker in the fleet is 0.3.15's; a 0.3.13 node given the file exits at parse; publish 1 carries no handshake split under the digest-compat rule",
|
||||
"live_devnet_side_effect": "11 live miners at about half rate 18:42Z to 19:04Z (their GPUs shared with the rehearsal worker); no box left the live devnet"
|
||||
}
|
||||
64
docs/plans/counter-asic-3-gate/digest-compat-20261006.json
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"pass": true,
|
||||
"checks": {
|
||||
"new_and_old_on_13_fields_print_one_digest": true,
|
||||
"the_16_field_digest_differs": true,
|
||||
"n0_peers_with_n1_and_n2": true,
|
||||
"old_node_peers_with_new": true,
|
||||
"n3_has_no_peer": true,
|
||||
"a_digest_refusal_line_exists": true
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"node": "n0",
|
||||
"binary": "new",
|
||||
"fields": 13,
|
||||
"digest": "a89be8a7e64b7d5a0d89d19c8837b3a8c2323fc277717e94c508eeb51a5e71e6",
|
||||
"peers": 2,
|
||||
"refusal": [
|
||||
"Application directory: /tmp/igneum-digest-compat/n0",
|
||||
"Data directory: /tmp/igneum-digest-compat/n0/igneum-devnet-995/datadir"
|
||||
]
|
||||
},
|
||||
{
|
||||
"node": "n1",
|
||||
"binary": "new",
|
||||
"fields": 13,
|
||||
"digest": "a89be8a7e64b7d5a0d89d19c8837b3a8c2323fc277717e94c508eeb51a5e71e6",
|
||||
"peers": 1,
|
||||
"refusal": [
|
||||
"Application directory: /tmp/igneum-digest-compat/n1",
|
||||
"Data directory: /tmp/igneum-digest-compat/n1/igneum-devnet-995/datadir"
|
||||
]
|
||||
},
|
||||
{
|
||||
"node": "n2",
|
||||
"binary": "old",
|
||||
"fields": 13,
|
||||
"digest": "a89be8a7e64b7d5a0d89d19c8837b3a8c2323fc277717e94c508eeb51a5e71e6",
|
||||
"peers": 1,
|
||||
"refusal": [
|
||||
"Application directory: /tmp/igneum-digest-compat/n2",
|
||||
"Data directory: /tmp/igneum-digest-compat/n2/igneum-devnet-995/datadir"
|
||||
]
|
||||
},
|
||||
{
|
||||
"node": "n3",
|
||||
"binary": "new",
|
||||
"fields": 16,
|
||||
"digest": "db9a85f9ff08f72f11f7ba496b3060189c32bdf6cb53e94c810a4cc25585487a",
|
||||
"peers": 0,
|
||||
"refusal": [
|
||||
"Application directory: /tmp/igneum-digest-compat/n3",
|
||||
"Data directory: /tmp/igneum-digest-compat/n3/igneum-devnet-995/datadir"
|
||||
]
|
||||
}
|
||||
],
|
||||
"new_binary": "/Users/joshm/Projects/igneum-wt-ca3-v4-node/vendor/igneum-node-0315fix/../igneum-node-ca3v4/target-ca3v4/release/igneumd",
|
||||
"old_binary": "/Users/joshm/Projects/igneum/vendor/igneum-node/target-0314/release/igneumd",
|
||||
"secs": 45,
|
||||
"refusal_lines_rechecked": [
|
||||
"Refusing peer 127.0.0.1:51123: consensus params digest mismatch, local a89be8a7e64b7d5a0d89d19c8837b3a8c2323fc277717e94c508eeb51a5e71e6 remote db9a85f9ff08f72f11f7ba496b3060189c32bdf6cb53e94c810a4cc25585487a (the peer's override file, environment or build differs)",
|
||||
"Refusing peer 127.0.0.1:51393: consensus params digest mismatch, local a89be8a7e64b7d5a0d89d19c8837b3a8c2323fc277717e94c508eeb51a5e71e6 remote db9a85f9ff08f72f11f7ba496b3060189c32bdf6cb53e94c810a4cc25585487a (the peer's override file, environment or build differs)"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
"pass": false,
|
||||
"checks": {
|
||||
"one_digest_on_old_and_new": true,
|
||||
"new_miner_blocks_accepted": true,
|
||||
"old_miner_blocks_accepted": true,
|
||||
"zero_rejected_by_miners": true,
|
||||
"no_wrong_version_or_reject_line": false,
|
||||
"every_header_version_is_the_block_version": false,
|
||||
"old_nodes_hold_the_new_blocks": false,
|
||||
"counts_agree_at_the_end": false,
|
||||
"clean_new_node_joined_and_synced": true,
|
||||
"restarted_new_node_resynced": false
|
||||
},
|
||||
"new_binary": "/private/tmp/claude-501/-Users-joshm/cd75457f-4858-4f86-9634-7481ee056b7b/scratchpad/igneumd-713ef876",
|
||||
"old_binary": "/Users/joshm/Projects/igneum/vendor/igneum-node/target-0314/release/igneumd",
|
||||
"digests": [
|
||||
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2",
|
||||
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2",
|
||||
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2",
|
||||
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2"
|
||||
],
|
||||
"counts_after_mining": [
|
||||
{
|
||||
"count": 97,
|
||||
"sink": "7c26e545e4982ac9",
|
||||
"daa": 97
|
||||
},
|
||||
{
|
||||
"count": 101,
|
||||
"sink": "11439a8867718a2b",
|
||||
"daa": 101
|
||||
},
|
||||
{
|
||||
"count": 97,
|
||||
"sink": "7c26e545e4982ac9",
|
||||
"daa": 97
|
||||
},
|
||||
{
|
||||
"count": 97,
|
||||
"sink": "7c26e545e4982ac9",
|
||||
"daa": 97
|
||||
}
|
||||
],
|
||||
"final": [
|
||||
{
|
||||
"count": 138,
|
||||
"sink": "360b8259384d8fc1",
|
||||
"daa": 138
|
||||
},
|
||||
{
|
||||
"count": 101,
|
||||
"sink": "11439a8867718a2b",
|
||||
"daa": 101
|
||||
},
|
||||
{
|
||||
"count": 138,
|
||||
"sink": "360b8259384d8fc1",
|
||||
"daa": 138
|
||||
},
|
||||
{
|
||||
"count": 138,
|
||||
"sink": "360b8259384d8fc1",
|
||||
"daa": 138
|
||||
}
|
||||
],
|
||||
"accepted_old_new": [
|
||||
138,
|
||||
100
|
||||
],
|
||||
"rejected_by_miners_old_new": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"reject_lines": [
|
||||
[
|
||||
"HandleRelayInvsFlow flow error: wrong block version: got 1026 but expected 2, disconnecting from peer 127.0.0.1:51290.",
|
||||
"HandleRelayInvsFlow flow error: wrong block version: got 1026 but expected 2, disconnecting from peer 127.0.0.1:51818.",
|
||||
"HandleRelayInvsFlow flow error: wrong block version: got 1026 but expected 2, disconnecting from peer 127.0.0.1:52104."
|
||||
],
|
||||
[
|
||||
"P2P, got reject message: wrong block version: got 1026 but expected 2 from peer: 127.0.0.1:29831",
|
||||
"P2P, got reject message: wrong block version: got 1026 but expected 2 from peer: 127.0.0.1:29831",
|
||||
"P2P, got reject message: wrong block version: got 1026 but expected 2 from peer: 127.0.0.1:29831"
|
||||
],
|
||||
[],
|
||||
[]
|
||||
],
|
||||
"header_versions": {
|
||||
"0": 1,
|
||||
"2": 138
|
||||
},
|
||||
"secs": 120
|
||||
}
|
||||
102
docs/plans/counter-asic-3-gate/node-compat-20261006-fixed.json
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"pass": true,
|
||||
"checks": {
|
||||
"one_digest_on_old_and_new": true,
|
||||
"new_miner_blocks_accepted": true,
|
||||
"old_miner_blocks_accepted": true,
|
||||
"zero_rejected_by_miners": true,
|
||||
"no_wrong_version_or_reject_line": true,
|
||||
"every_header_version_is_the_block_version": true,
|
||||
"old_nodes_hold_the_new_blocks": true,
|
||||
"counts_agree_at_the_end": true,
|
||||
"clean_new_node_joined_and_synced": true,
|
||||
"restarted_new_node_resynced": true,
|
||||
"new_node_served_a_genesis_sync_and_survived": true,
|
||||
"no_panic_in_any_node_log": true
|
||||
},
|
||||
"new_binary": "vendor/igneum-node-ca3v4/target-ca3v4/release/igneumd",
|
||||
"old_binary": "/Users/joshm/Projects/igneum/vendor/igneum-node/target-0314/release/igneumd",
|
||||
"digests": [
|
||||
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2",
|
||||
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2",
|
||||
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2",
|
||||
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2",
|
||||
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2"
|
||||
],
|
||||
"counts_after_mining": [
|
||||
{
|
||||
"count": 165,
|
||||
"sink": "44a4876cfc2ba782",
|
||||
"daa": 165
|
||||
},
|
||||
{
|
||||
"count": 165,
|
||||
"sink": "44a4876cfc2ba782",
|
||||
"daa": 165
|
||||
},
|
||||
{
|
||||
"count": 165,
|
||||
"sink": "44a4876cfc2ba782",
|
||||
"daa": 165
|
||||
},
|
||||
{
|
||||
"count": 165,
|
||||
"sink": "44a4876cfc2ba782",
|
||||
"daa": 165
|
||||
},
|
||||
{
|
||||
"count": 165,
|
||||
"sink": "44a4876cfc2ba782",
|
||||
"daa": 165
|
||||
}
|
||||
],
|
||||
"final": [
|
||||
{
|
||||
"count": 195,
|
||||
"sink": "0be1fa5e67bedeb1",
|
||||
"daa": 195
|
||||
},
|
||||
{
|
||||
"count": 195,
|
||||
"sink": "0be1fa5e67bedeb1",
|
||||
"daa": 195
|
||||
},
|
||||
{
|
||||
"count": 195,
|
||||
"sink": "0be1fa5e67bedeb1",
|
||||
"daa": 195
|
||||
},
|
||||
{
|
||||
"count": 195,
|
||||
"sink": "0be1fa5e67bedeb1",
|
||||
"daa": 195
|
||||
},
|
||||
{
|
||||
"count": 195,
|
||||
"sink": "0be1fa5e67bedeb1",
|
||||
"daa": 195
|
||||
}
|
||||
],
|
||||
"served_join_count": 165,
|
||||
"serving_node_alive": true,
|
||||
"accepted_old_new": [
|
||||
120,
|
||||
75
|
||||
],
|
||||
"rejected_by_miners_old_new": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"reject_lines": [
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[]
|
||||
],
|
||||
"header_versions": {
|
||||
"0": 1,
|
||||
"2": 195
|
||||
},
|
||||
"secs": 160
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
{
|
||||
"pass": true,
|
||||
"checks": {
|
||||
"one_digest_on_old_and_new": true,
|
||||
"new_miner_blocks_accepted": true,
|
||||
"old_miner_blocks_accepted": true,
|
||||
"zero_rejected_by_miners": true,
|
||||
"no_wrong_version_or_reject_line": true,
|
||||
"every_header_version_is_the_block_version": true,
|
||||
"old_nodes_hold_the_new_blocks": true,
|
||||
"counts_agree_at_the_end": true,
|
||||
"clean_new_node_joined_and_synced": true,
|
||||
"restarted_new_node_resynced": true,
|
||||
"new_node_served_a_genesis_sync_and_survived": true,
|
||||
"no_panic_in_any_node_log": true,
|
||||
"new_node_refused_the_poisoned_blocks": true,
|
||||
"poisoned_node_mined_something_to_refuse": true,
|
||||
"old_hub_never_saw_a_1026_block": true
|
||||
},
|
||||
"new_binary": "vendor/igneum-node-ca3v4/target-ca3v4/release/igneumd",
|
||||
"old_binary": "/Users/joshm/Projects/igneum/vendor/igneum-node/target-0314/release/igneumd",
|
||||
"poisoned_binary": "/private/tmp/claude-501/-Users-joshm/cd75457f-4858-4f86-9634-7481ee056b7b/scratchpad/igneumd-713ef876",
|
||||
"new_node_rejects_of_1026": 12,
|
||||
"poisoned_blocks_accepted_by_its_own_node": 179,
|
||||
"digests": [
|
||||
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2",
|
||||
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2",
|
||||
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2",
|
||||
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2",
|
||||
"b0afb2ee93d0d6274cbf63d40ac7bed487202c5af84e035c8a634c521bffe6a2"
|
||||
],
|
||||
"counts_after_mining": [
|
||||
{
|
||||
"count": 147,
|
||||
"sink": "9d12ad182ab77726",
|
||||
"daa": 147
|
||||
},
|
||||
{
|
||||
"count": 147,
|
||||
"sink": "9d12ad182ab77726",
|
||||
"daa": 147
|
||||
},
|
||||
{
|
||||
"count": 147,
|
||||
"sink": "9d12ad182ab77726",
|
||||
"daa": 147
|
||||
},
|
||||
{
|
||||
"count": 147,
|
||||
"sink": "9d12ad182ab77726",
|
||||
"daa": 147
|
||||
},
|
||||
{
|
||||
"count": 147,
|
||||
"sink": "9d12ad182ab77726",
|
||||
"daa": 147
|
||||
}
|
||||
],
|
||||
"final": [
|
||||
{
|
||||
"count": 180,
|
||||
"sink": "040d5ae85d45bc24",
|
||||
"daa": 180
|
||||
},
|
||||
{
|
||||
"count": 180,
|
||||
"sink": "040d5ae85d45bc24",
|
||||
"daa": 180
|
||||
},
|
||||
{
|
||||
"count": 180,
|
||||
"sink": "040d5ae85d45bc24",
|
||||
"daa": 180
|
||||
},
|
||||
{
|
||||
"count": 180,
|
||||
"sink": "040d5ae85d45bc24",
|
||||
"daa": 180
|
||||
},
|
||||
{
|
||||
"count": 180,
|
||||
"sink": "040d5ae85d45bc24",
|
||||
"daa": 180
|
||||
}
|
||||
],
|
||||
"served_join_count": 147,
|
||||
"serving_node_alive": true,
|
||||
"accepted_old_new": [
|
||||
108,
|
||||
72
|
||||
],
|
||||
"rejected_by_miners_old_new": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"reject_lines": [
|
||||
[],
|
||||
[
|
||||
"HandleRelayInvsFlow flow error: wrong block version: got 1026 but expected 2, disconnecting from peer 127.0.0.1:56593.",
|
||||
"HandleRelayInvsFlow flow error: wrong block version: got 1026 but expected 2, disconnecting from peer 127.0.0.1:56829.",
|
||||
"HandleRelayInvsFlow flow error: wrong block version: got 1026 but expected 2, disconnecting from peer 127.0.0.1:57104."
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[]
|
||||
],
|
||||
"header_versions": {
|
||||
"0": 1,
|
||||
"2": 180
|
||||
},
|
||||
"secs": 160
|
||||
}
|
||||
|
|
@ -13,8 +13,14 @@
|
|||
|
||||
| # | Gate | Evidence required | State |
|
||||
|---|---|---|---|
|
||||
| P1 | the rehearsal plan and the v4 override object for the rented fleet | `docs/plans/counter-asic-3-rehearsal.md`; the objects with their digests | WRITTEN (not run: the fleet agent runs it): the publish object (the live 13 fields plus the floor `N6` and the window 86,400; digest `ac8e60ce205852bdda6b554f8cbfbd9dbb040187f487cbd8affe8103633dfd56` at the worked example N6 = 219,600) and the rehearsal object (every earlier switch at 0, window 3,600, floor 14,400; digest `bc2142b178ff367ae84ff0699ff523760d8878f883375da21864ed8d3ad39237`), both read from the signalling node's start lines on throwaway private networks; `override-v4-publish-example.json` and `override-v4-rehearsal.json` in this directory |
|
||||
| P1 | the rehearsal plan and the v4 override object for the rented fleet | `docs/plans/counter-asic-3-rehearsal.md`; the objects with their digests | WRITTEN (not run: the fleet agent runs it): the publish object (the live 13 fields plus the floor `N6` and the window 86,400; digest on the devnet network id `65a42ab2e63d93ac02acf761b411b31acac6c9413bbd0c6a2f4cced10509efdf` at the worked example N6 = 219,600, fork 0562a7f2), the rehearsal object (every earlier switch at 0, window 3,600, floor 14,400; digest on suffix 400 `aef46983dfd121d5ecbefd056ff02d001b89315bd0547b4e1007b0b0ee7eee2c`) and the re-cut for the hour (600-DAA epochs, window 600, floor 2,400; digest on suffix 400 `a15db4f0d6a5cc891582760ef5593e69014a5133261def84647cb7617528764f`, the value the 16 fleet boxes printed); CORRECTED 18:4xZ: the digest folds the network id in, and the first values (`ac8e60ce`, `bc2142b1`, `d23394e7`) were read on throwaway suffixes; CORRECTED 18:5xZ: the rehearsal chain inherits the devnet's genesis bits (the object sets none; the fast-time harness lowers them), so its miners are the GPU workers, not 1-thread CPU miners (the fleet's 16 CPU miners at about 80 kH/s against about 2^27 hashes a block held the height at 0 for 20 minutes); the id assertion on a worker box reads the id from the exported pack's program.h (the `program pack checked ... <dir>` line names it), the Mac side unchanged; `override-v4-publish-example.json` and `override-v4-rehearsal.json` in this directory |
|
||||
| P2 | miner-signalled class activation, implemented behind the override, the fast-time gate with three cases and the failed case | `docs/plans/counter-asic-3-node.md` section 6; `infra/fast-time/class-v4-signal.mjs` | GREEN on the Mac: two of three signalling never flips (7 epochs, 6,166 to 7,583 bps), all three flips at epoch 3 (the first full window, 10,000 bps, the same line on 3 of 3 nodes, the id assertion), nobody signalling flips at the floor epoch 5 and not before; the known-failed case fails on eight checks. Rows and summary files: node doc section 6.4; `class-v4-signal-{no-flip,flip,floor,failed-case}.json` |
|
||||
| G6 (signalling) | the fork change on PC 2 with the igneum-pow feature | the job ids and their lines | GREEN on fork 0562a7f2 (main f39a8eb), three PC 2 jobs under one clearance ("PC 2 open for G6", 17:27Z), the mkdir lock taken 17:28:15Z and released 17:38:58Z, then 17:39:41Z to 17:55:31Z; prover on, app untouched. Job 1 `build-20261006-173017` (the six crates and the app): every build stage ok, the app tests 112 + 26 + 8, but kaspa-consensus 97 passed and 1 FAILED on 2.0's known flake `ban_is_decided_by_the_carrying_block_so_nodes_agree_on_every_voter_list` (`UnexpectedDifficulty(.., 487111630, 487128802)` in `mine_on_all`, the 0.3.10 cut's section 11 case, which also passed on the Mac and in this morning's job 155958), and cargo stopped the unit there. Treated as 2.0 did: job 2 `build-20261006-174027` (kaspa-consensus alone, 17:40 to 17:46Z): `RESULT test node [kaspa-consensus] exit 0 19 s`, `done exit 0 after 345 s ... every stage ok`. Job 3 `build-20261006-174823` (the five crates and the app, 17:48 to 17:55Z): `RESULT test node [kaspa-consensus-core igneum-exec kaspa-pow igneum-miner kaspa-p2p-flows] exit 0 35 s` and `RESULT test app/igneum-app [igneum-app] exit 0 5 s`: kaspa-consensus-core 110 + 7 (`override_params_carry_the_program_class_v4_activation ... ok`, the signal window test inside it), igneum-exec 18, kaspa-pow 15 (`program_class_signal_rule ... ok` is consensus-core's; kaspa-pow's `program_class_v4_seeds_hash_the_shadow_program_over_the_v3_day_cache ... ok`, the feature on), igneum-miner 18, kaspa-p2p-flows 33, igneum-app 112 + 26 + 8; the job's own closing line reads `failed ... upload incomplete` because the relay's blob service refused one of the nine uploads (`igneum-app.exe.zst FAILED: blob PUT: no url in the reply: service_unavailable`, the shipper's 17:25Z fault) AFTER both test stages had closed with exit 0, so the STAGE and RESULT lines are the evidence, as the shipper's warning said. Where the runs went: these three on PC 2 under the clearance given before the build-server rule arrived (18:0xZ); no further Linux build or suite is needed by this brief; the next one from this lane goes to igneum-build-1 through tools/build-remote.sh |
|
||||
|
||||
| P3 | the 0.3.15 digest rule (main's ruling, 20:1x UK): an absent class v4 field contributes nothing to the digest, so publish 1 (the binary) splits no handshake and publish 2 (the sixteen-field file) is the one sweep | the compat case and the refusal case on real nodes; the pinned fixtures | GREEN on fork ca3-v4-order-fix 713ef876 (with the order-test race fix 791ff22c; both handed to the shipper for release-0.3.15-node): `infra/fast-time/digest-compat.mjs` (18:58Z, suffix 995, the live thirteen-field file copied, never written): the fixed node and a 0.3.14 node (`target-0314/release/igneumd`) on the thirteen-field file print ONE digest `a89be8a7e64b7d5a...` and peer (n0 has 2 peers, the old node 1: the compat case); the fixed node on the sixteen-field file (the exec pin, the floor 219,600, the window 86,400) prints `db9a85f9ff08f72f...` and has no peer, the handshake's `Refusing peer ...: consensus params digest mismatch` line in the log (the refusal case, the rule's known-failed case: present fields move the digest). On the devnet network id the sixteen-field object digests to `1dddfa5574d5536109a5ae68dc415b55e954bd11208608440845e19670505871` (the fixed Mac binary's start line = the pinned test value), the thirteen-field live file to `b18ed271f75dd464...` (0.3.14's value), fourteen fields `23e76936...`; no file `c562d70e...` (0.3.11 to 0.3.15 unchanged). Box line on 713ef876: kaspa-consensus 98, consensus-core 111 + 7, rc 0. The rehearsal object sets both fields, so its `a15db4f0` on devnet-400 stands. The shipper's independent readings on the 0.3.15 Mac binary (release-0.3.15-node = 4c6b129d + 0562a7f2 + 2e5d3d30 + 791ff22c + 713ef876, 19:0xZ): the same three values, and at tonight's live floor 226,800 the sixteen-field object reads `2c1162e2...`, the publish-2 digest if the floor stays there (recomputed at the publish); the 0.3.14 binary exits at parse on the sixteen-field file, which is why the file follows the binary; its suite on igneum-build-1 at 713ef876: kaspa-consensus 97 + the recorded flake alone 1 of 1, consensus-core 111 + 7, igneum-exec 20, kaspa-pow 15 with both engine tests, igneum-miner 18, p2p-flows 33. Summary `digest-compat-20261006.json` |
|
||||
|
||||
| P4 | the two 0.3.15 node faults found on the live devnet (the canary's version 1026, the pruned hub's sync panic), fixed in the same fork tree | the unit tests; the node-cut compat gate on real nodes (a MINING new node beside a 0.3.14 node on the live object, the old node accepting the new node's blocks; a clean new node joining through the old hub; a clean OLD node joining through the NEW node, which must survive serving a sync from the genesis; the new node restarted and re-synced; every header version the block version; no panic); the ledger rows | GREEN on fork ca3-v4-order-fix 7961c5f1 (= 791ff22c + 713ef876 + 17c60367 the signal gate + 7961c5f1 the retention error; the shipper's release-0.3.15-node). The gate `infra/fast-time/node-compat.mjs` (20:15:5x to 20:19:04Z, suffix 994, the live object plus CPU genesis bits, the 0.3.14 binary `target-0314/release/igneumd` as the old side): SUMMARY PASS: one digest `b0afb2ee93d0d627` on all five nodes; accepted old/new 120/75, rejected 0/0; header versions {0: the genesis, 2: 195}; counts 195/195/195/195/195 at the end (the clean new node through the old hub, the clean old node served from the genesis by the new node, which stayed alive, and the restarted new node all at 195); no `wrong block version`, reject or panic line in any log. The known-failed case on the canary binary 713ef876 beside 0.3.14 (20:00 to 20:02Z): SUMMARY FAIL, `HandleRelayInvsFlow flow error: wrong block version: got 1026 but expected 2, disconnecting` on the old nodes, `P2P, got reject message: wrong block version` on the new, counts 138 against the new node's 101, the restarted new node never re-synced (the fleet's p2-3090-1). Unit tests: `block_template_uses_current_block_version` (2 on the thirteen-field state, 1026 with both v4 fields), `program_class_signal_rule`, `a_sync_request_below_retention_is_an_error_not_a_panic`; the box on 7961c5f1: kaspa-consensus 99, consensus-core 111 + 7, rc 0 (the shipper's six-crate line: exec 20, miner 18, pow 15, p2p-flows 33). Ledger rows N1 and N2 in `docs/fud-ledger.md`. RECEIVE SIDE (the clean canary, 21:3x UK: a 7961c5f1 node accepted and relayed a version-1026 block off a poisoned peer and was disconnected by every 0.3.14 peer): fork f1ea7a38 gates the header version RULE like the stamp (signalling inactive: the version must be exactly 2, 0.3.14's rule; active: the low byte); test inside `cheap_checks_run_before_the_pow_engine` (a 1026 header refused with `WrongBlockVersion(1026, 2)` before the engine on the old-format network, accepted with both fields installed); box on f1ea7a38: kaspa-consensus 99, consensus-core 111 + 7, rc 0. The gate with a POISONED peer (`--poisoned <713ef876 igneumd>` mining into the new node, 20:42:5x to 20:45:42Z): SUMMARY PASS: the new node refused the poisoned blocks 12 times (`HandleRelayInvsFlow flow error: wrong block version: got 1026 but expected 2, disconnecting`), the old hub's log carries no reject and no 1026 block (header versions {0: the genesis, 2: 180}), the new node stayed the hub's peer and mined 72 accepted blocks, counts 180 on every clean node including the clean joins and the restart. The stale blocks on the live devnet: a 0.3.14 node holding 1026 blocks is disconnected by its 0.3.14 peers by THEIR rule when it relays them, which no new-node change alters; new nodes are immune from f1ea7a38; the fleet wipes the poisoned datadirs. Owed: a truly pruned serving node in a harness (hours of fast-time chain at pruning depth 13,838). Summaries `node-compat-20261006-fixed.json`, `node-compat-20261006-canary-failed-case.json`, `node-compat-20261006-poisoned-peer.json` |
|
||||
|
||||
| P5 | the dependency pass for 0.3.15.1 (the night battery's audit, mirror branch fbb72e5: the fork's lock file) | `cargo audit` before and after on the box; the six-crate suite | DONE on fork `ca3-v4-deps` f8f0f1df (from 7961c5f1): `cargo audit` on igneum-build-1 before: 6 vulnerabilities (crossbeam-epoch RUSTSEC-2026-0204, h2 2026-0258, quinn-proto 2026-0185, ruint 2026-0220, rustls 2026-0285, tracing-subscriber 2025-0055; the battery's 22 counts advisory paths), 16 warnings (unmaintained, unsound, yanked); the three peer-reachable crates first (h2 0.4.6 to 0.4.20, quinn-proto 0.11.14 to 0.11.19, rustls 0.23.39 to 0.23.45 with rustls-webpki 0.103.15), then crossbeam-epoch 0.9.21 and ruint 1.20.1 (rand_pcg added), every one a patch or minor bump by the audit's own solution line; after: 1 vulnerability, 16 warnings; the one left is tracing-subscriber 0.2.25, pinned by ark-relations 0.5.1 through ark-groth16 and ark-snark (a 0.2 to 0.3 major bump outside the fork's pins, which name 0.3 in bridge/Cargo.toml), named here, not taken. The six-crate suite on the box on the new lock: kaspa-consensus 99, consensus-core 111 + 7, igneum-exec 20, kaspa-pow 15, igneum-miner 18, kaspa-p2p-flows 33, rc 0 (80 s). The 16 warnings are unmaintained or unsound transitive crates (async-std, atty, bincode 1, derivative, instant, mach, paste, proc-macro-error, rustls-pemfile, anyhow, event-listener, faster-hex, lru, chacha20, spin), none with a bump to take, each a dependency choice for 0.3.16 |
|
||||
|
||||
Summary files in this directory: `class-v4-20261006-1553Z-cpu.json` (G4 run 1), `class-v4-20261006-never-failed-case.json` (G4 run 2, the failed case), `class-v4-20261006-metal.json` (G4b), `class-v4-20261006-id-rerun.json` (G4 on the program-id fix, with the id assertion), `class-v4-20261006-id-failed-case.json` (the id assertion's failed case).
|
||||
|
|
|
|||
33
docs/plans/counter-asic-3-public-text-2026-10-07.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# The chip claim, public text (7 October 2026, 14:3x UK, on the project lead's "this needs updating with all of our updates"; served since 11:03 UK on master 9162c847 with main's two cuts: no mention of the disclosure prize until the publish word, and row 17 in evidence.md's eight-column shape)
|
||||
|
||||
Three texts and one ledger row, written by the Counter ASIC lane, which owns the chip model. Every number carries its label: measured (a card or a chain we ran, with the date), modelled (arithmetic on cited parts), claimed (a vendor's figure, never measured by us), designed (a rule in a class, not yet measured). Sources: `docs/analysis/chip-model-v3.md` sections 5 and 6, `docs/analysis/latency-shadow-2026-10-06.md`, `docs/plans/counter-asic-3-status.md`, `docs/analysis/attack-pass/f8-uniform.md` and `f4-weakday.md` (branch attack-pass), `docs/design/class-v5-stored-state.md`, the datacentre and market-cap rows of 7 October (lanes 3 and the fleet), the cryptanalysis plan in `docs/plans/funding.md`.
|
||||
|
||||
## 1. The home page's chip line (replaces the hero sentence served since 6 October 16:21Z)
|
||||
|
||||
Built for graphics cards. In our public model the strongest chip reaches 5x to 9x per joule against an RTX 5090 today; class v4, now on the vote, brings that to 2.1x to 3.9x, and class v5 makes the dataset the chain's own state, so a chip that stores it or recomputes it is wrong on every item. The model and every measurement are public.
|
||||
|
||||
## 2. The litepaper's chip section (replaces the paragraph that begins "The chip model: 5x to 9x per joule")
|
||||
|
||||
The chip model. We price the strongest chip we can design against an RTX 5090 and publish the arithmetic. The honest card: an RTX 5090 mines class v3 at 136 MH/s on 350 W in the bench and 290 W in the app (measured, 6 October 2026); an Apple M5 Max at 27 MH/s on 21 W (measured, 6 October 2026); an H100 SXM at 249 MH/s, 98 percent of its random-read ceiling like the 5090, 1.78x the 5090's hash at 1.15x the tuned 5090's hash per watt and a third of the hash per rented dollar (measured, 7 October 2026), so datacentre silicon does not change the chip question. The CPU verifier takes 2.33 ms per warp of 32 hashes on one M5 Max core under class v4 (measured, 6 October 2026), against a gate of 10 ms.
|
||||
|
||||
| The chip and the class | Edge over an RTX 5090 per joule | Label and date |
|
||||
|---|---|---|
|
||||
| A memory-controller chip that stores the whole dataset (the Ethash class), class v3 | 5x to 9x (5.1x on GDDR7, 9.2x on eight HBM3 stacks; the Ethash chips of this class reached 2.1x to 4.8x) | modelled, 6 October 2026; the precedent measured by others, 2020 to 2022 |
|
||||
| The same chip under class v4 (about 100,000 integer ops per hash in the latency shadow, so the chip carries a GPU-class datapath beside its memory) | 2.1x with a core as costly per op as the GPU's (k = 1); 3.9x with the core Bitmain claimed for its Antminer X9 (k about 0.33), a product withdrawn before any unit shipped | modelled on measured card watts, 6 October 2026; the X9 figure claimed, never measured |
|
||||
| The same chip at the ladder's second rung (about 200,000 ops per hash) | about 2.8x | modelled, 7 October 2026 |
|
||||
| Any chip under class v5, where the dataset is the chain's own state | a stateless or stale chip is wrong on every item, so the stored-dataset chip and the recompute chip are removed as categories; the verifier pays 0.2 ms more per warp | designed, 7 October 2026 |
|
||||
| A chip caching the hottest 0.1 percent of items (about 1 MB of SRAM) | bounded at 1.067x at the ceiling, 1.005x on about half the hours and 1.048x on 5 percent | measured census of 1,024 programs, 7 October 2026; the source rule in the next class |
|
||||
| A per-day FPGA that recomputes the dataset with cheap multipliers on a weak day | at most 12 percent more hash rate on 12 days a century, nothing on the other days and nothing for any chip | measured census of 2^24 days, 7 October 2026; the rule in the next class |
|
||||
| When a stored-dataset chip pays for itself | at about USD 100 M of market cap in the first two years, not before | modelled, 7 October 2026 |
|
||||
|
||||
What a miner sees from this. Class v4 costs a 5090 about 80 W more for 0.2 percent of rate, an M5 Max 16 W more for 1.5 percent, an RX 9070 XT and an RTX 4070 nothing (all measured, 6 October 2026). The ladder that sets how much work rides in the shadow starts at rung 0 at the testnet genesis and climbs by miner signal; its third rung is inadmissible today because a server core verifies it in 10.85 ms, over the gate (measured, 7 October 2026). The next test of the model is not ours: the cryptanalysis plan buys three external lots against the mixer, the chained cache and the acceptance rule.
|
||||
|
||||
## 3. The miner page's line
|
||||
|
||||
Your card against the strongest chip we can price: an RTX 5090 at 136 MH/s on 350 W (measured 6 October 2026), the chip 5x to 9x per joule in the public model today, 2.1x to 3.9x under class v4 (modelled on measured watts), and under class v5 wrong on every item because the dataset is the chain's own state (designed); the model and the measurements are public.
|
||||
|
||||
## 4. The ledger row (docs/evidence.md row 17, in the table's eight columns as served)
|
||||
|
||||
| # | Claim | Where it is made | Status | Version or commit | Reproducible test | Result, date, machine | Independent verification |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 17 | The chip resistance claim: the strongest chip in the public model reaches 5x to 9x per joule against an RTX 5090 today; class v4 brings it to 2.1x (k = 1) to 3.9x (k about 0.33) and its second rung to about 2.8x; class v5 makes the dataset the chain's state so a stateless or stale chip is wrong on every item; the hot-set cache is bounded at 1.067x at the ceiling and the weak-day FPGA at 12 percent on 12 days a century, both routed to the next class; datacentre silicon does not change the question; a stored-dataset chip pays for itself only at about USD 100 M of market cap in two years | the home page's chip line, the litepaper's chip section (/litepaper#chip-model), the miner page's line | tested by the team (every card, the verifier, the two attack-pass bounds, the H100), the chip itself modelled, class v5 and the ladder designed, the X9 core claimed and never measured | `docs/analysis/chip-model-v3.md` 5 and 6; `docs/analysis/latency-shadow-2026-10-06.md`; `docs/plans/counter-asic-3-status.md`; `docs/analysis/attack-pass/f8-uniform.md`, `f4-weakday.md`, `docs/analysis/ca3-v4-uniform.md`; `docs/design/class-v5-stored-state.md`; the H100 and market-cap rows of 7 October; `docs/plans/funding.md` (the three lots) | the chip model's arithmetic in its file; the card rows by the benchmark package; the attack-pass harnesses `tools/attack/f8-uniform` and the F4 census; the verifier by `igneum-pow bench` | 136 MH/s at 350 W (5090, bench) and 290 W (app); 27 MH/s at 21 W (M5 Max); 249 MH/s (H100 SXM) at 98 percent of its read ceiling, 1.78x hash, 1.15x MH/W, a third per rented dollar; 2.33 ms per warp; 5.1x to 9.2x; 2.1x, 3.9x, 2.8x; 1.067x at the ceiling; 12 percent on 12 days a century; 10.85 ms at rung 3; USD 100 M; 6 and 7 October 2026, the M5 Max, PC 2's RTX 5090, PC 1's RX 9070 XT and RTX 4070, a rented H100 SXM, igneum-build-1 | none yet; the three cryptanalysis lots are the next test |
|
||||
|
|
@ -16,7 +16,7 @@ The class v4 activation on a chain of real boxes, in the shape the live devnet w
|
|||
|
||||
## 2. The override objects
|
||||
|
||||
Both objects below are JSON text to be written verbatim; a `never` height is `18446744073709551615`, which no JSON tool that goes through a double may rewrite (the fast-time harness's rule). The digests are what a node of the signalling commit prints at start (`Consensus params digest`); the fleet agent compares every box's line against them and the stale box's against its own.
|
||||
Both objects below are JSON text to be written verbatim; a `never` height is `18446744073709551615`, which no JSON tool that goes through a double may rewrite (the fast-time harness's rule). The digests are what a node of the signalling commit (fork 0562a7f2) prints at start (`Consensus params digest`); the fleet agent compares every box's line against them and the stale box's against its own. CORRECTION 18:4xZ (the rehearsal's first reading): the digest folds the NETWORK ID in, so one file prints another digest on another `--devnet-suffix` (the same binary and file: `a15db4f0...` at suffix 400, `d23394e7...` at 996, `45eeea99...` at 401); the first values written here were read on throwaway suffixes and were wrong for the fleet's suffix 400. Every digest below now names its network id; the one the live devnet prints is the one read with no suffix.
|
||||
|
||||
### 2a. The live devnet publish object (NOT published by this plan; the shape the cut will use)
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ The live file today (`/tmp/igneum-devnet/override-v3.json`, 13 fields, read 16:5
|
|||
{"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":"bb45cf0dd2d7cc97ebfa5a2701527c09a8ede5d32de74efead9caa293b15688a","exec_restart_trust_daa":200000,"program_class_v4_activation_daa":N6,"program_class_v4_signal_window_daa":86400}
|
||||
```
|
||||
|
||||
What the two fields do on the live devnet: every node of the signalling binary stamps object byte 4 into its templates from its first block, so the signal share climbs as the fleet updates; the class flips at the first epoch boundary whose window (the 86,400 DAA, one day, below that epoch's seed block) has 95 percent of its blue blocks signalling, which is about a day after the LAST box of 95 percent of the hash rate has updated; the floor `N6` flips it regardless at the latest. Digest of this object: `ac8e60ce205852bdda6b554f8cbfbd9dbb040187f487cbd8affe8103633dfd56` (read from the signalling node's start line, 18:05Z; the node also prints `Program class v4 from the override file: active from epoch 61 (DAA score 219600 rounded up to the epoch boundary at 219600, epochs of 3600 DAA)` and the window line) (with `N6` = 219,600 as the worked example; any other `N6` moves it).
|
||||
What the two fields do on the live devnet: every node of the signalling binary stamps object byte 4 into its templates from its first block, so the signal share climbs as the fleet updates; the class flips at the first epoch boundary whose window (the 86,400 DAA, one day, below that epoch's seed block) has 95 percent of its blue blocks signalling, which is about a day after the LAST box of 95 percent of the hash rate has updated; the floor `N6` flips it regardless at the latest. Digest of this object on the DEVNET network id (no suffix), the signalling fork 0562a7f2: `65a42ab2e63d93ac02acf761b411b31acac6c9413bbd0c6a2f4cced10509efdf` (18:4xZ; the earlier `ac8e60ce...` was read at suffix 998 and does not apply); the no-file devnet digest on the same binary is `7f2e49be...`, the fork test's pinned value. The 0.3.15 binary may move both if 0.3.14 added a digest field: the shipper reads the live value from the 0.3.15 node. The node also prints `Program class v4 from the override file: active from epoch 61 (DAA score 219600 rounded up to the epoch boundary at 219600, epochs of 3600 DAA)` and the window line. CUT ORDER (found by the shipper, 18:3xZ): a 0.3.13 node refuses the 15-field file at PARSE (`unknown field program_class_v4_activation_daa`) and exits before any handshake, so the file reaches a node only with or after its 0.3.15 binary (the app applies the manifest's override after the update; the hand nodes and the seeds get the file in the same step as the binary, never before) (with `N6` = 219,600 as the worked example; any other `N6` moves it).
|
||||
|
||||
### 2b. The rehearsal object (the fleet chain)
|
||||
|
||||
|
|
@ -36,11 +36,11 @@ A fresh chain, every earlier switch at 0 (the testnet's shape: the chain is born
|
|||
{"difficulty_v2_activation_daa":0,"proving_v0_activation_daa":0,"fees_v1_activation_daa":0,"finality_v3_activation_daa":0,"program_class_v3_activation_daa":0,"proving_v1_activation_daa":0,"proving_v1_segment_blocks":8,"proving_v1_unproven_daa":600,"proving_v1_aggregator_share_bps":1000,"proving_v1_fresh_rule_daa":0,"exec_restart_number":18446744073709551615,"exec_restart_hash":"","exec_restart_trust_daa":18446744073709551615,"program_class_v4_activation_daa":14400,"program_class_v4_signal_window_daa":3600}
|
||||
```
|
||||
|
||||
The arithmetic: epochs of 3,600 DAA, lead 600. Epoch `e`'s seed block is the last chain block below `3600 e - 600`; its window is full when that block's DAA is at least 3,600: epoch 1's seed block sits at DAA 2,999 (not full), epoch 2's at 6,599 (full). With every mining box signalling 4, the tally at epoch 2's seed block is 100 percent of the blue blocks in DAA 2,999 to 6,599, so the class flips at epoch 2, DAA 7,200, about 2 hours after genesis; the floor (epoch 4, DAA 14,400) is 2 hours later and is not reached by the run. Digest: `bc2142b178ff367ae84ff0699ff523760d8878f883375da21864ed8d3ad39237` (the signalling node's start line on this object, 18:05Z, with `Program class v4 from the override file: active from epoch 4 (DAA score 14400 ...)` and `Program class v4 signal window from the override file: 3600 DAA ...`); the devnet digest with no file at all is `7f2e49beabc253f327c5ac6bb457a674ea7f527af2971c95d3bdf65ef8bcf977` on this binary (the fork's pinned test), `c562d70e...` on 0.3.11 to 0.3.13.
|
||||
The arithmetic: epochs of 3,600 DAA, lead 600. Epoch `e`'s seed block is the last chain block below `3600 e - 600`; its window is full when that block's DAA is at least 3,600: epoch 1's seed block sits at DAA 2,999 (not full), epoch 2's at 6,599 (full). With every mining box signalling 4, the tally at epoch 2's seed block is 100 percent of the blue blocks in DAA 2,999 to 6,599, so the class flips at epoch 2, DAA 7,200, about 2 hours after genesis; the floor (epoch 4, DAA 14,400) is 2 hours later and is not reached by the run. Digest on `igneum-devnet-400` (suffix 400): `aef46983dfd121d5ecbefd056ff02d001b89315bd0547b4e1007b0b0ee7eee2c` (the signalling node's start line, 18:4xZ; the earlier `bc2142b1...` was read at suffix 997); the node also prints `Program class v4 from the override file: active from epoch 4 (DAA score 14400 ...)` and `Program class v4 signal window from the override file: 3600 DAA ...`.
|
||||
|
||||
### 2c. The re-cut for the hour (18:1x UTC, the coordinator's "run it now")
|
||||
|
||||
If the fleet chain runs the devnet's 1 block/s, object 2b flips at 2 hours; this object flips at 20 minutes: epochs of 600 DAA (lead 100), window 600, floor 2,400. Epoch `e`'s seed block sits at DAA `600 e - 100`; the window is full from epoch 2 (seed at 1,100), so the flip is at epoch 2, DAA 1,200, minute 20 from the chain's start; the floor is epoch 4, DAA 2,400, minute 40; the run ends at DAA 1,800 (minute 30, one epoch after the flip; the floor is not reached). If the fleet chain is made to run 10 blocks/s instead, object 2b itself gives 6 / 12 / 24 minutes (window / flip / floor) and this one 1 / 2 / 4 minutes, too fast for a 15-minute sample cadence: use 2b. File `docs/plans/counter-asic-3-gate/override-v4-rehearsal-1bps.json`, digest `d23394e796fb542274207f1d281bc40126040e04f86882faa0b49ea3c3f1f91b` (the signalling node's start line, with `PoW schedule from the override file: epoch 600 DAA, lead 100 DAA, day 86400000 ms`, `Program class v4 from the override file: active from epoch 4 (DAA score 2400 ...)`, `Program class v4 signal window from the override file: 600 DAA ...`).
|
||||
If the fleet chain runs the devnet's 1 block/s, object 2b flips at 2 hours; this object flips at 20 minutes: epochs of 600 DAA (lead 100), window 600, floor 2,400. Epoch `e`'s seed block sits at DAA `600 e - 100`; the window is full from epoch 2 (seed at 1,100), so the flip is at epoch 2, DAA 1,200, minute 20 from the chain's start; the floor is epoch 4, DAA 2,400, minute 40; the run ends at DAA 1,800 (minute 30, one epoch after the flip; the floor is not reached). If the fleet chain is made to run 10 blocks/s instead, object 2b itself gives 6 / 12 / 24 minutes (window / flip / floor) and this one 1 / 2 / 4 minutes, too fast for a 15-minute sample cadence: use 2b. File `docs/plans/counter-asic-3-gate/override-v4-rehearsal-1bps.json`, digest on `igneum-devnet-400` (suffix 400): `a15db4f0d6a5cc891582760ef5593e69014a5133261def84647cb7617528764f`, the value all 16 fleet boxes printed on the Linux build of the same fork commit 0562a7f2 (PC 2 job build-20261006-174823; the earlier `d23394e7...` was this file at suffix 996), with `PoW schedule from the override file: epoch 600 DAA, lead 100 DAA, day 86400000 ms`, `Program class v4 from the override file: active from epoch 4 (DAA score 2400 ...)`, `Program class v4 signal window from the override file: 600 DAA ...`).
|
||||
|
||||
```
|
||||
{"difficulty_v2_activation_daa":0,"proving_v0_activation_daa":0,"fees_v1_activation_daa":0,"finality_v3_activation_daa":0,"program_class_v3_activation_daa":0,"proving_v1_activation_daa":0,"proving_v1_segment_blocks":8,"proving_v1_unproven_daa":600,"proving_v1_aggregator_share_bps":1000,"proving_v1_fresh_rule_daa":0,"exec_restart_number":18446744073709551615,"exec_restart_hash":"","exec_restart_trust_daa":18446744073709551615,"pow_epoch_blocks":600,"pow_epoch_lead":100,"program_class_v4_activation_daa":2400,"program_class_v4_signal_window_daa":600}
|
||||
|
|
@ -55,12 +55,12 @@ With this object the step-7 line reads `epoch 2 (share 10000 bps over 600 DAA ..
|
|||
| 1 | Fetch the signalling commit's Linux binaries from the G6 build job (the shas in node-gates.md), verify every sha256, place `igneumd` and `igneum-miner` on every box; the 0.3.13 Linux `igneumd` on the stale box | every sha matches |
|
||||
| 2 | Write the rehearsal object (2b) as `override.json` on every box, byte for byte (sha256 the file on each box and compare) | one sha on every box |
|
||||
| 3 | Start the seed box: `igneumd --devnet --devnet-suffix=400 --nodnsseed --disable-upnp --listen=0.0.0.0:16411 --rpclisten=127.0.0.1:16410 --rpclisten-json=127.0.0.1:16412 --override-params-file=override.json --utxoindex --enable-unsynced-mining --yes --appdir=<fresh dir>` (ports of the box's choosing, never the live devnet's 26610/26611); read its first lines: `Consensus params digest` equals 2b's, `Program class v4 from the override file: active from epoch 4 (DAA score 14400 ...)`, `Program class v4 signal window from the override file: 3600 DAA ...` | the three lines |
|
||||
| 4 | Start every mining box the same way with `--connect=<seed>:16411`, then its miner: `igneum-miner mine grpc://127.0.0.1:16410 1 100000000 <label> --engine igneum-pow --no-vote --payout-label <label>` (a CPU miner; a GPU box uses `--worker <path> --prepare-packs packs/prepare --exit-on-seed-change`, the app's shape) | every box's node prints the same digest and the two switch lines; every miner prints `epoch seed ... class v3 program id ...` for epoch 0 |
|
||||
| 4 | Start every mining box the same way with `--connect=<seed>:16411`, then its miner on the box's GPU worker, the app's shape: `igneum-miner mine grpc://127.0.0.1:16410 1 100000000 <label> --engine igneum-pow --worker <igneum-worker-cuda> --worker-args "--pack packs/devnet" --prepare-packs packs/prepare --exit-on-seed-change --no-vote --payout-label <label>`. CORRECTED 18:5xZ from the run: the rehearsal object inherits the devnet's genesis bits (the fast-time harness lowers `genesis_bits` to 0x1f010000, 2^16 hashes a block; the object does not), so a 1-thread CPU miner (about 5 kH/s a box, 80 kH/s for 16, the fleet agent's reading) against the genesis difficulty (about 2^27 hashes a block, the fleet agent's figure) finds a block every 28 minutes for the whole fleet and the height stayed 0 for 20 minutes; the GPU workers find blocks at the chain's 1 a second | every box's node prints the same digest and the two switch lines; every miner prints `program pack checked for epoch seed <E> day <D>: attempt <A>, <dir>` for epoch 0 and the worker's `ready ... self-test PASS` |
|
||||
| 5 | Start the stale box last, the same command on the 0.3.13 node, `--connect=<seed>:16411` | its log shows the handshake refusal (a digest mismatch line or `0 peers` after 60 s with connection attempts in the log) and its chain stays at its own genesis (block count 1 or its own lonely blocks if it mines; it must NOT mine: no miner on it) |
|
||||
| 6 | Every 15 minutes, on every mining box: `getBlockDagInfo` (block count, sink, virtual DAA) and one `getBlockTemplate` (`powEpoch.programClass`, `nextProgramClass`, `programClassV4SignalBps`, `programClassV4SignalEpoch`); keep the lines | the shares read 10,000 bps on every box from the first template; sinks agree across boxes at each sample |
|
||||
| 7 | At DAA 7,200 (about 2 h): every box's node prints `Program class v4 by miner signal: epoch 2 (share 10000 bps over 3600 DAA ending at seed block <hash>, threshold 9500 bps, N of N blue blocks)` with the SAME seed block hash and the same N on every box; the templates read class 4 from epoch 2; every miner prints `epoch seed <S2> ... class v4 program id <id>` | the same `<S2>`, the same `<id>` on every box |
|
||||
| 8 | Run to DAA 10,800 (epoch 3, one epoch after the flip; two epochs after is 14,400, the floor, so the run stops at 10,800 plus 600) | the final sample |
|
||||
| 9 | Collect every miner's `program and 256 MiB cache ready` lines (seed, class, id) and the template's `eraSeed` for epochs 2 and 3, and send them to the node lane; the Mac computes `igneum-pow show --epoch-hex <seed> --program-class v3|v4 --era-hex <era>` for each (the id assertion of the G4 harness, no binary needed on a box) | every box's v4 id equals the CLI's v4 id and differs from the CLI's v3 id of the same seed and era |
|
||||
| 9 | The id assertion, the same Mac-side procedure with the box's id read from the pack, not a log line (a worker-mode miner prints no `program id` line; the Metal run of G4b showed the same): on every box, for epochs 2 and 3, take the `program pack checked for epoch seed <E> day <D>: attempt <A>, <dir>` line and read `<dir>/program.h`'s `IGNEUM_GENERATOR` (must be 4), `IGNEUM_PROGRAM_CLASS` ("v4"), `IGNEUM_PROGRAM_ID`, `IGNEUM_SEED_BYTES_HEX` and `IGNEUM_ERA_SEED_HEX` (the miner's `PREPARE sent ... class v4 era=<hex>` line carries the same era), and send the five values per box and epoch to the node lane; the Mac computes `igneum-pow show --epoch-hex <IGNEUM_SEED_BYTES_HEX> --program-class v4 --era-hex <IGNEUM_ERA_SEED_HEX>` and the same with `v3` | every box's `IGNEUM_PROGRAM_ID` equals the CLI's v4 id and differs from the CLI's v3 id of the same seed and era; one id per epoch across boxes |
|
||||
| 10 | Stop every node and miner; destroy the instances by the fleet plan's rule | the report in section 4 is in |
|
||||
|
||||
Never: no live-devnet port, no live override file, no manifest, no `update-now`; the boxes' app installs (if any) are not touched (the rule of 5 October: a job never quits or restarts an app it did not start).
|
||||
|
|
@ -71,13 +71,13 @@ Never: no live-devnet port, no live override file, no manifest, no `update-now`;
|
|||
|
||||
| Field | From | Pass rule |
|
||||
|---|---|---|
|
||||
| `digest` per box | the node's first lines | one value on every signalling box, equal to 2b's; the stale box prints 0.3.13's digest of the same file (a different value, since its binary lacks the two fields) |
|
||||
| `digest` per box | the node's first lines | one value on every signalling box, equal to the object's suffix-400 value (2b `aef46983...`, 2c `a15db4f0...`); the stale box prints 0.3.13's digest of the same file (a different value, since its binary lacks the two fields) |
|
||||
| `switch_lines` per box | the node's first lines | the floor line names epoch 4 and the window line names 3,600 DAA, on every signalling box |
|
||||
| `signal_line` per box | step 7 | present on every signalling box, epoch 2, the same seed block hash, share 10,000 bps (at least 9,500) |
|
||||
| `blocks_before`, `blocks_after` | `getBlocks` from any box, split at DAA 7,200 | both over 0 |
|
||||
| `rejected` | every miner's `rejected=` STATUS count and every node's `PoW rejected` lines | 0 on every box |
|
||||
| `sinks` at the end | `getBlockDagInfo` | one value on every signalling box |
|
||||
| `program_ids` per epoch per box | the miners' lines | one id per epoch across boxes; epochs 2 and 3 class v4; the Mac's CLI check (step 9) holds |
|
||||
| `program_ids` per epoch per box | the packs' `program.h` named by the miners' `program pack checked` lines (step 9) | one id per epoch across boxes; epochs 2 and 3 generator 4, class v4; the Mac's CLI check holds |
|
||||
| `stale_box` | its log and `getBlockDagInfo` | refused (no peer), block count 1, no block of its ever appears on any signalling box's chain (its own genesis-state chain and the fleet's never merge) |
|
||||
| `shares` per sample | step 6 | 10,000 bps on every box at every sample |
|
||||
|
||||
|
|
|
|||
|
|
@ -22,13 +22,55 @@ BS_HOST_FILE="${IGNEUM_BUILD_HOST_FILE:-$HOME/.config/igneum/build-server}" #
|
|||
# caller passes --box: gates, builds, checks, cross-builds, the workers, the hands and the observer stay on box 1; suites, benches and
|
||||
# the attack rows go to box 2; proving and aggregation CPU work, the second prover's shadow runner and the pool's fast-time NETWORK go
|
||||
# to box 3. A class whose box has no host file yet falls back to box 1, and the log line says so. No box mines, ever (README.md).
|
||||
# Since 7 October 2026 15:xx UK the class is a preference with spill-over (bs_route_spill below): a full or overloaded box hands
|
||||
# the job to the other one.
|
||||
bs_box_file() { case "${1:-1}" in 1) echo "$BS_HOST_FILE" ;; *) echo "${BS_HOST_FILE}-$1" ;; esac; }
|
||||
bs_route() { # <class: gate|build|check|suite|bench|prove|attack|other> -> the box number, falling back to 1
|
||||
bs_route() { # <class: gate|build|check|suite|bench|prove|attack|other> -> the PREFERRED box number, falling back to 1
|
||||
local want=1
|
||||
case "$1" in suite|bench|attack) want=2 ;; prove|shadow|fasttime) want=3 ;; esac
|
||||
if [ "$want" != 1 ] && [ ! -s "$(bs_box_file "$want")" ]; then bs_log "class $1 routes to box $want, which has no host file yet ($(bs_box_file "$want")): box 1"; want=1; fi
|
||||
if [ "$want" != 1 ] && [ ! -s "$(bs_box_file "$want")" ]; then bs_log "class $1 prefers box $want, which has no host file yet ($(bs_box_file "$want")): box 1"; want=1; fi
|
||||
echo "$want"
|
||||
}
|
||||
# Spill-over (the project lead, 7 October 2026, 15:02 UK: build-1 at load 139 with a queue of 1 h 40 min while build-2 read 4.5 with both
|
||||
# slots free). The class is a PREFERENCE, not a pin: a job goes to its class's box unless that box has no free slot or its 1-minute
|
||||
# load is above BS_SPILL_LOAD (64), in which case it goes to the other box when THAT one has a free slot under the same load
|
||||
# line; when neither qualifies it queues on its own box. The alternate of box 1 is box 2, of box 2 box 1, of box 3 box 1; a box
|
||||
# without a host file is never chosen. The decision is one line on the Mac (bs_log) and travels to the box in BR_ROUTE_* for the
|
||||
# JSONL row ("route": preferred, box, spilled, reason), so the dashboard shows it per job. A box is read with one ssh
|
||||
# (bs_box_state: free slots of the slot count, load1); BS_ROUTE_STATE_<n> in the environment replaces the ssh for the self-test
|
||||
# (tools/ci/route-spill-check.sh), "down" standing for an unreachable box.
|
||||
BS_SPILL_LOAD="${BS_SPILL_LOAD:-64}"
|
||||
bs_box_state() { # <box> -> "free=<n> slots=<n> load1=<x>" | "absent" | "down"
|
||||
local b="$1" v f h
|
||||
v=$(eval "printf '%s' \"\${BS_ROUTE_STATE_$b:-}\""); if [ -n "$v" ]; then printf '%s' "$v"; return 0; fi
|
||||
f=$(bs_box_file "$b"); [ -s "$f" ] || { printf 'absent'; return 0; }
|
||||
h="$(head -1 "$f" | tr -d '[:space:]')"
|
||||
ssh "${BS_SSH_OPTS[@]}" -o ConnectTimeout=8 "$h" 'd=/srv/builds/_locks; n=$(cat $d/slots 2>/dev/null || echo 1); free=0; k=0; while [ $k -lt $n ]; do exec 9>>$d/build-$k; if flock -n 9; then free=$((free+1)); fi; exec 9>&-; k=$((k+1)); done; printf "free=%s slots=%s load1=%s" $free $n "$(cut -d" " -f1 /proc/loadavg)"' 2>/dev/null || printf 'down'
|
||||
}
|
||||
bs_state_ok() { # <state> -> 0 when the box can take a job now (a free slot, load1 at or under the line)
|
||||
local st="$1" free load
|
||||
case "$st" in free=*) ;; *) return 1 ;; esac
|
||||
free=${st#free=}; free=${free%% *}; load=${st##*load1=}
|
||||
[ "$free" -gt 0 ] 2>/dev/null || return 1
|
||||
awk -v l="$load" -v m="$BS_SPILL_LOAD" 'BEGIN { exit !(l + 0 <= m + 0) }'
|
||||
}
|
||||
bs_route_spill() { # <class> [priority] -> the box number; sets BS_ROUTE_PREF, BS_ROUTE_BOX, BS_ROUTE_SPILLED, BS_ROUTE_REASON
|
||||
local class="$1" pref alt ps as
|
||||
pref=$(bs_route "$class" 2>/dev/null)
|
||||
case "$pref" in 1) alt=2 ;; 2) alt=1 ;; *) alt=1 ;; esac
|
||||
BS_ROUTE_PREF=$pref; BS_ROUTE_BOX=$pref; BS_ROUTE_SPILLED=0
|
||||
ps=$(bs_box_state "$pref")
|
||||
if bs_state_ok "$ps"; then BS_ROUTE_REASON="box $pref ($ps) takes it"
|
||||
else
|
||||
as=$(bs_box_state "$alt")
|
||||
if bs_state_ok "$as"; then BS_ROUTE_BOX=$alt; BS_ROUTE_SPILLED=1; BS_ROUTE_REASON="box $pref ($ps) is full or over load $BS_SPILL_LOAD: spilled to box $alt ($as)"
|
||||
else BS_ROUTE_REASON="box $pref ($ps) and box $alt ($as) are both full or over load $BS_SPILL_LOAD: queued on box $pref"; fi
|
||||
fi
|
||||
bs_log "route: class $class prefers box $pref; $BS_ROUTE_REASON"
|
||||
BR_ROUTE_PREF=$BS_ROUTE_PREF BR_ROUTE_BOX=$BS_ROUTE_BOX BR_ROUTE_SPILLED=$BS_ROUTE_SPILLED BR_ROUTE_REASON=$BS_ROUTE_REASON
|
||||
export BR_ROUTE_PREF BR_ROUTE_BOX BR_ROUTE_SPILLED BR_ROUTE_REASON
|
||||
echo "$BS_ROUTE_BOX"
|
||||
}
|
||||
BS_ROOT_REMOTE=/srv/builds
|
||||
BS_MIRROR_REPO=/srv/igneum.git
|
||||
BS_MIRROR_NODE=/srv/igneum-node.git
|
||||
|
|
@ -296,6 +338,7 @@ bs_remote_run() {
|
|||
BR_BRANCH="$BS_BRANCH" BR_SHA="$BS_SHA" BR_AGENT="$agent" BR_KIND="${BR_KIND:-other}" BR_COMMAND="${BR_COMMAND:-}" \
|
||||
BR_TARGET="${BR_TARGET:-}" BR_ARTEFACTS="${BR_ARTEFACTS:-}" BR_SDE="${BR_SDE:-}" BR_PAIRS_WITH="${BR_PAIRS_WITH:-}" \
|
||||
BR_NICE="${BR_NICE:-0}" BR_CORES="${BR_CORES:-0}" BR_JOBS_CAP="${BR_JOBS_CAP:-0}" BR_PRIORITY="${BR_PRIORITY:-normal}" \
|
||||
BR_ROUTE_PREF="${BR_ROUTE_PREF:-}" BR_ROUTE_BOX="${BR_ROUTE_BOX:-}" BR_ROUTE_SPILLED="${BR_ROUTE_SPILLED:-0}" BR_ROUTE_REASON="${BR_ROUTE_REASON:-}" \
|
||||
bash -c '
|
||||
for v in BR_DIR BR_LABEL BR_CMD BR_TOOL BR_WT BR_CRATE BR_BRANCH BR_SHA BR_AGENT BR_KIND BR_COMMAND BR_TARGET BR_ARTEFACTS BR_SDE BR_PAIRS_WITH BR_NICE BR_CORES BR_JOBS_CAP BR_PRIORITY; do
|
||||
printf "export %s=%q\n" "$v" "${!v}"
|
||||
|
|
|
|||
|
|
@ -53,9 +53,10 @@ SCCACHE_GB="${SCCACHE_GB:-100}"
|
|||
SCCACHE_VERSION="${SCCACHE_VERSION:-}"
|
||||
NODE_MAJOR="${NODE_MAJOR:-22}"
|
||||
WORKTREES="${WORKTREES:-}"
|
||||
SLOTS="${SLOTS:-2}" # 2 since main's ruling of 6 October 2026 (20:3x UK); remote-run.sh gives 90 jobs alone, 45 beside another
|
||||
SLOTS_GIVEN="${SLOTS:-}"; SLOTS="${SLOTS:-2}" # 2 since main's ruling of 6 October 2026 (20:3x UK); remote-run.sh gives 90 jobs alone, 45 beside another
|
||||
P2P_PORTS="${P2P_PORTS:-26611 26811}"
|
||||
BOX_HOSTNAME="${BOX_HOSTNAME:-igneum-build-1}"
|
||||
case "$BOX_HOSTNAME" in *-2) [ -n "$SLOTS_GIVEN" ] || SLOTS=3 ;; esac # build-2: three slots (main, 7 Oct 2026; the bounded runs take one 32-core band each)
|
||||
WORKERS_HOST="${WORKERS_HOST:-build.igneum.network}" # the dashboard feed's HTTPS name (A record in deSEC, 6 Oct 2026)
|
||||
SSH_PUBKEY="${SSH_PUBKEY:-}"
|
||||
BUILD_USER=build
|
||||
|
|
|
|||
|
|
@ -255,6 +255,8 @@ d = {
|
|||
"source_date_epoch": num(e.get('BR_SDE')), "pairs_with": e.get('BR_PAIRS_WITH') or None,
|
||||
"nice": num(e.get('BR_NICE')) or 0, "cores": (num(e.get('BR_CORES')) or 0) or os.cpu_count(), "priority": e.get('BR_PRIORITY') or "normal",
|
||||
"class": e.get('BR_CLASS') or None, "run_log": e.get('BR_RUN_LOG') or None,
|
||||
"route": ({"preferred": num(e.get('BR_ROUTE_PREF')), "box": num(e.get('BR_ROUTE_BOX')), "spilled": e.get('BR_ROUTE_SPILLED') == '1',
|
||||
"reason": e.get('BR_ROUTE_REASON') or ""} if e.get('BR_ROUTE_BOX') else None),
|
||||
}
|
||||
sc = {k: num(e[v]) for k, v in (("hits", "BR_HITS"), ("misses", "BR_MISSES"), ("hits_total", "BR_HITS_T"), ("misses_total", "BR_MISSES_T"))}
|
||||
sc = {k: v for k, v in sc.items() if v is not None}
|
||||
|
|
@ -446,8 +448,15 @@ RUN_LOG_DIR="$LOG_DIR/runs"; mkdir -p "$RUN_LOG_DIR"
|
|||
BR_RUN_LOG="$RUN_LOG_DIR/$BR_HOST-$BR_T0-$BR_PID.log"; export BR_RUN_LOG
|
||||
# the class's nice and core set apply to the command's subshell and everything it starts (renice and taskset on the subshell's own
|
||||
# pid, BASHPID; cores are the LAST N of the box's set, so gates and builds keep the first ones to themselves)
|
||||
# (since the third slot on build-2, 7 Oct 2026: a bounded run takes the band its SLOT owns, counted from the top: slot 0 the last N
|
||||
# cores, slot 1 the N below, slot 2 the N below that, so three bounded runs never share a core; a band below core 0 falls back to
|
||||
# the last N)
|
||||
ncpu=$(nproc); cores_str="0-$((ncpu - 1))"
|
||||
if [ "${BR_CORES:-0}" -gt 0 ] && [ "${BR_CORES}" -lt "$ncpu" ]; then cores_str="$((ncpu - BR_CORES))-$((ncpu - 1))"; fi
|
||||
if [ "${BR_CORES:-0}" -gt 0 ] && [ "${BR_CORES}" -lt "$ncpu" ]; then
|
||||
band=0; case "${got:-}" in ''|measure) ;; *) band=$got ;; esac
|
||||
lo=$((ncpu - BR_CORES * (band + 1))); [ "$lo" -ge 0 ] || lo=$((ncpu - BR_CORES))
|
||||
cores_str="$lo-$((lo + BR_CORES - 1))"
|
||||
fi
|
||||
( [ "${BR_NICE:-0}" -gt 0 ] && renice -n "$BR_NICE" -p $BASHPID >/dev/null 2>&1; [ "$cores_str" != "0-$((ncpu - 1))" ] && taskset -cp "$cores_str" $BASHPID >/dev/null 2>&1; eval "$BR_CMD" ) > >(tee -a "$BR_RUN_LOG") 2> >(tee -a "$BR_RUN_LOG" >&2)
|
||||
rc=$?
|
||||
# the keeper stops BEFORE the bare `wait` (which flushes the two tees): a bare wait also waits for the keeper, and the keeper waits
|
||||
|
|
|
|||
84
infra/fast-time/digest-compat.mjs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#!/usr/bin/env node
|
||||
// The 0.3.15 digest rule's two cases (docs/plans/counter-asic-3-node.md section 6.2; main's ruling of 6 October 2026,
|
||||
// 20:1x UK): an absent class v4 field contributes nothing to the consensus digest. Four nodes on a private network
|
||||
// (suffix 995, ports 29800 and up, data /tmp/igneum-digest-compat), all on the same thirteen-field live devnet object
|
||||
// COPIED from /tmp/igneum-devnet/override-v3.json (the live file is never written) except the last:
|
||||
// n0 the fixed (0.3.15) node, thirteen fields the hub every other node connects to
|
||||
// n1 the fixed node, thirteen fields must peer with n0 (same binary, same file)
|
||||
// n2 the OLD node (0.3.13 or 0.3.14), thirteen fields the COMPAT case: must peer with n0 (same digest across binaries)
|
||||
// n3 the fixed node, SIXTEEN fields (the exec pin, the floor, the window) the REFUSAL case: must NOT peer (its digest moved)
|
||||
// PASS = n0 has n1 and n2 as peers, n3 has none and its log (or n0's) carries the digest refusal. The refusal case is
|
||||
// the rule's own known-failed case: the fields present DO move the digest.
|
||||
// node infra/fast-time/digest-compat.mjs --new <igneumd> --old <igneumd> [--secs 40]
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs';
|
||||
import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const sflag = (n) => { const i = args.indexOf(`--${n}`); return i >= 0 ? args[i + 1] : null; };
|
||||
const NEW = sflag('new'), OLD = sflag('old');
|
||||
const SECS = +(sflag('secs') || 40);
|
||||
if (!NEW || !OLD || !existsSync(NEW) || !existsSync(OLD)) { console.error('usage: --new <igneumd 0.3.15> --old <igneumd 0.3.13/14>'); process.exit(2); }
|
||||
const TMP = '/tmp/igneum-digest-compat';
|
||||
const BASE = 29800, SUFFIX = 995;
|
||||
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
|
||||
// the live thirteen-field object, read (never written) from the Mac node's file; the sixteen-field one adds the exec pin,
|
||||
// the floor and the window as text (the never heights must not pass through a JavaScript number)
|
||||
const live13 = readFileSync('/tmp/igneum-devnet/override-v3.json', 'utf8').trim();
|
||||
if (!/^\{.*\}$/s.test(live13) || (live13.match(/":/g) || []).length !== 13) { console.error(`the live file is not a thirteen-field object: ${live13.slice(0, 80)}`); process.exit(2); }
|
||||
const file13 = `${TMP}/override-13.json`; writeFileSync(file13, live13 + '\n');
|
||||
const file16 = `${TMP}/override-16.json`;
|
||||
writeFileSync(file16, live13.replace(/\}\s*$/, ',"exec_restart_state_root":"0xed27bb2d6128daf50493ed5539a73bb93f9d7c63ad7f70a7cbb46ba81c9e164e","program_class_v4_activation_daa":219600,"program_class_v4_signal_window_daa":86400}\n'));
|
||||
const started = [];
|
||||
class Node {
|
||||
constructor(i, bin, file, connect) { this.i = i; this.bin = bin; this.file = file; this.connect = connect; this.grpc = BASE + i * 10; this.p2p = BASE + i * 10 + 1; this.json = BASE + i * 10 + 2; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`; }
|
||||
async start() {
|
||||
mkdirSync(this.dir, { recursive: true });
|
||||
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--utxoindex', `--appdir=${this.dir}`,
|
||||
`--rpclisten=127.0.0.1:${this.grpc}`, `--rpclisten-json=127.0.0.1:${this.json}`, `--listen=127.0.0.1:${this.p2p}`, `--override-params-file=${this.file}`, '--loglevel=info', '--yes'];
|
||||
if (this.connect) a.push(`--connect=127.0.0.1:${this.connect}`); else a.push('--outpeers=0');
|
||||
const out = openSync(this.logFile, 'a');
|
||||
this.proc = spawn(this.bin, a, { stdio: ['ignore', out, out] }); started.push(this.proc);
|
||||
await sleep(1500);
|
||||
try { this.rpc = await connectRpc(`ws://127.0.0.1:${this.json}`); } catch (e) { log(`n${this.i} rpc: ${e.message}`); }
|
||||
return this;
|
||||
}
|
||||
grep(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
|
||||
digest() { return (this.grep(/Consensus params digest/)[0] || '').replace(/^.*digest: /, '').slice(0, 64) || null; }
|
||||
async peers() { try { const r = await this.rpc.call('getConnectedPeerInfo', {}); const list = r.peerInfo || r.peer_info || r.infos || r; return Array.isArray(list) ? list.length : null; } catch (e) { return `rpc: ${e.message}`; } }
|
||||
}
|
||||
async function stopAll() { for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } } await sleep(1500); for (const p of started) { try { p.kill('SIGKILL'); } catch { } } }
|
||||
process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
|
||||
|
||||
const n0 = await new Node(0, NEW, file13, null).start();
|
||||
const n1 = await new Node(1, NEW, file13, n0.p2p).start();
|
||||
const n2 = await new Node(2, OLD, file13, n0.p2p).start();
|
||||
const n3 = await new Node(3, NEW, file16, n0.p2p).start();
|
||||
const nodes = [n0, n1, n2, n3];
|
||||
await sleep(SECS * 1000);
|
||||
const rows = [];
|
||||
for (const n of nodes) {
|
||||
const peers = await n.peers();
|
||||
// the handshake's own line (flow_context.rs): `Refusing peer <addr>: consensus params digest mismatch, local <d> remote <d>`
|
||||
const refusal = n.grep(/Refusing peer .*digest/).map(l => l.replace(/^.*?\] /, '')).slice(0, 2);
|
||||
rows.push({ node: `n${n.i}`, binary: n.bin === NEW ? 'new' : 'old', fields: n.file === file13 ? 13 : 16, digest: n.digest(), peers, refusal });
|
||||
log(`n${n.i} ${n.bin === NEW ? 'new' : 'old'} ${n.file === file13 ? 13 : 16} fields: digest ${n.digest()}, peers ${peers}${refusal.length ? `, digest lines: ${refusal.join(' | ').slice(0, 300)}` : ''}`);
|
||||
}
|
||||
const checks = {
|
||||
new_and_old_on_13_fields_print_one_digest: rows[0].digest && rows[0].digest === rows[1].digest && rows[0].digest === rows[2].digest,
|
||||
the_16_field_digest_differs: rows[3].digest && rows[3].digest !== rows[0].digest,
|
||||
n0_peers_with_n1_and_n2: rows[0].peers === 2,
|
||||
old_node_peers_with_new: rows[2].peers === 1,
|
||||
n3_has_no_peer: rows[3].peers === 0,
|
||||
a_digest_refusal_line_exists: rows[3].refusal.length > 0 || rows[0].refusal.length > 0,
|
||||
};
|
||||
const pass = Object.values(checks).every(Boolean);
|
||||
writeFileSync(`${TMP}/summary.json`, JSON.stringify({ pass, checks, rows, new_binary: NEW, old_binary: OLD, secs: SECS }, null, 2));
|
||||
log(`SUMMARY ${pass ? 'PASS' : 'FAIL'}: COMPAT new-13 ${rows[0].digest?.slice(0, 16)} = old-13 ${rows[2].digest?.slice(0, 16)} (n0 peers ${rows[0].peers}, old node peers ${rows[2].peers}); REFUSAL new-16 ${rows[3].digest?.slice(0, 16)} peers ${rows[3].peers}`);
|
||||
for (const [k, v] of Object.entries(checks)) if (!v) log(`FAILED CHECK ${k}`);
|
||||
log(`summary: ${TMP}/summary.json`);
|
||||
await stopAll();
|
||||
process.exit(pass ? 0 : 1);
|
||||
132
infra/fast-time/lib/redact-keys.mjs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
#!/usr/bin/env node
|
||||
// Harness summaries never carry a raw key. Every fast-time runner that writes a summary JSON writes it through
|
||||
// writeSummary(): a 64-hex value under a key-shaped field (a name ending in key, keys, token, secret, password or
|
||||
// passphrase, or anything nested under one) is shortened to its first 8 hex characters and an ellipsis, and the
|
||||
// serialised text is then checked with the same rule as tools/ci/no-secrets-check.sh before it touches the disk.
|
||||
// A summary that would fail the no-secrets gate is refused here, at the source, with the line named.
|
||||
//
|
||||
// The class (7 October 2026, 11:26 to 12:47 UK): fork-gate.mjs wrote the two miners' vote-key hashes into
|
||||
// joint_sinks.*.key and split_sinks.*.key of three gate summaries under docs/plans/counter-asic-3-gate; eight CI runs
|
||||
// on ca3-v4-node went red on "no secret file names and no 64-hex secrets in the tree" and no pushing lane saw it,
|
||||
// because the feature-branch hook ran only the structural checks and the red watcher posted master and release-* only.
|
||||
//
|
||||
// import { writeSummary } from './lib/redact-keys.mjs'; writeSummary(OUT, summary);
|
||||
// node infra/fast-time/lib/redact-keys.mjs --self-test a key field is shortened, a hash field is left alone,
|
||||
// a raw key in the text fails the writer's own check
|
||||
// node infra/fast-time/lib/redact-keys.mjs --check <file>... exit 1 if any file carries a raw key line (the hits named)
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, mkdtempSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
// the content rule of tools/ci/no-secrets-check.sh, verbatim in spirit: a 64-hex value (0x optional) assigned to a name
|
||||
// ending in token, key, secret, password or passphrase
|
||||
export const RAW_KEY_LINE = /(token|key|secret|password|passphrase)["']?\s*[:=]\s*["']?(0x)?[0-9a-fA-F]{64}(?![0-9a-fA-F])/i;
|
||||
export const KEY_FIELD = /(key|keys|token|secret|password|passphrase)$/i;
|
||||
const HEX64 = /^(0x)?[0-9a-fA-F]{64}$/;
|
||||
|
||||
export function shortHex(v) {
|
||||
if (typeof v !== 'string' || !HEX64.test(v)) return v;
|
||||
const head = v.startsWith('0x') ? 10 : 8;
|
||||
return v.slice(0, head) + '…';
|
||||
}
|
||||
|
||||
// A copy of `value` with every 64-hex string under a key-shaped field shortened. Nothing else changes: hashes, digests
|
||||
// and ids under other names stay whole. The input object is never mutated (the runner keeps comparing live keys).
|
||||
export function redactKeys(value, underKeyField = false) {
|
||||
if (Array.isArray(value)) return value.map((v) => redactKeys(v, underKeyField));
|
||||
if (value && typeof value === 'object') {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(value)) out[k] = redactKeys(v, underKeyField || KEY_FIELD.test(k));
|
||||
return out;
|
||||
}
|
||||
return underKeyField ? shortHex(value) : value;
|
||||
}
|
||||
|
||||
// The line numbers (1-based) of `text` that the no-secrets gate would flag.
|
||||
export function rawKeyLines(text) {
|
||||
return text.split('\n').map((l, i) => (RAW_KEY_LINE.test(l) ? i + 1 : 0)).filter(Boolean);
|
||||
}
|
||||
|
||||
// The text a summary is written as: redacted, then checked. Throws when a raw key survives (a key inside a free-text
|
||||
// field such as a quoted log line), so the runner fails before the tree does.
|
||||
export function summaryText(summary) {
|
||||
const text = JSON.stringify(redactKeys(summary), null, 2);
|
||||
const lines = rawKeyLines(text);
|
||||
if (lines.length) throw new Error(`summary carries a raw 64-hex key at line${lines.length > 1 ? 's' : ''} ${lines.join(', ')}: the no-secrets gate would refuse it; shorten it with redactKeys or drop the field`);
|
||||
return text;
|
||||
}
|
||||
|
||||
export function writeSummary(file, summary) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
const text = summaryText(summary);
|
||||
writeFileSync(file, text);
|
||||
return text;
|
||||
}
|
||||
|
||||
const hex = (c) => c.repeat(64);
|
||||
|
||||
function selfTest() {
|
||||
const fails = [];
|
||||
const summary = {
|
||||
pass: true, keys: { a: hex('a'), b: hex('b') },
|
||||
joint_sinks: { a: { hash: hex('c'), blocks: 1, key: hex('a') }, b: { hash: hex('d'), blocks: 2, key: '0x' + hex('b') } },
|
||||
samples: [{ t: 1, a: { sink: hex('c').slice(0, 16), key: hex('a') } }],
|
||||
vote_key: hex('e'), signingKey: hex('f'), a_token: hex('1'), digest: hex('2'), exec_restart_hash: hex('3'), id32: 'a'.repeat(32),
|
||||
};
|
||||
const before = JSON.stringify(summary);
|
||||
const out = redactKeys(summary);
|
||||
if (JSON.stringify(summary) !== before) fails.push('redactKeys mutated its input');
|
||||
if (out.keys.a !== 'aaaaaaaa…' || out.keys.b !== 'bbbbbbbb…') fails.push(`keys.* not shortened: ${out.keys.a} ${out.keys.b}`);
|
||||
if (out.joint_sinks.a.key !== 'aaaaaaaa…') fails.push(`joint_sinks.a.key not shortened: ${out.joint_sinks.a.key}`);
|
||||
if (out.joint_sinks.b.key !== '0xbbbbbbbb…') fails.push(`a 0x key kept its prefix wrong: ${out.joint_sinks.b.key}`);
|
||||
if (out.samples[0].a.key !== 'aaaaaaaa…') fails.push('a key inside an array element was not shortened');
|
||||
if (out.vote_key !== 'eeeeeeee…' || out.signingKey !== 'ffffffff…' || out.a_token !== '11111111…') fails.push('a name ending in key/Key/token was not shortened');
|
||||
if (out.joint_sinks.a.hash !== hex('c') || out.digest !== hex('2') || out.exec_restart_hash !== hex('3')) fails.push('a hash, digest or *_hash field was changed');
|
||||
if (out.id32 !== 'a'.repeat(32) || out.joint_sinks.a.blocks !== 1) fails.push('a non-key value was changed');
|
||||
let text;
|
||||
try { text = summaryText(summary); } catch (e) { fails.push(`summaryText refused a redactable summary: ${e.message}`); }
|
||||
if (text && /[0-9a-f]{64}/.test(text.replace(new RegExp(`"(hash|digest|exec_restart_hash)": "(0x)?[0-9a-f]{64}"`, 'g'), ''))) fails.push('a raw 64-hex key survived in the written text');
|
||||
if (text && rawKeyLines(text).length) fails.push('the written text would fail the no-secrets rule');
|
||||
// the writer's own check: a raw key line in the text fails, under each shape the gate catches
|
||||
for (const t of [`{\n "key": "${hex('9')}"\n}`, `KEY=0x${hex('8')}`, `x-igneum-key: ${hex('7')}`, `const signingKey = "${hex('6')}";`]) {
|
||||
if (!rawKeyLines(t).length) fails.push(`rawKeyLines missed: ${t.slice(0, 30)}`);
|
||||
}
|
||||
if (rawKeyLines(`{\n "hash": "${hex('5')}",\n "id": "${'4'.repeat(32)}"\n}`).length) fails.push('rawKeyLines flagged a hash or a 32-hex id');
|
||||
// a key hiding in free text under a field the redactor does not know: summaryText refuses it, and names the line
|
||||
let refused = false;
|
||||
try { summaryText({ ok: true, refusal_example: `Fork choice: refused block by key: ${hex('9')}` }); } catch (e) { refused = /line 3/.test(e.message); }
|
||||
if (!refused) fails.push('summaryText did not refuse (or did not name the line of) a raw key inside a free-text field');
|
||||
// writeSummary writes the redacted text, and --check on a raw file fails
|
||||
const dir = mkdtempSync(join(tmpdir(), 'redact-keys-'));
|
||||
try {
|
||||
const good = join(dir, 'nested', 'good.json'); writeSummary(good, summary);
|
||||
if (!existsSync(good) || rawKeyLines(readFileSync(good, 'utf8')).length) fails.push('writeSummary wrote a raw key or nothing');
|
||||
if (JSON.parse(readFileSync(good, 'utf8')).keys.a !== 'aaaaaaaa…') fails.push('the written file does not parse back to the shortened key');
|
||||
const bad = join(dir, 'bad.json'); writeFileSync(bad, JSON.stringify({ keys: { a: hex('a') }, sink: { key: hex('a') } }, null, 2));
|
||||
if (checkFiles([good]).length) fails.push('--check flagged the redacted file');
|
||||
const hits = checkFiles([bad]);
|
||||
if (hits.length !== 1 || !/bad\.json:\d+/.test(hits[0])) fails.push(`--check did not name the raw file and line: ${hits.join(' | ')}`);
|
||||
if (hits.some((h) => /[0-9a-f]{64}/.test(h))) fails.push('--check printed a 64-hex value');
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
if (fails.length) { for (const f of fails) console.error(`self-test failed: ${f}`); process.exit(1); }
|
||||
console.log('self-test passed: key-shaped fields shorten to 8 hex and an ellipsis, hashes stay whole, the input is not mutated, a raw key in the text fails the writer\'s own check with its line, --check names a raw file without printing the key');
|
||||
}
|
||||
|
||||
export function checkFiles(files) {
|
||||
const hits = [];
|
||||
for (const f of files) {
|
||||
if (!existsSync(f)) { hits.push(`${f}: missing`); continue; }
|
||||
for (const n of rawKeyLines(readFileSync(f, 'utf8'))) hits.push(`${f}:${n}: a raw 64-hex key under a key-shaped field`);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === '--self-test') selfTest();
|
||||
else if (args[0] === '--check') {
|
||||
const hits = checkFiles(args.slice(1));
|
||||
if (hits.length) { for (const h of hits) console.error(h); process.exit(1); }
|
||||
console.log(`redact-keys: ${args.length - 1} file(s), no raw key`);
|
||||
} else { console.error('usage: redact-keys.mjs --self-test | --check <file>...'); process.exit(2); }
|
||||
}
|
||||
163
infra/fast-time/node-compat.mjs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
#!/usr/bin/env node
|
||||
// The node-cut compat gate (the 0.3.15 canary, 6 October 2026, 19:4xZ: a new node on the live file wrote header version
|
||||
// 1026 and every 0.3.14 node rejected its blocks as a wrong block version although the handshake peered; the digest
|
||||
// compat case alone did not catch it because nothing mined). Every future node cut runs this before its publish 1.
|
||||
//
|
||||
// Four nodes on a private network (suffix 994, ports 29830 and up, /tmp/igneum-node-compat), on the LIVE override
|
||||
// object (copied from /tmp/igneum-devnet/override-v3.json, never written) plus `genesis_bits` 0x1f010000 so CPU miners
|
||||
// find blocks (a field both binaries know; no class v4 field, so the digest is the same on both):
|
||||
// n0 OLD node (the release in the field), mining the hub
|
||||
// n1 NEW node, MINING, connected to n0 its blocks must be accepted by n0 (the canary case)
|
||||
// n2 OLD node, connected to n0 sees the new node's blocks through the old one
|
||||
// n3 NEW node, CLEAN, started late, connected to n0 the fresh join through the OLD node: must sync to the chain
|
||||
// n4 OLD node, CLEAN, started late, connected to n1 the fresh join SERVED BY THE NEW node (the 19:57Z hub class:
|
||||
// the serving node must survive a sync from the genesis)
|
||||
// then n1 is RESTARTED and must re-sync from n0 (the lagging-node case p2-3090-1 failed).
|
||||
// The pruned-serving-node case itself (a serving node whose history below its retention is gone) is the unit test
|
||||
// `a_sync_request_below_retention_is_an_error_not_a_panic` (consensus/src/processes/sync/mod.rs): a pruned node takes
|
||||
// hours of chain on the fast-time profile (pruning depth 13,838 DAA), so this harness proves the serving path alive
|
||||
// and the test proves the error in place of the panic.
|
||||
// PASS: one digest on all four; every node's block count equal at the end (within the relay lag); the new node's
|
||||
// accepted blocks over 0; no "wrong block version" or "reject message" line in any log; n3 reaches the count after
|
||||
// joining; n1 after its restart reaches the count; every mined header's version byte is the block version.
|
||||
// node infra/fast-time/node-compat.mjs --new <igneumd> --old <igneumd> --miner <igneum-miner> [--secs 150]
|
||||
// The known-failed case: --new <the 713ef876 binary> (the canary's): n0 rejects n1's blocks, FAIL.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs';
|
||||
import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const sflag = (n) => { const i = args.indexOf(`--${n}`); return i >= 0 ? args[i + 1] : null; };
|
||||
const NEW = sflag('new'), OLD = sflag('old'), MINER = sflag('miner');
|
||||
// --poisoned <igneumd>: a binary that stamps version 1026 on the live file (the canary 713ef876) mining beside the
|
||||
// NEW node: the new node must refuse its blocks with 0.3.14's line, relay nothing of them to the old hub, and stay
|
||||
// the old hub's peer (the 21:3xZ finding: the first 0.3.15 build accepted and forwarded such a block)
|
||||
const POISONED = sflag('poisoned');
|
||||
const SECS = +(sflag('secs') || 150);
|
||||
for (const b of [NEW, OLD, MINER]) if (!b || !existsSync(b)) { console.error('usage: --new <igneumd> --old <igneumd> --miner <igneum-miner>'); process.exit(2); }
|
||||
const TMP = '/tmp/igneum-node-compat';
|
||||
const BASE = 29830, SUFFIX = 994;
|
||||
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
|
||||
const live = readFileSync('/tmp/igneum-devnet/override-v3.json', 'utf8').trim();
|
||||
if (!/^\{.*\}$/s.test(live)) { console.error('the live file is not a JSON object'); process.exit(2); }
|
||||
const file = `${TMP}/override.json`;
|
||||
writeFileSync(file, live.replace(/\}\s*$/, ',"genesis_bits":520159232,"skip_proof_of_work":false}\n'));
|
||||
const DAY_MS = 86_400_000;
|
||||
const started = [];
|
||||
class Node {
|
||||
constructor(i, bin, connect) { this.i = i; this.bin = bin; this.connect = connect; this.grpcPort = BASE + i * 10; this.p2p = BASE + i * 10 + 1; this.json = BASE + i * 10 + 2; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`; this.minerProc = null; }
|
||||
get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; }
|
||||
async start() {
|
||||
mkdirSync(this.dir, { recursive: true });
|
||||
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--utxoindex', '--enable-unsynced-mining', `--appdir=${this.dir}`,
|
||||
`--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.json}`, `--listen=127.0.0.1:${this.p2p}`, `--override-params-file=${file}`, '--loglevel=info', '--yes'];
|
||||
// --addpeer, not --connect: kaspad's --connect means "connect only to these peers" and takes no inbound, and the
|
||||
// served-join case needs the new node to ACCEPT a connection (the first run of this gate left n4 at 0 blocks for
|
||||
// that reason, a harness fault); the hub takes no outbound at all
|
||||
if (this.connect) a.push(`--addpeer=127.0.0.1:${this.connect}`); else a.push('--outpeers=0');
|
||||
const out = openSync(this.logFile, 'a');
|
||||
this.proc = spawn(this.bin, a, { stdio: ['ignore', out, out] }); started.push(this.proc);
|
||||
await sleep(1500);
|
||||
try { this.rpc = await connectRpc(`ws://127.0.0.1:${this.json}`); } catch (e) { log(`n${this.i} rpc: ${e.message}`); }
|
||||
return this;
|
||||
}
|
||||
mine(label, secs) {
|
||||
const out = openSync(`${TMP}/${label}.log`, 'a');
|
||||
this.minerProc = spawn(MINER, ['mine', this.grpc, '1', String(secs), label, '--engine', 'igneum-pow', '--payout-label', label, '--status-secs', '30', '--no-vote'], { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_POW_DAY_MS: String(DAY_MS) } });
|
||||
started.push(this.minerProc);
|
||||
}
|
||||
async stop() { try { this.proc.kill('SIGINT'); } catch { } await sleep(2500); try { this.proc.kill('SIGKILL'); } catch { } }
|
||||
grep(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
|
||||
digest() { return (this.grep(/Consensus params digest/)[0] || '').replace(/^.*digest: /, '').slice(0, 64) || null; }
|
||||
async count() { try { const d = await this.rpc.call('getBlockDagInfo'); return { count: d.blockCount, sink: String(d.sink).slice(0, 16), daa: d.virtualDaaScore }; } catch (e) { return { count: null, sink: `rpc: ${e.message}` }; } }
|
||||
}
|
||||
const minerLog = (label) => { try { return readFileSync(`${TMP}/${label}.log`, 'utf8').split('\n'); } catch { return []; } };
|
||||
async function stopAll() { for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } } await sleep(2000); for (const p of started) { try { p.kill('SIGKILL'); } catch { } } }
|
||||
process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
|
||||
|
||||
const t0 = Date.now();
|
||||
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
|
||||
const n0 = await new Node(0, OLD, null).start();
|
||||
const n1 = await new Node(1, NEW, n0.p2p).start();
|
||||
const n2 = await new Node(2, OLD, n0.p2p).start();
|
||||
let n5 = null;
|
||||
if (POISONED) { n5 = await new Node(5, POISONED, n1.p2p).start(); n5.mine('poisoned5', SECS + 60); log(`n5 poisoned (${POISONED.split('/').pop()}) mining into the new node, digest ${n5.digest()?.slice(0, 16)}`); }
|
||||
log(`n0 old digest ${n0.digest()?.slice(0, 16)}, n1 new ${n1.digest()?.slice(0, 16)}, n2 old ${n2.digest()?.slice(0, 16)}`);
|
||||
n0.mine('old0', SECS + 60);
|
||||
n1.mine('new1', SECS + 60);
|
||||
const phase1 = Math.floor(SECS * 0.5);
|
||||
await sleep(phase1 * 1000);
|
||||
let c = await Promise.all([n0, n1, n2].map(n => n.count()));
|
||||
log(`t=${since()} after mining on both: counts ${c.map(x => x.count).join('/')} sinks ${c.map(x => x.sink).join(' ')}`);
|
||||
// the fresh join: a clean new node through the OLD hub
|
||||
const n3 = await new Node(3, NEW, n0.p2p).start();
|
||||
const n4 = await new Node(4, OLD, n1.p2p).start();
|
||||
log(`n3 new (clean join through the old hub) digest ${n3.digest()?.slice(0, 16)}; n4 old (clean join served by the new node) digest ${n4.digest()?.slice(0, 16)}`);
|
||||
await sleep(Math.floor(SECS * 0.25) * 1000);
|
||||
c = await Promise.all([n0, n1, n2, n3, n4].map(n => n.count()));
|
||||
log(`t=${since()} after the joins: counts ${c.map(x => x.count).join('/')}; the new node serving n4 is ${n1.proc.exitCode == null ? 'alive' : 'DEAD'}`);
|
||||
const joinedCount = c[3].count, hubAtJoin = c[0].count, servedCount = c[4].count;
|
||||
const servingNodeAlive = n1.proc.exitCode == null && n1.proc.signalCode == null;
|
||||
// the restart: n1 (the new, mining node) stopped and started again, must re-sync from the old hub
|
||||
try { n1.minerProc.kill('SIGINT'); } catch { }
|
||||
await n1.stop();
|
||||
const n1b = new Node(1, NEW, n0.p2p); await n1b.start();
|
||||
log(`n1 restarted (pid ${n1b.proc.pid})`);
|
||||
await sleep(Math.floor(SECS * 0.25) * 1000);
|
||||
const fin = await Promise.all([n0, n1b, n2, n3, n4].map(n => n.count()));
|
||||
log(`t=${since()} final: counts ${fin.map(x => x.count).join('/')} sinks ${fin.map(x => x.sink).join(' ')}`);
|
||||
const nodes = [n0, n1b, n2, n3, n4];
|
||||
const rejectLines = nodes.map(n => n.grep(/wrong block version|got reject message|Rejected block|PoW rejected/i).map(l => l.replace(/^.*?\] /, '')).slice(0, 3));
|
||||
// the poisoned case: the NEW node's log (before and after its restart) must carry 0.3.14's refusal of the 1026 blocks,
|
||||
// the OLD hub's log none (nothing bad was relayed to it), and the poisoned node's blocks must be absent from the hub
|
||||
const newRejects = POISONED ? [n1, n1b].flatMap(n => n.grep(/wrong block version: got 1026 but expected 2/)).length : 0;
|
||||
const poisonedAccepted = POISONED ? minerLog('poisoned5').filter(x => /ACCEPTED block/.test(x)).length : 0;
|
||||
const minerRej = ['old0', 'new1'].map(l => minerLog(l).filter(x => /rejected nonce=|submit error/.test(x)).length);
|
||||
const accepted = ['old0', 'new1'].map(l => minerLog(l).filter(x => /ACCEPTED block/.test(x)).length);
|
||||
// the header versions on the chain, from the old hub's view
|
||||
let versions = {};
|
||||
try {
|
||||
const d = await n0.rpc.call('getBlockDagInfo'); let low = d.pruningPointHash; const seen = new Set();
|
||||
for (let round = 0; round < 200; round++) {
|
||||
const r = await n0.rpc.call('getBlocks', { lowHash: low, includeBlocks: true, includeTransactions: false });
|
||||
const blocks = r.blocks || []; let added = 0;
|
||||
for (const b of blocks) { const h = b.verboseData?.hash || b.header?.hash; if (seen.has(h)) continue; seen.add(h); versions[b.header.version] = (versions[b.header.version] || 0) + 1; added++; }
|
||||
if (!blocks.length || added === 0) break;
|
||||
low = (r.blockHashes || []).at(-1) || blocks.at(-1).verboseData?.hash; if (!low) break;
|
||||
}
|
||||
} catch (e) { log(`getBlocks: ${e.message}`); }
|
||||
const digests = nodes.map(n => n.digest());
|
||||
const maxCount = Math.max(...fin.map(x => x.count || 0));
|
||||
const checks = {
|
||||
one_digest_on_old_and_new: new Set(digests).size === 1 && digests[0] != null,
|
||||
new_miner_blocks_accepted: accepted[1] > 0,
|
||||
old_miner_blocks_accepted: accepted[0] > 0,
|
||||
zero_rejected_by_miners: minerRej.every(x => x === 0),
|
||||
// without a poisoned peer no node sees a reject; with one, only the NEW node (which refuses the 1026 blocks) and the
|
||||
// poisoned node itself (which hears the refusal) may carry such lines, never the old hub nor the other old nodes
|
||||
no_wrong_version_or_reject_line: POISONED ? (rejectLines[0].length === 0 && rejectLines[2].length === 0 && rejectLines[4].length === 0) : rejectLines.every(r => r.length === 0),
|
||||
// every mined header carries the block version 2; the devnet genesis header is version 0 and is the one such block
|
||||
every_header_version_is_the_block_version: Object.keys(versions).length > 0 && Object.keys(versions).every(v => +v === 2 || (+v === 0 && versions[v] === 1)),
|
||||
old_nodes_hold_the_new_blocks: fin[0].count != null && fin[2].count != null && fin[0].count >= accepted[1] + accepted[0] - 2,
|
||||
counts_agree_at_the_end: fin.every(x => x.count != null && maxCount - x.count <= 3),
|
||||
clean_new_node_joined_and_synced: joinedCount != null && hubAtJoin != null && joinedCount >= hubAtJoin - 3 && fin[3].count >= maxCount - 3,
|
||||
restarted_new_node_resynced: fin[1].count != null && fin[1].count >= maxCount - 3,
|
||||
new_node_served_a_genesis_sync_and_survived: servingNodeAlive && servedCount != null && servedCount >= hubAtJoin - 3 && fin[4].count >= maxCount - 3,
|
||||
no_panic_in_any_node_log: nodes.every(n => n.grep(/panicked at|Exiting\.\.\./).length === 0),
|
||||
...(POISONED ? {
|
||||
new_node_refused_the_poisoned_blocks: newRejects > 0,
|
||||
poisoned_node_mined_something_to_refuse: poisonedAccepted > 0,
|
||||
old_hub_never_saw_a_1026_block: rejectLines[0].length === 0 && Object.keys(versions).every(v => +v === 2 || (+v === 0 && versions[v] === 1)),
|
||||
} : {}),
|
||||
};
|
||||
const pass = Object.values(checks).every(Boolean);
|
||||
writeFileSync(`${TMP}/summary.json`, JSON.stringify({ pass, checks, new_binary: NEW, old_binary: OLD, poisoned_binary: POISONED, new_node_rejects_of_1026: newRejects, poisoned_blocks_accepted_by_its_own_node: poisonedAccepted, digests, counts_after_mining: c, final: fin, served_join_count: servedCount, serving_node_alive: servingNodeAlive, accepted_old_new: accepted, rejected_by_miners_old_new: minerRej, reject_lines: rejectLines, header_versions: versions, secs: SECS }, null, 2));
|
||||
log(`SUMMARY ${pass ? 'PASS' : 'FAIL'}: digest ${digests[0]?.slice(0, 16)} on ${new Set(digests).size === 1 ? 'all' : 'NOT all'};${POISONED ? ` POISONED peer: the new node refused ${newRejects} times (its own node accepted ${poisonedAccepted});` : ''} accepted old/new ${accepted.join('/')}, rejected ${minerRej.join('/')}; header versions ${JSON.stringify(versions)}; final counts ${fin.map(x => x.count).join('/')}; reject lines ${rejectLines.map(r => r.length).join('/')}`);
|
||||
for (const r of rejectLines.flat().slice(0, 4)) log(` BAD ${r.slice(0, 220)}`);
|
||||
for (const [k, v] of Object.entries(checks)) if (!v) log(`FAILED CHECK ${k}`);
|
||||
log(`summary: ${TMP}/summary.json`);
|
||||
await stopAll();
|
||||
process.exit(pass ? 0 : 1);
|
||||
|
|
@ -121,15 +121,20 @@ if [ "$PLAN" = 1 ]; then
|
|||
exit 0
|
||||
fi
|
||||
|
||||
# the box: --box N, else the route by class (suite and bench to box 2, prove to box 3, the rest to box 1; lib.sh bs_route)
|
||||
if [ -z "$BOX" ]; then
|
||||
case "$SCHED_CLASS:$PRIORITY" in *:gate) BOX=1 ;; suite:*|bench:*) BOX=$(bs_route "$SCHED_CLASS") ;; *) BOX=1 ;; esac
|
||||
fi
|
||||
# the box: --box N pins it; else the class's PREFERRED box with spill-over (lib.sh bs_route_spill, the project lead 7 Oct 2026: a build or gate
|
||||
# prefers box 1, a suite or bench box 2, a proving crate box 3; a preferred box with no free slot or a 1-minute load above 64 hands
|
||||
# the job to the other box when that one qualifies; the decision line is printed here and lands in the JSONL row as "route")
|
||||
ROUTE_CLASS="$SCHED_CLASS"; [ "$PRIORITY" = gate ] && ROUTE_CLASS=gate
|
||||
if [ -z "$BOX" ]; then bs_route_spill "$ROUTE_CLASS"; BOX=$BS_ROUTE_BOX; else BR_ROUTE_PREF=$BOX BR_ROUTE_BOX=$BOX BR_ROUTE_SPILLED=0 BR_ROUTE_REASON="box $BOX by --box"; export BR_ROUTE_PREF BR_ROUTE_BOX BR_ROUTE_SPILLED BR_ROUTE_REASON; fi
|
||||
bs_host "$BOX"
|
||||
bs_context
|
||||
# a proving crate routes to box 3 unless the caller chose (its builds and suites alike)
|
||||
if [ "$BS_CRATE_REL" = proving/igneum-prove ] && [ -z "${BOX_GIVEN:-}" ] && [ "$PRIORITY" != gate ]; then b=$(bs_route prove); [ "$b" != "$BOX" ] && { BOX=$b; bs_host "$BOX"; }; fi
|
||||
bs_log "box $BOX ($BS_HOST) for class $SCHED_CLASS, priority $PRIORITY"
|
||||
# a proving crate prefers box 3 unless the caller chose (its builds and suites alike; the same spill-over)
|
||||
if [ "$BS_CRATE_REL" = proving/igneum-prove ] && [ -z "${BOX_GIVEN:-}" ] && [ "$PRIORITY" != gate ]; then bs_route_spill prove; [ "$BS_ROUTE_BOX" != "$BOX" ] && { BOX=$BS_ROUTE_BOX; bs_host "$BOX"; }; fi
|
||||
# box 2 keeps the suites' numbers: everything that lands there runs at the bounded class (nice 10, a 32-core band, -j 32), builds
|
||||
# and gates included (main, 7 Oct 2026); a gate still takes its slot ahead of queued suites
|
||||
if [ "$BOX" = 2 ] && [ "$BR_CORES" = 0 ]; then BR_NICE=10; BR_CORES=32; BR_JOBS_CAP=32; export BR_NICE BR_CORES BR_JOBS_CAP; bs_log "bounded on box 2 (nice 10, a 32-core band, -j 32) so a suite beside it keeps its number"; fi
|
||||
if [ "$BR_JOBS_CAP" -gt 0 ] && [ -n "$JOBS" ] && [ "$JOBS" -gt "$BR_JOBS_CAP" ]; then bs_log "--jobs $JOBS clamped to $BR_JOBS_CAP on box $BOX"; JOBS=$BR_JOBS_CAP; fi
|
||||
bs_log "box $BOX ($BS_HOST) for class $SCHED_CLASS, priority $PRIORITY$( [ "${BR_ROUTE_SPILLED:-0}" = 1 ] && echo ", SPILLED from box $BR_ROUTE_PREF")"
|
||||
|
||||
if [ "$SELFTEST" = 1 ]; then
|
||||
[ "$BS_KIND" = node ] || bs_die "--self-test-repro runs from a fork worktree (igneum-miner and kaspad live there)"
|
||||
|
|
@ -223,6 +228,7 @@ BR_KIND=$(bs_kind build-remote "$( [ "${CARGO_ARGS[0]}" = zigbuild ] && echo bui
|
|||
[ "$SCHED_CLASS" = bench ] && BR_KIND=bench
|
||||
[ "$PRIORITY" = gate ] && BR_KIND=gate
|
||||
label="$label; kind=$BR_KIND nice=$BR_NICE cores=$( [ "$BR_CORES" = 0 ] && echo 96 || echo "$BR_CORES")"
|
||||
[ "${BR_ROUTE_SPILLED:-0}" = 1 ] && label="$label; spilled from box $BR_ROUTE_PREF"
|
||||
for ((i = 0; i < ${#CARGO_ARGS[@]}; i++)); do [ "${CARGO_ARGS[$i]}" = --target ] && BR_TARGET="${CARGO_ARGS[$((i + 1))]:-}"; done
|
||||
BR_ARTEFACTS=""; [ "$FETCH" = 1 ] && BR_ARTEFACTS="$ARTEFACTS"
|
||||
export BR_KIND BR_COMMAND BR_TARGET BR_ARTEFACTS
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@
|
|||
#
|
||||
# tools/ci/pre-push.sh # the full gate over this working tree (any worktree; reads the tree, writes nothing in it)
|
||||
# tools/ci/pre-push.sh --ci # the same, with the site built in place (a CI checkout is disposable)
|
||||
# tools/ci/pre-push.sh --hook # from .git/hooks/pre-push: full gate for master and release-*, the two structural
|
||||
# # checks (conflict markers, Windows paths) for every other ref
|
||||
# tools/ci/pre-push.sh --self-test # the runner fires on a known failure, passes a known success, and the hook picks the
|
||||
# # right gate from the ref lines
|
||||
# tools/ci/pre-push.sh --hook # from .git/hooks/pre-push: full gate for master and release-*; the light gate for every
|
||||
# # other ref: the two structural checks (conflict markers, Windows paths) and the two
|
||||
# # never-push classes (the no-secrets check, the identity grep), about 20 s on the Mac
|
||||
# tools/ci/pre-push.sh --self-test # the runner fires on a known failure, passes a known success, the hook picks the
|
||||
# # right gate from the ref lines, and the light gate carries the never-push classes
|
||||
# tools/ci/pre-push.sh --list # the check names, one per line
|
||||
#
|
||||
# What is NOT here, on purpose: the three compile-or-compute jobs (igneum-pow tests, igneum-census, the simulators) and
|
||||
|
|
@ -51,12 +52,20 @@ structural_checks() {
|
|||
run "every tracked path is valid on Windows (colon, trailing dot, reserved names, length)" bash tools/ci/windows-paths-check.sh
|
||||
}
|
||||
|
||||
never_push_checks() {
|
||||
# The two never-push classes, on EVERY ref (7 October 2026: three gate summaries on ca3-v4-node carried a 64-hex key
|
||||
# through eight red CI runs in 80 minutes; the feature-branch hook ran only the structural checks, so no lane saw it).
|
||||
# A secret or an identity leak must not reach the remote on any branch; together about 20 s on the Mac.
|
||||
run "identity grep of the public export list and the served site" bash tools/ci/identity-check.sh
|
||||
run "no secret file names and no 64-hex secrets in the tree" bash -c 'bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh'
|
||||
}
|
||||
|
||||
tree_checks() {
|
||||
run "site build (in a temporary copy locally, in place in CI)" site_build
|
||||
run "internal link check of site/*.html" node tools/ci/link-check.mjs
|
||||
run "every served page carries the full header (six items, Download, burger)" node tools/ci/site-nav-check.mjs
|
||||
run "ledger sentences present verbatim on their public pages" node tools/ci/ledger-text-check.mjs
|
||||
run "identity grep of the public export list and the served site" bash tools/ci/identity-check.sh
|
||||
never_push_checks
|
||||
run "shell inside .github/workflows parses (bash -n, the PowerShell 5.1 rule)" node tools/ci/check-workflow-shell.mjs
|
||||
run "PowerShell drive-reference check (\$name: in a double-quoted string)" bash tools/ci/ps-drive-ref-check.sh
|
||||
run "copied sources are re-stamped before a build" bash tools/ci/copied-sources-check.sh
|
||||
|
|
@ -77,6 +86,7 @@ tree_checks() {
|
|||
run "the remote checkout's clean spares a lane's scratch (.igneum-scratch-spare, the fixed prefixes, never -x; the lost-scratch class)" bash -c 'bash tools/ci/scratch-spare-check.sh --self-test && bash tools/ci/scratch-spare-check.sh'
|
||||
run "long-running tools keep their body in one parsed block (the edited-while-running class)" bash -c 'bash tools/ci/whole-body-check.sh --self-test && bash tools/ci/whole-body-check.sh'
|
||||
run "build-remote without a priority flag bounds suites and benches (nice 10, 32 cores); a gate runs unbounded" bash tools/ci/build-kind-default-check.sh
|
||||
run "the class router is a preference with spill-over (a held or overloaded box hands the job to the other one)" bash tools/ci/route-spill-check.sh
|
||||
run "no shell assignment hides behind a trailing comment (the swallowed-defaults class)" bash -c 'bash tools/ci/defaults-line-check.sh --self-test && bash tools/ci/defaults-line-check.sh'
|
||||
run "no script kills or finds a process by a plain name or a file name (pgrep/pkill -f literals, ps | grep)" bash -c 'bash tools/ci/kill-by-name-check.sh --self-test && bash tools/ci/kill-by-name-check.sh'
|
||||
run "the identity check's own self-test (excluded research path passes, exported leak fails)" bash tools/ci/identity-check.sh --self-test
|
||||
|
|
@ -89,10 +99,10 @@ tree_checks() {
|
|||
run "ship tool self-test" node tools/ship-app.mjs --self-test
|
||||
run "relay unit tests" node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs
|
||||
run "miner app notice strip and update card tests" node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs
|
||||
run "no secret file names and no 64-hex secrets in the tree" bash -c 'bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh'
|
||||
run "launch gates: every row with its check, the handoff text clean (self-test, then the tree)" bash -c 'node tools/ci/launch-gates-check.mjs --self-test && node tools/ci/launch-gates-check.mjs'
|
||||
run "income per tier: the public table equals its inputs, the schedule arithmetic" bash -c 'node tools/launch/income-tiers.mjs --check && node --test tools/launch/income-tiers.test.mjs'
|
||||
run "hash-origin report: a known-finished day and a known-failed day" node --test tools/observer/hash-origin.test.mjs
|
||||
run "harness summaries never carry a raw 64-hex key (the writer's own redaction and check)" node infra/fast-time/lib/redact-keys.mjs --self-test
|
||||
}
|
||||
|
||||
gated_refs() {
|
||||
|
|
@ -125,7 +135,12 @@ case "$MODE" in
|
|||
[ "$(printf 'refs/heads/x 1 refs/heads/release-0.3.15 2\n' | gated_refs)" = full ] || { echo "self-test failed: a push to release-0.3.15 did not select the full gate"; fails=1; }
|
||||
[ "$(printf 'refs/heads/x 1 refs/heads/x 2\n' | gated_refs)" = light ] || { echo "self-test failed: a push to a feature branch selected the full gate"; fails=1; }
|
||||
[ "$(printf '' | gated_refs)" = light ] || { echo "self-test failed: a push with no refs selected the full gate"; fails=1; }
|
||||
[ "$fails" = 0 ] && echo "self-test passed: a failing check is RED and fails the gate, a passing one is ok; master and release-* select the full gate, other refs the light one"
|
||||
# the light gate carries the two never-push classes beside the structural checks, and the full gate runs them too
|
||||
declare -f never_push_checks | grep -q 'tools/ci/no-secrets-check.sh' || { echo "self-test failed: the never-push checks do not run the no-secrets check"; fails=1; }
|
||||
declare -f never_push_checks | grep -q 'tools/ci/identity-check.sh' || { echo "self-test failed: the never-push checks do not run the identity grep"; fails=1; }
|
||||
grep -qE '^\s+structural_checks; never_push_checks; finish "feature branch"' "$0" || { echo "self-test failed: the hook's light gate does not run the never-push checks"; fails=1; }
|
||||
declare -f tree_checks | grep -q 'never_push_checks' || { echo "self-test failed: the full gate does not run the never-push checks"; fails=1; }
|
||||
[ "$fails" = 0 ] && echo "self-test passed: a failing check is RED and fails the gate, a passing one is ok; master and release-* select the full gate, other refs the light one (structural checks, no-secrets, identity grep)"
|
||||
exit $fails ;;
|
||||
list)
|
||||
grep -E '^\s+run "' "$0" | sed -E 's/^\s+run "([^"]+)".*/\1/' ;;
|
||||
|
|
@ -135,8 +150,8 @@ case "$MODE" in
|
|||
echo "pre-push gate: a push to master or release-*, the full gate (the same checks CI runs):"
|
||||
structural_checks; tree_checks; finish "push to master or release-*"
|
||||
else
|
||||
echo "pre-push gate: a feature branch, the two structural checks:"
|
||||
structural_checks; finish "feature branch"
|
||||
echo "pre-push gate: a feature branch, the light gate (the two structural checks, the no-secrets check, the identity grep):"
|
||||
structural_checks; never_push_checks; finish "feature branch"
|
||||
fi ;;
|
||||
ci|local)
|
||||
[ "$MODE" = ci ] && echo "pre-push gate in CI (the same script as the local hook):" || echo "pre-push gate over this working tree:"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#!/usr/bin/env node
|
||||
// The red-master watcher. One line per failed master or release-* run, so nobody opens the Actions page to learn CI is red.
|
||||
// The red watcher. One line per failed run on ANY branch (master and release-* only until 7 October 2026: eight red runs on
|
||||
// ca3-v4-node went unseen that morning), naming the branch, the commit, the red check and the pushing author, so nobody
|
||||
// opens the Actions page to learn a branch is red.
|
||||
// Node 22, standard library only.
|
||||
//
|
||||
// node tools/ci/red-watch.mjs record --file <red.jsonl> in the workflow's `red` job (runs on igneum-build-1 after a
|
||||
|
|
@ -41,7 +43,7 @@ const STATE_FILE = process.env.IGNEUM_CI_RED_STATE || path.join(os.homedir(), '.
|
|||
const WEBHOOK_KEY = 'DISCORD_WEBHOOK_UPDATES';
|
||||
// what stops each class now (named in the digest so the line teaches, not just counts); docs/analysis/ci-failures-2026-10-06.md
|
||||
export const GUARDS = {
|
||||
'ci': 'the pre-push gate (tools/ci/pre-push.sh, the same checks CI runs, before any push to master or release-*)',
|
||||
'ci': 'the pre-push gate (tools/ci/pre-push.sh: the full gate before a push to master or release-*, the never-push checks before a push to any branch)',
|
||||
'instant': 'pre-flight in remote-run.sh (manifest, -p package, feature, subcommand checked in a second) and the kept run log',
|
||||
'preflight-manifest': 'refused before the slot: the manifest did not parse',
|
||||
'preflight-package': 'refused before the slot: the -p package does not exist',
|
||||
|
|
@ -68,6 +70,7 @@ export function runFromEnv(env = process.env) {
|
|||
return {
|
||||
run_id: String(env.GITHUB_RUN_ID), attempt: Number(env.GITHUB_RUN_ATTEMPT || 1), workflow: env.GITHUB_WORKFLOW,
|
||||
branch: env.GITHUB_REF_NAME, sha: env.GITHUB_SHA.slice(0, 7), event: env.GITHUB_EVENT_NAME || '',
|
||||
actor: env.GITHUB_ACTOR || '', author: env.RED_WATCH_AUTHOR || '', // who pushed (the GitHub login), who the head commit names
|
||||
url: `${server}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`, at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
|
@ -115,7 +118,8 @@ export async function record(file, env = process.env, fetchImpl = fetch, title =
|
|||
export function formatLine(l) {
|
||||
const where = l.failed.length ? l.failed.map((f) => `${f.job.replace(/,.*$/, '')} at "${f.step}"`).join('; ') : (l.note || 'no step detail');
|
||||
const title = l.title ? ` "${l.title}"` : '';
|
||||
return `CI red: ${l.workflow} on ${l.branch} @${l.sha}${title}: ${where} ${l.url}`;
|
||||
const who = l.actor ? ` pushed by ${l.actor}${l.author && l.author !== l.actor ? ` (commit by ${l.author})` : ''};` : '';
|
||||
return `CI red: ${l.workflow} on ${l.branch} @${l.sha}${title}:${who} ${where} ${l.url}`;
|
||||
}
|
||||
|
||||
function readCredentials(file) {
|
||||
|
|
@ -208,7 +212,8 @@ async function selfTest() {
|
|||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'red-watch-'));
|
||||
const file = path.join(dir, 'red.jsonl'); const stateFile = path.join(dir, 'posted.json'); const credFile = path.join(dir, 'discord');
|
||||
const env = { GITHUB_RUN_ID: '424242', GITHUB_RUN_ATTEMPT: '1', GITHUB_REPOSITORY: 'igneum-network/igneum', GITHUB_REF_NAME: 'master',
|
||||
GITHUB_SHA: '0f0abc6deadbeef', GITHUB_WORKFLOW: 'ci', GITHUB_TOKEN: 'x', RED_WATCH_TITLE: 'Merge box-work 2bd3bec: the repro script re-stamps its clones' };
|
||||
GITHUB_SHA: '0f0abc6deadbeef', GITHUB_WORKFLOW: 'ci', GITHUB_TOKEN: 'x', RED_WATCH_TITLE: 'Merge box-work 2bd3bec: the repro script re-stamps its clones',
|
||||
GITHUB_ACTOR: 'igneum-labs', RED_WATCH_AUTHOR: 'igneum-labs' };
|
||||
const jobs = { jobs: [
|
||||
{ name: 'site build, link check, identity grep', conclusion: 'failure', steps: [{ name: 'site build', conclusion: 'success' }, { name: 'identity grep of the public export list', conclusion: 'failure' }] },
|
||||
{ name: 'igneum-pow tests, igneum-census build', conclusion: 'success', steps: [] },
|
||||
|
|
@ -226,6 +231,13 @@ async function selfTest() {
|
|||
if (lines[0].failed[1].step !== '(job never started: runner or billing)') fails.push('record: a job with no steps was not named as never started');
|
||||
const text = formatLine(lines[0]);
|
||||
if (!/^CI red: ci on master @0f0abc6 "Merge box-work/.test(text) || !text.includes('actions/runs/424242')) fails.push(`format: ${text}`);
|
||||
if (!text.includes('pushed by igneum-labs (commit by igneum-labs);') || !text.includes('site build at "identity grep of the public export list"')) fails.push(`format: the line does not name the pushing author and the red check: ${text}`);
|
||||
// a feature branch is recorded and formatted like master (every branch since 7 October 2026)
|
||||
const envFeature = { ...env, GITHUB_RUN_ID: '424299', GITHUB_REF_NAME: 'ca3-v4-node', RED_WATCH_AUTHOR: 'igneum-labs' };
|
||||
const fileFeature = path.join(dir, 'feature.jsonl');
|
||||
await record(fileFeature, envFeature, fakeFetch);
|
||||
const textFeature = formatLine(readLines(fileFeature)[0]);
|
||||
if (!/^CI red: ci on ca3-v4-node @0f0abc6 "Merge box-work[^"]*": pushed by igneum-labs; site build at/.test(textFeature)) fails.push(`format on a feature branch: ${textFeature}`);
|
||||
// post, dry run: prints, sends nothing, marks nothing
|
||||
let printed = []; const log = (s) => printed.push(s);
|
||||
const sends = []; const hookFetch = async (url, init) => { sends.push({ url, body: JSON.parse(init.body) }); return { ok: true, status: 204 }; };
|
||||
|
|
@ -274,7 +286,7 @@ async function selfTest() {
|
|||
if (!d3.sent) fails.push('digest: not sent the next day');
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
if (fails.length) { for (const f of fails) console.error(`self-test failed: ${f}`); process.exit(1); }
|
||||
console.log('self-test passed: one line per run however often record runs; the dry run sends nothing; a missing key is named, never a URL; one live send per run; a webhook error keeps the run pending; a box row is counted, never posted alone; the digest goes once per London day, at or after 09:00');
|
||||
console.log('self-test passed: one line per run however often record runs, on any branch, naming the pushing author; the dry run sends nothing; a missing key is named, never a URL; one live send per run; a webhook error keeps the run pending; a box row is counted, never posted alone; the digest goes once per London day, at or after 09:00');
|
||||
}
|
||||
|
||||
const cmd = args[0];
|
||||
|
|
|
|||
37
tools/ci/route-spill-check.sh
Executable file
|
|
@ -0,0 +1,37 @@
|
|||
#!/usr/bin/env bash
|
||||
# The spill-over router of tools/build-remote.sh (lib.sh bs_route_spill; the project lead, 7 October 2026, 15:02 UK: build-1 at load 139 with a
|
||||
# queue of 1 h 40 min while build-2 sat at 4.5 with free slots). The class is a preference: a job goes to its class's box unless
|
||||
# that box has no free slot or its 1-minute load is above 64, in which case it goes to the other box when that one qualifies, else
|
||||
# it queues on its own box. This check feeds the router fixed box states through BS_ROUTE_STATE_<n> (no ssh) and host files in a
|
||||
# scratch directory, and compares the chosen box and the spilled flag for the known cases: a held build-1 selects build-2, a free
|
||||
# build-1 selects build-1, an overloaded build-1 spills, a held build-2 sends a suite to build-1, two full boxes queue on the
|
||||
# class's own box, a box without a host file is never chosen, a build-1 that is down spills.
|
||||
#
|
||||
# tools/ci/route-spill-check.sh # exit 1 with the case that resolved wrongly (the check IS its self-test)
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/../.."
|
||||
t=$(mktemp -d); trap 'rm -rf "$t"' EXIT
|
||||
printf 'build@10.0.0.1\n' > "$t/hosts"; printf 'build@10.0.0.2\n' > "$t/hosts-2" # box 3 absent on purpose
|
||||
export IGNEUM_BUILD_HOST_FILE="$t/hosts"
|
||||
fail=0
|
||||
expect() { # <case> <class> <want box> <want spilled> ; the states come from the environment
|
||||
local name="$1" class="$2" wbox="$3" wsp="$4" out
|
||||
out=$(bash -c '. infra/build-server/lib.sh; bs_route_spill "$1" >/dev/null 2>&1; printf "%s %s" "$BS_ROUTE_BOX" "$BS_ROUTE_SPILLED"' _ "$class" 2>/dev/null)
|
||||
if [ "$out" = "$wbox $wsp" ]; then echo "route-spill: $name: $class -> box $wbox spilled=$wsp"
|
||||
else echo "route-spill: $name: $class resolved to '$out', expected 'box $wbox spilled=$wsp'" >&2; fail=1; fi
|
||||
}
|
||||
BS_ROUTE_STATE_1="free=0 slots=2 load1=30" BS_ROUTE_STATE_2="free=3 slots=3 load1=5" expect "a held build-1 selects build-2" build 2 1
|
||||
BS_ROUTE_STATE_1="free=1 slots=2 load1=20" BS_ROUTE_STATE_2="free=3 slots=3 load1=5" expect "a free build-1 selects build-1" build 1 0
|
||||
BS_ROUTE_STATE_1="free=2 slots=2 load1=70" BS_ROUTE_STATE_2="free=3 slots=3 load1=5" expect "an overloaded build-1 spills" build 2 1
|
||||
BS_ROUTE_STATE_1="free=0 slots=2 load1=30" BS_ROUTE_STATE_2="free=3 slots=3 load1=5" expect "a gate spills like a build" gate 2 1
|
||||
BS_ROUTE_STATE_1="free=1 slots=2 load1=5" BS_ROUTE_STATE_2="free=0 slots=3 load1=10" expect "a held build-2 sends a suite to build-1" suite 1 1
|
||||
BS_ROUTE_STATE_1="free=1 slots=2 load1=5" BS_ROUTE_STATE_2="free=1 slots=3 load1=90" expect "an overloaded build-2 sends a bench away" bench 1 1
|
||||
BS_ROUTE_STATE_1="free=2 slots=2 load1=5" BS_ROUTE_STATE_2="free=2 slots=3 load1=10" expect "a free build-2 keeps its suite" suite 2 0
|
||||
BS_ROUTE_STATE_1="free=0 slots=2 load1=80" BS_ROUTE_STATE_2="free=0 slots=3 load1=90" expect "two full boxes queue a build on build-1" build 1 0
|
||||
BS_ROUTE_STATE_1="free=0 slots=2 load1=80" BS_ROUTE_STATE_2="free=0 slots=3 load1=90" expect "two full boxes queue a suite on build-2" suite 2 0
|
||||
BS_ROUTE_STATE_1="down" BS_ROUTE_STATE_2="free=3 slots=3 load1=5" expect "a build-1 that is down spills" build 2 1
|
||||
BS_ROUTE_STATE_1="free=1 slots=2 load1=5" BS_ROUTE_STATE_2="free=3 slots=3 load1=5" expect "a proving class with no box 3 prefers box 1" prove 1 0
|
||||
BS_ROUTE_STATE_1="free=0 slots=2 load1=5" BS_ROUTE_STATE_2="free=3 slots=3 load1=5" expect "a proving class spills to box 2 when box 1 is held" prove 2 1
|
||||
BS_ROUTE_STATE_1="free=1 slots=2 load1=64" BS_ROUTE_STATE_2="free=3 slots=3 load1=5" expect "load exactly 64 is under the line" build 1 0
|
||||
[ "$fail" = 0 ] && echo "route-spill: the class is a preference; a full or overloaded box hands the job to the other one"
|
||||
exit $fail
|
||||
|
|
@ -12,11 +12,13 @@
|
|||
// node tools/community/discord-hooks.mjs incident resolve --id <id> --cause "..." --fix "..." [--at <UTC ISO>] [--live]
|
||||
// node tools/community/discord-hooks.mjs watch [--live] one watcher pass (opens and resolves automatic incidents)
|
||||
// node tools/community/discord-hooks.mjs tick [--live] one scheduler pass: what is due by the London clock, plus watch
|
||||
// node tools/community/discord-hooks.mjs feed [--live] [--via updates] hourly network feed → #network-feed (milestones and the daily hash-origin line ride with it)
|
||||
// node tools/community/discord-hooks.mjs preview rebuild out/preview.html from out/*.json
|
||||
// node tools/community/discord-hooks.mjs check which webhooks are configured (names only, never values)
|
||||
//
|
||||
// Credentials: ~/.config/igneum/discord (or $IGNEUM_DISCORD_ENV), KEY=VALUE lines, mode 600, never in the repository, never printed:
|
||||
// DISCORD_WEBHOOK_NUMBERS, DISCORD_WEBHOOK_ANNOUNCEMENTS, DISCORD_WEBHOOK_INCIDENTS.
|
||||
// DISCORD_WEBHOOK_NUMBERS, DISCORD_WEBHOOK_ANNOUNCEMENTS, DISCORD_WEBHOOK_INCIDENTS; optional DISCORD_WEBHOOK_FEED (#network-feed;
|
||||
// until it exists, `feed --via updates` posts the feed through DISCORD_WEBHOOK_UPDATES, the hidden updates channel, as a proof of the path).
|
||||
// State: ~/.config/igneum/discord-hooks-state.json (or $IGNEUM_DISCORD_STATE) in live mode; out/state.json in dry run.
|
||||
// Rules (docs/community/reddit/bot.md): the bot never replies, never mentions, posts numbers from the public API and
|
||||
// incident sentences a person wrote (the watcher's three automatic conditions are the one exception, each a stated rule).
|
||||
|
|
@ -35,6 +37,9 @@ export const BOT_NAME = 'Igneum';
|
|||
export const PULSE_HOURS_UK = [3, 9, 15, 21];
|
||||
export const LIMITS = { title: 256, description: 4096, fieldName: 256, fieldValue: 1024, footer: 2048, fields: 8, total: 6000, content: 2000 };
|
||||
export const WATCH = { finalityPausedS: 300, proofLagS: 900, observerSilentS: 180, clearHoldS: 120 };
|
||||
// Milestones the feed posts once each (idempotency key milestone:<kind>:<value>): every 100,000 chain blocks, every 10,000 locks,
|
||||
// every 10,000 paid shards; the first crossing of a vote-key count and of a hash-rate step. The first feed seeds and posts none.
|
||||
export const FEED_MILESTONES = { height: 100000, locks: 10000, shards: 10000, keys: [100, 250, 500, 1000, 2500, 5000, 10000], hashrate: [1e9, 10e9, 100e9, 1e12, 10e12, 100e12] };
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const OUT_DIR = process.env.IGNEUM_DISCORD_OUT || path.join(HERE, 'out');
|
||||
|
|
@ -507,6 +512,66 @@ export function watchPass(data, state, now = data.now) {
|
|||
return actions;
|
||||
}
|
||||
|
||||
|
||||
// ---------- 5. the hourly network feed (#network-feed, read-only channel; the bot's only voice there) ----------
|
||||
// Height, hash rate, keys and the last lock from /api/live (the observer's copy of the node), one small embed an hour.
|
||||
export function hashOriginLine(live) {
|
||||
const h = live && live.state && live.state.hash_origin;
|
||||
if (!h || !h.date || !h.keys) return null;
|
||||
const pct = x => (x === null || x === undefined ? 'n/a' : `${(Number(x) * 100).toFixed(1)}%`);
|
||||
const parts = [`${fmtInt(h.keys.with_block)} keys found a block`, `${fmtInt(h.keys.above_dust_30d)} above dust`, `ten largest ${pct(h.keys.top10_share)} of blocks`];
|
||||
if (h.fleet && h.fleet.share !== undefined) parts.push(`project fleet ${pct(h.fleet.share)}`);
|
||||
if (h.pools && h.pools.attested !== undefined) parts.push(`${plural(h.pools.attested, 'attested pool')}`);
|
||||
return { date: String(h.date), text: `${parts.join(', ')}. Full report in #numbers.` };
|
||||
}
|
||||
export function shapeFeed(data, prev, { hashOrigin = true } = {}) {
|
||||
const { stats, live, now } = data;
|
||||
const cur = snapshot(data);
|
||||
const st = (live && live.state) || {};
|
||||
const windowS = prev ? (now.getTime() - new Date(prev.at).getTime()) / 1000 : null;
|
||||
const bps = prev && windowS > 0 && cur.block_count !== null && prev.block_count !== null ? (cur.block_count - prev.block_count) / windowS : st.blocks_per_second_60s ?? null;
|
||||
const lock = newestLock(live);
|
||||
const stale = !!(stats.stale || st.stale);
|
||||
const description = stale
|
||||
? `Observer stale for ${fmtDur(stats.age_s ?? st.age_s)}: every number is last known. ${SITE}/live`
|
||||
: `${stats.network || st.network || 'devnet'}. Devnet coins have no value. ${SITE}/live`;
|
||||
const fields = [
|
||||
{ name: 'Height', value: `${fmtInt(st.height ?? stats.height)}\n${bps === null ? 'n/a' : bps.toFixed(2)} blocks/s ${prev ? `over ${fmtDur(windowS)}` : 'last 60 s'}` },
|
||||
{ name: 'Hash rate', value: `${fmtHash(st.hashes_per_second_estimate ?? stats.hashrate)}\ndifficulty ${fmtNum(st.difficulty ?? stats.difficulty, 0)}` },
|
||||
{ name: 'Keys', value: `${fmtInt(st.miners_10m ?? stats.miners_10m)} active in 10 min (a card runs several)\n${cur.voters === null ? 'voters n/a' : `${fmtInt(cur.voters)} voters in the 30-day window`}` },
|
||||
{ name: 'Last lock', value: lock
|
||||
? `checkpoint ${fmtInt(lock.index)} at ${fmtPct(lock.fraction_total)} of weight\n${ageOf(lock.locked_at, now.getTime())} ago, ${plural(lock.voters, 'voter')}`
|
||||
: `${cur.finality_active ? 'active' : 'paused'}, latest index ${fmtInt(cur.locked_index)}` },
|
||||
];
|
||||
const ho = hashOrigin ? hashOriginLine(live) : null;
|
||||
if (ho) fields.push({ name: `Hash origin, ${ho.date}`, value: ho.text, inline: false });
|
||||
const payload = embed({ title: 'Network feed', url: `${SITE}/live`, description, fields, footerNote: 'every hour from /api/live; this channel is read-only, questions go to #mining', now, thumbnail: false });
|
||||
return { payload, snapshot: cur, hashOriginDate: ho ? ho.date : null };
|
||||
}
|
||||
// Milestones crossed between the previous feed snapshot and this one. None on the first feed (the seed).
|
||||
export function feedMilestones(cur, prev) {
|
||||
if (!prev) return [];
|
||||
const out = [];
|
||||
const step = (kind, a, b, size, text) => {
|
||||
if (a === null || a === undefined || b === null || b === undefined) return;
|
||||
const from = Math.floor(Number(a) / size), to = Math.floor(Number(b) / size);
|
||||
for (let k = from + 1; k <= to; k++) out.push({ key: `milestone:${kind}:${k * size}`, text: text(k * size) });
|
||||
};
|
||||
step('height', prev.height, cur.height, FEED_MILESTONES.height, v => `Chain block ${fmtInt(v)}. ${SITE}/block/${fmtInt(v).replace(/,/g, '')}`);
|
||||
step('locks', prev.locked_index, cur.locked_index, FEED_MILESTONES.locks, v => `${fmtInt(v)} checkpoints locked by miner vote weight.`);
|
||||
step('shards', prev.paid_shards_total, cur.paid_shards_total, FEED_MILESTONES.shards, v => `${fmtInt(v)} proof shards paid to provers from the block.`);
|
||||
const first = (kind, a, b, levels, text) => {
|
||||
if (a === null || a === undefined || b === null || b === undefined) return;
|
||||
for (const v of levels) if (Number(a) < v && Number(b) >= v) out.push({ key: `milestone:${kind}:${v}`, text: text(v) });
|
||||
};
|
||||
first('keys', prev.miners_10m, cur.miners_10m, FEED_MILESTONES.keys, v => `${fmtInt(v)} vote keys active at once (a card runs several).`);
|
||||
first('hashrate', prev.hashrate, cur.hashrate, FEED_MILESTONES.hashrate, v => `Network hash rate passed ${fmtHash(v)}.`);
|
||||
return out;
|
||||
}
|
||||
export function shapeMilestone(text, now = new Date()) {
|
||||
return embed({ title: 'Milestone', url: `${SITE}/live`, description: text, fields: [], footerNote: 'posted once per milestone, from /api/live', now, thumbnail: false });
|
||||
}
|
||||
|
||||
// ---------- credentials, state, posting ----------
|
||||
export function readCredentials(file = CRED_FILE) {
|
||||
if (!fs.existsSync(file)) return null;
|
||||
|
|
@ -517,7 +582,10 @@ export function readCredentials(file = CRED_FILE) {
|
|||
}
|
||||
return out;
|
||||
}
|
||||
export const CHANNEL_KEY = { numbers: 'DISCORD_WEBHOOK_NUMBERS', announcements: 'DISCORD_WEBHOOK_ANNOUNCEMENTS', incidents: 'DISCORD_WEBHOOK_INCIDENTS' };
|
||||
export const CHANNEL_KEY = { numbers: 'DISCORD_WEBHOOK_NUMBERS', announcements: 'DISCORD_WEBHOOK_ANNOUNCEMENTS', incidents: 'DISCORD_WEBHOOK_INCIDENTS', feed: 'DISCORD_WEBHOOK_FEED' };
|
||||
export const REQUIRED_CHANNELS = ['numbers', 'announcements', 'incidents']; // feed is optional: no key, no feed, one log line
|
||||
// --via updates: the feed through the hidden updates channel's webhook until #network-feed has its own
|
||||
export const VIA_KEY = { updates: 'DISCORD_WEBHOOK_UPDATES' };
|
||||
|
||||
export function loadState(file) {
|
||||
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return { posts: {}, pulses: [], incidents: {}, watch: {} }; }
|
||||
|
|
@ -551,8 +619,10 @@ export async function postWebhook(url, payload, { fetchImpl = fetch, maxTries =
|
|||
}
|
||||
|
||||
export class Poster {
|
||||
constructor({ live = false, outDir = OUT_DIR, credFile = CRED_FILE, stateFile, force = false, log = msg => process.stderr.write(`${msg}\n`), fetchImpl = fetch } = {}) {
|
||||
constructor({ live = false, outDir = OUT_DIR, credFile = CRED_FILE, stateFile, force = false, log = msg => process.stderr.write(`${msg}\n`), fetchImpl = fetch, via = {} } = {}) {
|
||||
this.live = live; this.outDir = outDir; this.force = force; this.log = log; this.fetchImpl = fetchImpl;
|
||||
this.via = {}; // channel → credentials key override, e.g. { feed: 'DISCORD_WEBHOOK_UPDATES' }
|
||||
for (const [ch, name] of Object.entries(via)) { if (!VIA_KEY[name]) throw new Error(`unknown --via ${name}`); this.via[ch] = VIA_KEY[name]; }
|
||||
this.creds = live ? readCredentials(credFile) : null;
|
||||
if (live && !this.creds) throw new Error(`no credentials file at ${credFile}; create it with the three webhook keys (mode 600) or run without --live`);
|
||||
this.stateFile = stateFile || (live ? STATE_FILE_LIVE : path.join(outDir, 'state.json'));
|
||||
|
|
@ -561,8 +631,9 @@ export class Poster {
|
|||
}
|
||||
already(key) { return !this.force && !!this.state.posts[key]; }
|
||||
// the webhook keys the credentials file lacks (names only); empty in dry run
|
||||
missingKeys() { return this.live ? Object.values(CHANNEL_KEY).filter(k => !this.creds[k]) : []; }
|
||||
has(channel) { return !this.live || !!this.creds[CHANNEL_KEY[channel]]; }
|
||||
missingKeys() { return this.live ? REQUIRED_CHANNELS.map(ch => CHANNEL_KEY[ch]).filter(k => !this.creds[k]) : []; }
|
||||
keyFor(channel) { return this.via[channel] || CHANNEL_KEY[channel]; }
|
||||
has(channel) { return !this.live || !!this.creds[this.keyFor(channel)]; }
|
||||
// Posts one payload under an idempotency key; returns {posted, skipped, id}. Writes the dry-run JSON and preview in dry-run mode.
|
||||
async post(channel, key, payload) {
|
||||
const total = guardPayload(payload);
|
||||
|
|
@ -576,8 +647,8 @@ export class Poster {
|
|||
writePreview(this.outDir);
|
||||
return { posted: false, skipped: false, id: null, file };
|
||||
}
|
||||
const url = this.creds[CHANNEL_KEY[channel]];
|
||||
if (!url) throw new Error(`no ${CHANNEL_KEY[channel]} in the credentials file`);
|
||||
const url = this.creds[this.keyFor(channel)];
|
||||
if (!url) throw new Error(`no ${this.keyFor(channel)} in the credentials file`);
|
||||
const r = await postWebhook(url, payload, { fetchImpl: this.fetchImpl });
|
||||
this.state.posts[key] = { id: r.id, at: new Date().toISOString(), channel };
|
||||
this.save();
|
||||
|
|
@ -636,7 +707,7 @@ export function writePreview(outDir = OUT_DIR) {
|
|||
if (!fs.existsSync(outDir)) return null;
|
||||
const items = fs.readdirSync(outDir).filter(f => f.endsWith('.json') && f !== 'state.json').sort()
|
||||
.map(f => { try { return JSON.parse(fs.readFileSync(path.join(outDir, f), 'utf8')); } catch { return null; } }).filter(x => x && x.payload);
|
||||
const order = { announcements: 0, numbers: 1, incidents: 2 };
|
||||
const order = { announcements: 0, numbers: 1, incidents: 2, feed: 3 };
|
||||
items.sort((a, b) => (order[a.channel] ?? 9) - (order[b.channel] ?? 9) || a.key.localeCompare(b.key));
|
||||
const file = path.join(outDir, 'preview.html');
|
||||
fs.writeFileSync(file, renderPreview(items));
|
||||
|
|
@ -644,10 +715,11 @@ export function writePreview(outDir = OUT_DIR) {
|
|||
}
|
||||
|
||||
// ---------- the scheduler ----------
|
||||
export function dueNow(now, state) {
|
||||
export function dueNow(now, state, { feed = false } = {}) {
|
||||
const l = londonParts(now);
|
||||
const date = londonDateKey(now);
|
||||
const due = [];
|
||||
if (feed) due.push({ kind: 'feed', key: `feed:${date}:${String(l.hour).padStart(2, '0')}` });
|
||||
if (PULSE_HOURS_UK.includes(l.hour)) due.push({ kind: 'pulse', key: `pulse:${date}:${String(l.hour).padStart(2, '0')}` });
|
||||
if (l.hour === 9) due.push({ kind: 'digest', key: `digest:${date}` });
|
||||
if (l.hour === 9 && l.weekday === 'Mon') due.push({ kind: 'weekly', key: `weekly:${date}` });
|
||||
|
|
@ -697,6 +769,22 @@ export async function runWeekly(poster, data, key) {
|
|||
if (!r.skipped) { poster.state.weekly_prev = snap; poster.save(); }
|
||||
return r;
|
||||
}
|
||||
export async function runFeed(poster, data, key) {
|
||||
const prev = poster.state.feed_last || null;
|
||||
const seenDate = poster.state.feed_hash_origin_date || null;
|
||||
const { payload, snapshot: snap, hashOriginDate } = shapeFeed(data, prev, { hashOrigin: true });
|
||||
// the hash-origin line rides in the first feed that sees a new report date, once a day
|
||||
const shaped = hashOriginDate && hashOriginDate === seenDate ? shapeFeed(data, prev, { hashOrigin: false }) : { payload, hashOriginDate };
|
||||
const r = await poster.post('feed', key || `feed:${londonDateKey(data.now)}:${String(londonParts(data.now).hour).padStart(2, '0')}`, shaped.payload);
|
||||
const results = [r];
|
||||
if (!r.skipped) {
|
||||
for (const m of feedMilestones(snap, prev)) results.push(await poster.post('feed', m.key, shapeMilestone(m.text, data.now)));
|
||||
poster.state.feed_last = snap;
|
||||
if (shaped.hashOriginDate) poster.state.feed_hash_origin_date = shaped.hashOriginDate;
|
||||
poster.save();
|
||||
}
|
||||
return results;
|
||||
}
|
||||
export async function openIncident(poster, { id, at, what, affected, doing, auto = false }) {
|
||||
const { payload } = shapeIncidentOpen({ id, at, what, affected, doing, now: new Date() });
|
||||
const r = await poster.post('incidents', `incident:${id}:open`, payload);
|
||||
|
|
@ -739,13 +827,15 @@ export async function main(argv = process.argv.slice(2)) {
|
|||
if (cmd === 'check') {
|
||||
const c = readCredentials();
|
||||
if (!c) { process.stdout.write(`no credentials file at ${CRED_FILE}\n`); return 1; }
|
||||
for (const [ch, k] of Object.entries(CHANNEL_KEY)) process.stdout.write(`${ch.padEnd(14)} ${c[k] ? 'set' : 'missing'} (${k})\n`);
|
||||
return Object.values(CHANNEL_KEY).every(k => c[k]) ? 0 : 1;
|
||||
for (const [ch, k] of Object.entries(CHANNEL_KEY)) process.stdout.write(`${ch.padEnd(14)} ${c[k] ? 'set' : 'missing'} (${k}${REQUIRED_CHANNELS.includes(ch) ? '' : ', optional'})\n`);
|
||||
process.stdout.write(`${'updates'.padEnd(14)} ${c[VIA_KEY.updates] ? 'set' : 'missing'} (${VIA_KEY.updates}, the --via updates route for the feed)\n`);
|
||||
return REQUIRED_CHANNELS.every(ch => c[CHANNEL_KEY[ch]]) ? 0 : 1;
|
||||
}
|
||||
if (cmd === 'preview') { const f = writePreview(); process.stdout.write(`${f || 'nothing in out/ yet'}\n`); return 0; }
|
||||
|
||||
const poster = new Poster({ live, force: !!flags.force });
|
||||
const poster = new Poster({ live, force: !!flags.force, via: flags.via ? { feed: String(flags.via) } : {} });
|
||||
if (cmd === 'pulse') { const data = await fetchAll(); await runPulse(poster, data, flags.key); return 0; }
|
||||
if (cmd === 'feed') { const data = await fetchAll({ fleet: false }); await runFeed(poster, data, flags.key); return 0; }
|
||||
if (cmd === 'digest') { const data = await fetchAll({ fleet: false }); await runDigest(poster, data, flags.key); return 0; }
|
||||
if (cmd === 'weekly') { const data = await fetchAll({ fleet: false }); await runWeekly(poster, data, flags.key); return 0; }
|
||||
if (cmd === 'release') {
|
||||
|
|
@ -772,12 +862,14 @@ export async function main(argv = process.argv.slice(2)) {
|
|||
if (cmd === 'watch') { const data = await fetchForWatch(); const r = await runWatch(poster, data); poster.log(`watch: ${r.length} action(s)${data.fetchFailed ? `, api unreachable (${data.error})` : ''}`); return 0; }
|
||||
if (cmd === 'tick') {
|
||||
const now = new Date();
|
||||
const due = dueNow(now, poster.state);
|
||||
const feedOn = poster.has('feed');
|
||||
const due = dueNow(now, poster.state, { feed: feedOn });
|
||||
let data = null;
|
||||
if (due.length) {
|
||||
data = await fetchAll();
|
||||
for (const d of due) {
|
||||
try {
|
||||
if (d.kind === 'feed') await runFeed(poster, data, d.key);
|
||||
if (d.kind === 'pulse') await runPulse(poster, data, d.key);
|
||||
if (d.kind === 'digest') await runDigest(poster, data, d.key);
|
||||
if (d.kind === 'weekly') await runWeekly(poster, data, d.key);
|
||||
|
|
@ -787,7 +879,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|||
const wdata = data && !data.fetchFailed ? data : await fetchForWatch();
|
||||
const r = await runWatch(poster, wdata);
|
||||
const missing = poster.missingKeys();
|
||||
poster.log(`tick ${ukStamp(now)}: ${due.length} due, watch ${r.length} action(s)${missing.length ? `, missing ${missing.join(', ')}` : ''}`);
|
||||
poster.log(`tick ${ukStamp(now)}: ${due.length} due, watch ${r.length} action(s)${missing.length ? `, missing ${missing.join(', ')}` : ''}${feedOn ? '' : `, feed off (no ${poster.keyFor('feed')})`}`);
|
||||
return 0;
|
||||
}
|
||||
throw new Error(`unknown command ${cmd}`);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
shapePulse, shapeDigest, shapeWeekly, shapeRelease, shapeIncidentOpen, shapeIncidentResolve, changedLinesFromPlan, plainLine,
|
||||
guardText, guardPayload, embed, embedLength, LIMITS, snapshot, devnet2Line, versionShare, watchPass, WATCH, WATCH_CONDITIONS,
|
||||
Poster, postWebhook, dueNow, ukStamp, londonParts, fmtHash, fmtDur, fmtDelta, fmtChangePct, renderPreview, ICON, EMBER,
|
||||
shapeFeed, feedMilestones, shapeMilestone, hashOriginLine, runFeed, FEED_MILESTONES,
|
||||
} from './discord-hooks.mjs';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -370,3 +371,89 @@ test('preview: renders every item as a Discord-like card with the channel and th
|
|||
assert.match(html, /border-left-color:#f2541b/);
|
||||
assert.doesNotMatch(html, /<script/);
|
||||
});
|
||||
|
||||
// ---------- the hourly network feed ----------
|
||||
test('feed: one small embed with height, hash rate, keys and the last lock from /api/live; no hash-origin field without the report', () => {
|
||||
const { payload, snapshot: snap, hashOriginDate } = shapeFeed(data(), null);
|
||||
guardPayload(payload);
|
||||
const e = payload.embeds[0];
|
||||
assert.equal(e.title, 'Network feed');
|
||||
assert.deepEqual(e.fields.map(f => f.name), ['Height', 'Hash rate', 'Keys', 'Last lock']);
|
||||
assert.match(e.fields[0].value, /^141,642\n/);
|
||||
assert.match(e.fields[1].value, /2\.56 GH\/s/);
|
||||
assert.match(e.fields[2].value, /56 active in 10 min \(a card runs several\)\n93 voters in the 30-day window/);
|
||||
assert.match(e.fields[3].value, /checkpoint 6,842 at \d+\.\d% of weight/);
|
||||
assert.match(e.footer.text, /every hour from \/api\/live/);
|
||||
assert.equal(hashOriginDate, null);
|
||||
assert.equal(snap.height, 141642);
|
||||
assert.ok(embedLength(e) < 700, 'the feed stays small');
|
||||
});
|
||||
test('feed: the second feed computes blocks per second over its own window', () => {
|
||||
const first = shapeFeed(data(), null);
|
||||
const prev = { ...first.snapshot, at: new Date(NOW.getTime() - 3600e3).toISOString(), block_count: first.snapshot.block_count - 3600 };
|
||||
const second = shapeFeed(data(), prev);
|
||||
assert.match(second.payload.embeds[0].fields[0].value, /1\.00 blocks\/s over 1 h/);
|
||||
});
|
||||
test('feed: the hash-origin line comes from live.state.hash_origin, once per report date, and never estimates a missing field', () => {
|
||||
const d = data();
|
||||
assert.equal(hashOriginLine(d.live), null);
|
||||
d.live.state.hash_origin = { date: '2026-10-07', keys: { with_block: 113, above_dust_30d: 95, top10_share: 0.45 }, fleet: { share: 0.384 }, pools: { attested: 0 } };
|
||||
const ho = hashOriginLine(d.live);
|
||||
assert.equal(ho.date, '2026-10-07');
|
||||
assert.equal(ho.text, '113 keys found a block, 95 above dust, ten largest 45.0% of blocks, project fleet 38.4%, 0 attested pools. Full report in #numbers.');
|
||||
const { payload, hashOriginDate } = shapeFeed(d, null);
|
||||
assert.equal(hashOriginDate, '2026-10-07');
|
||||
assert.equal(payload.embeds[0].fields[4].name, 'Hash origin, 2026-10-07');
|
||||
delete d.live.state.hash_origin.fleet;
|
||||
assert.doesNotMatch(hashOriginLine(d.live).text, /fleet/);
|
||||
});
|
||||
test('milestones: none on the first feed; every 100,000 blocks, 10,000 locks and 10,000 shards; the first crossing of a key count and a hash-rate step; each once', () => {
|
||||
const base = { height: 99990, locked_index: 9998, paid_shards_total: 19990, miners_10m: 90, hashrate: 0.9e9 };
|
||||
assert.deepEqual(feedMilestones(base, null), []);
|
||||
const next = { height: 200010, locked_index: 10002, paid_shards_total: 20001, miners_10m: 260, hashrate: 1.2e9 };
|
||||
const ms = feedMilestones(next, base);
|
||||
assert.deepEqual(ms.map(m => m.key), ['milestone:height:100000', 'milestone:height:200000', 'milestone:locks:10000', 'milestone:shards:20000', 'milestone:keys:100', 'milestone:keys:250', 'milestone:hashrate:1000000000']);
|
||||
assert.match(ms[0].text, /Chain block 100,000/);
|
||||
assert.match(ms[6].text, /passed 1\.00 GH\/s/);
|
||||
assert.deepEqual(feedMilestones(next, next), []);
|
||||
for (const m of ms) guardPayload(shapeMilestone(m.text, NOW));
|
||||
assert.deepEqual(feedMilestones({ height: null }, { height: 1 }), []);
|
||||
assert.equal(FEED_MILESTONES.height, 100000);
|
||||
});
|
||||
test('feed: runFeed posts the feed, then the milestones, seeds the snapshot, and posts the hash-origin field once a day', async () => {
|
||||
const dir = tmpDir();
|
||||
const p = new Poster({ live: false, outDir: dir, stateFile: path.join(dir, 'state.json'), log: () => {} });
|
||||
const d = data();
|
||||
d.live.state.hash_origin = { date: '2026-10-06', keys: { with_block: 1, above_dust_30d: 1, top10_share: 1 } };
|
||||
const r1 = await runFeed(p, d, 'feed:t:1');
|
||||
assert.equal(r1.length, 1, 'no milestone on the seed');
|
||||
assert.equal(p.state.feed_last.height, 141642);
|
||||
assert.equal(p.state.feed_hash_origin_date, '2026-10-06');
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'feed_t_1.json'), 'utf8')).payload.embeds[0].fields.length, 5);
|
||||
const d2 = data(); d2.now = at(NOW, 3600); d2.live.state.hash_origin = d.live.state.hash_origin; d2.stats.miners_10m = 120; d2.live.state.miners_10m = 120;
|
||||
const r2 = await runFeed(p, d2, 'feed:t:2');
|
||||
assert.equal(r2.length, 2, 'the feed and one milestone (keys 100)');
|
||||
assert.ok(fs.existsSync(path.join(dir, 'milestone_keys_100.json')));
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'feed_t_2.json'), 'utf8')).payload.embeds[0].fields.length, 4, 'the same report date is not repeated');
|
||||
assert.equal((await runFeed(p, d2, 'feed:t:2'))[0].skipped, true);
|
||||
});
|
||||
test('feed: the scheduler adds the hourly feed only when asked; --via updates routes the feed through the updates webhook; an unknown via is refused', async () => {
|
||||
const mon = new Date('2026-10-05T08:30:00Z'); // Monday 09:30 UK
|
||||
assert.deepEqual(dueNow(mon, { posts: {} }).map(d => d.kind), ['pulse', 'digest', 'weekly']);
|
||||
assert.deepEqual(dueNow(mon, { posts: {} }, { feed: true }).map(d => d.kind), ['feed', 'pulse', 'digest', 'weekly']);
|
||||
assert.equal(dueNow(mon, { posts: {} }, { feed: true })[0].key, 'feed:2026-10-05:09');
|
||||
const dir = tmpDir();
|
||||
const cred = path.join(dir, 'discord');
|
||||
fs.writeFileSync(cred, 'DISCORD_WEBHOOK_NUMBERS=https://discord.com/api/webhooks/1/a\nDISCORD_WEBHOOK_UPDATES=https://discord.com/api/webhooks/2/b\n', { mode: 0o600 });
|
||||
const calls = [];
|
||||
const fetchImpl = async (url) => { calls.push(url); return { ok: true, status: 200, headers: new Map(), json: async () => ({ id: '7' }) }; };
|
||||
const plain = new Poster({ live: true, credFile: cred, stateFile: path.join(dir, 's1.json'), outDir: dir, log: () => {}, fetchImpl });
|
||||
assert.equal(plain.has('feed'), false, 'no DISCORD_WEBHOOK_FEED, the feed is off');
|
||||
assert.deepEqual(plain.missingKeys(), ['DISCORD_WEBHOOK_ANNOUNCEMENTS', 'DISCORD_WEBHOOK_INCIDENTS'], 'the feed key is optional');
|
||||
const via = new Poster({ live: true, credFile: cred, stateFile: path.join(dir, 's2.json'), outDir: dir, log: () => {}, fetchImpl, via: { feed: 'updates' } });
|
||||
assert.equal(via.has('feed'), true);
|
||||
const { payload } = shapeFeed(data(), null);
|
||||
await via.post('feed', 'feed:via', payload);
|
||||
assert.match(calls[0], /webhooks\/2\/b/);
|
||||
assert.throws(() => new Poster({ live: false, outDir: dir, via: { feed: 'numbers' } }), /unknown --via/);
|
||||
});
|
||||
|
|
|
|||