Merge discord-hooks: network pulse, digest, weekly, release and incident posts to Discord from igneum-build-1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 19:03:13 +00:00
commit 1c8c4f81ae
12 changed files with 2099 additions and 0 deletions

3
.gitignore vendored
View file

@ -31,3 +31,6 @@ vendor/igneum-node-ship/
# Trademark instruction packs name the director and the applicant company; never in the repository
brand/trademark/pbip-pack/
brand/trademark/*.zip
# the Discord bot and reddit bot dry-run renders
tools/community/out/

View file

@ -0,0 +1,82 @@
# Discord webhooks: the bot's posts to #numbers, #announcements and #incidents
6 October 2026. Three channel webhooks named "Igneum" (the Bot role in the server kit, docs/community/reddit/discord.md)
carry the project's numbers to the Discord server. One script, `tools/community/discord-hooks.mjs` (Node 22, standard
library only), shapes every post as a single embed: ember accent (#F2541B), the site icon as thumbnail, plain words, at most
eight fields, UK time in the footer with UTC in brackets, never a mention, never a reply. Dry run is the default.
## The posts
| Post | Channel | When (UK) | Content |
|---|---|---|---|
| Network pulse | #numbers | 03:00, 09:00, 15:00, 21:00 | chain block and blocks/s over the 6 h window, hash rate and active vote keys ("a card runs several"), difficulty and its 6 h change, last lock (index, share of weight, age, voters), shards paid in the window and IGN paid to provers in the last 5 min, median proof lag and provers, reward and ramp, miner version share when /api/live exposes engines, Devnet 2 in one line (boxes, height, last gate PASS or FAIL) |
| Daily digest | #numbers | 09:00 | a small monospace table, yesterday against now, with the 24 h deltas |
| 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 |
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.
## The watcher
`watch` (and every `tick`) opens one incident per condition and resolves it when the condition has been clear for 2 minutes.
One open incident per condition at a time; the state file carries since / clear_since / open.
| Condition | Opens when | Text |
|---|---|---|
| finality_paused | `finality.active` false, or the locked index unchanged, for over 5 min | finality paused; miners and provers paid as usual; nothing final until weight returns |
| proof_lag | `proving.median_proof_lag_s` over 900 s | proving behind; blocks final as usual, proofs late |
| observer_silent | `stale` with `age_s` over 180 s, or the API unreachable for over 3 min | the live numbers are stale; the chain is unaffected |
The watcher's texts are fixed sentences in the script, each a stated rule, and are the only incidents nobody typed.
## Commands
```
node tools/community/discord-hooks.mjs pulse | digest | weekly [--live] [--force]
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]
node tools/community/discord-hooks.mjs incident resolve --id inc-... --cause "..." --fix "..." [--at ...] [--live]
node tools/community/discord-hooks.mjs watch [--live] one watcher pass
node tools/community/discord-hooks.mjs tick [--live] one scheduler pass (what the London clock says is due, then watch)
node tools/community/discord-hooks.mjs preview rebuild tools/community/out/preview.html
node tools/community/discord-hooks.mjs check which webhooks are configured (names, never values)
node --test tools/community/discord-hooks.test.mjs
```
Dry run writes `tools/community/out/<key>.json` (git-ignored) and renders `out/preview.html`, a Discord-like dark page of
every payload in the folder. The release subcommand takes the "what changed" lines from `--changed` flags or from the named
section of the release plan: bullet lines as they are, table rows by their first cell, cut at the first semicolon, a long
parenthetical dropped, 160 characters at most; a line that trips the guard is dropped with a note on stderr.
## Guard rails
| Rail | How |
|---|---|
| Forbidden strings | before every post: the founder's name and logins, hosting providers, internal host names, machine ids, internal paths, IP addresses, a standalone 32-hex token (a sha256 passes), any dl.igneum.network path outside /public/, a webhook URL, any mention; the post is refused with the pattern named |
| Limits | title 256, description 4,096, field value 1,024, eight fields, 6,000 in total, content 2,000; refused, never cut silently |
| Idempotency | a post id file (`posts` in the state) keyed `pulse:<date>:<hour>`, `digest:<date>`, `weekly:<date>`, `release:<version>`, `incident:<id>:open|resolve`; a rerun is skipped, `--force` posts again |
| 429 | exponential backoff from 1 s, doubling to 60 s, `retry_after` honoured, six tries, then exit 1 |
| State | written only after a successful live post; a dry run keeps its own state in out/state.json |
| Mentions | `allowed_mentions: {parse: []}` on every payload and a guard that refuses @everyone, @here and user mentions in the text |
## 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.
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
credentials file to `/srv/discord-hooks/env` (mode 600, owner build, over ssh stdin) and enables the timer. The repository rule
says the box never holds a secret; a webhook URL is one, so the script refuses unless `IGNEUM_SECRET_ON_BOX_OK=1` is set,
which records the coordinator's ruling. A leaked webhook is deleted in Discord and the file replaced.
## Tested, 6 October 2026
Dry run against the live API at 18:51 UTC rendered all six posts (pulse 641 characters, digest 477, weekly 1,699, release
1,501, incident open 716 and resolve 659). The guard refused the fleet file's seed address and a line with an internal path
from the release plan. Finality was paused at render time (`finality.active` false, last lock 6842 at about 18:42Z, the newest
checkpoints at 55% of total weight): the watcher's first condition, seen in dry run. Not yet run: a live post (no credentials
file yet), the install on the box.

View file

@ -81,6 +81,7 @@ Also logged for context: the Mac's Linux cross-build with zig (`infra/cross/buil
| R4 | A worktree builds once on the box per commit plus overlay; the next build is incremental in `/srv/builds/<worktree>/.../target`. Nobody deletes another worktree's target dir on the box. |
| R5 | `RUST_TOOLCHAIN` in provision.sh is bumped in the same commit as the Mac's `rustup update`; build-remote.sh refuses a mismatch. Add a `rust-toolchain.toml` to the fork and the repo (none exists today) so both sides pin from one file. |
| R6 | The box is never a node host for the live devnet and never holds a secret (no `~/.config/igneum` there). The Devnet 2 seed on it runs under its own unit with `--devnet --devnet-suffix=<n>` on 26611 (ufw already open) when that work starts. |
| R6a | ONE recorded exception to R6 (main's ruling, 6 October 2026, 20:0x UK): the three Discord webhook URLs live at `/srv/discord-hooks/env` (mode 600, owner build), installed by `infra/build-server/discord-hooks/install.sh` with `IGNEUM_SECRET_ON_BOX_OK=1`, because the Mac sleeps and the bot's timer must not. The reasoning: a webhook URL signs no release, moves no funds and reaches none of the devnet's hands; whoever holds it can only post as the bot, and a leaked one is deleted in Discord in one click and the file replaced. No other secret joins it; `check` prints key names, never values. |
| R7 | CLAUDE.md line "nothing is built on a server" and "Windows builds go to the GitHub runner, Linux binaries come from infra/cross/build-linux.sh" are rewritten when R1 and R2 are adopted; until then the box is the measured option, not the rule. |
## 5. Gotchas met on the first day

View file

@ -0,0 +1,28 @@
# The Discord bot's scheduler on igneum-build-1: one `tick` a minute from igneum-discord-hooks.timer.
# A tick posts what the London clock says is due (pulse 03/09/15/21, digest and weekly at 09:00) and runs the watcher
# (finality paused, proof lag, observer silent). Idempotency keys in the state file make a repeated tick harmless.
# Installed by infra/build-server/discord-hooks/install.sh: the script to /srv/discord-hooks/bin, the webhook file to
# /srv/discord-hooks/env (mode 600, owner build), never in the repository. `journalctl -u igneum-discord-hooks -n 30` for the log.
[Unit]
Description=Igneum Discord webhooks (one scheduler tick)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=build
Group=build
Environment=HOME=/home/build
Environment=PATH=/usr/local/bin:/usr/bin:/bin
Environment=IGNEUM_DISCORD_ENV=/srv/discord-hooks/env
Environment=IGNEUM_DISCORD_STATE=/srv/discord-hooks/state.json
Environment=IGNEUM_DISCORD_OUT=/srv/discord-hooks/out
WorkingDirectory=/srv/discord-hooks
ExecStart=/usr/local/bin/node /srv/discord-hooks/bin/discord-hooks.mjs tick --live
TimeoutStartSec=50
Nice=10
# the unit reads one secret file; nothing else on the box may
PrivateTmp=yes
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/srv/discord-hooks

View file

@ -0,0 +1,14 @@
# Fires the Discord scheduler tick every minute. `systemctl list-timers igneum-discord-hooks.timer` shows the next pass;
# the tick itself decides what is due by the London clock (pulse at 03:00, 09:00, 15:00, 21:00 UK; digest and weekly at 09:00).
[Unit]
Description=Igneum Discord webhooks, a tick every minute
[Timer]
OnBootSec=45s
OnCalendar=*-*-* *:*:00
AccuracySec=5s
Persistent=true
Unit=igneum-discord-hooks.service
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Install or refresh the Discord webhooks scheduler on igneum-build-1 from this Mac.
# IGNEUM_SECRET_ON_BOX_OK=1 infra/build-server/discord-hooks/install.sh
# Copies tools/community/discord-hooks.mjs to /srv/discord-hooks/bin, the webhook file ~/.config/igneum/discord to
# /srv/discord-hooks/env (mode 600, owner build; never into the repository), the two units, and enables the timer.
# Needs root over ssh (root@<ip> with ~/.ssh/igneum_ed25519); the host ip comes from ~/.config/igneum/build-server (build@<ip>).
#
# The repository rule says the box never holds a secret. A webhook URL is one: whoever holds it posts as the bot. This
# script therefore refuses unless IGNEUM_SECRET_ON_BOX_OK=1 is set, which records that the coordinator ruled the exception.
# A leaked webhook is revoked in Discord (Channel settings > Integrations > Webhooks > Delete) and the file replaced here.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$(cd "$HERE/../../.." && pwd)"
KEY="${IGNEUM_BUILD_KEY:-$HOME/.ssh/igneum_ed25519}"
CRED="${IGNEUM_DISCORD_ENV:-$HOME/.config/igneum/discord}"
HOST_LINE="$(head -1 "${IGNEUM_BUILD_HOST_FILE:-$HOME/.config/igneum/build-server}" | tr -d '[:space:]')"
IP="${HOST_LINE#*@}"; [ -n "$IP" ] || { echo "no build server in ~/.config/igneum/build-server" >&2; exit 1; }
[ "${IGNEUM_SECRET_ON_BOX_OK:-}" = "1" ] || { echo "refusing: the box holds no secret by rule; set IGNEUM_SECRET_ON_BOX_OK=1 once the coordinator has ruled the exception" >&2; exit 1; }
[ -f "$CRED" ] || { echo "no credentials file at $CRED (DISCORD_WEBHOOK_NUMBERS, _ANNOUNCEMENTS, _INCIDENTS)" >&2; exit 1; }
for k in DISCORD_WEBHOOK_NUMBERS DISCORD_WEBHOOK_ANNOUNCEMENTS DISCORD_WEBHOOK_INCIDENTS; do
grep -q "^$k=" "$CRED" || { echo "credentials file lacks $k" >&2; exit 1; }
done
MODE="$(stat -f %Lp "$CRED" 2>/dev/null || stat -c %a "$CRED")"
[ "$MODE" = "600" ] || { echo "credentials file must be mode 600 (is $MODE)" >&2; exit 1; }
SSH=(ssh -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10)
"${SSH[@]}" "root@$IP" 'install -d -o build -g build -m 750 /srv/discord-hooks /srv/discord-hooks/bin /srv/discord-hooks/out'
scp -q -i "$KEY" "$ROOT/tools/community/discord-hooks.mjs" "build@$IP:/srv/discord-hooks/bin/"
# the secret travels over ssh stdin, never as a command-line argument and never through a world-readable temp file
"${SSH[@]}" "build@$IP" 'umask 077; cat > /srv/discord-hooks/env.tmp && mv -f /srv/discord-hooks/env.tmp /srv/discord-hooks/env && chmod 600 /srv/discord-hooks/env' < "$CRED"
scp -q -i "$KEY" "$HERE/igneum-discord-hooks.service" "$HERE/igneum-discord-hooks.timer" "root@$IP:/etc/systemd/system/"
"${SSH[@]}" "root@$IP" 'systemctl daemon-reload && systemctl enable --now igneum-discord-hooks.timer >/dev/null 2>&1; systemctl is-active igneum-discord-hooks.timer; systemctl list-timers igneum-discord-hooks.timer --no-pager | sed -n 2p'
# one check pass as the unit's user: names only, never values
"${SSH[@]}" "build@$IP" 'IGNEUM_DISCORD_ENV=/srv/discord-hooks/env /usr/local/bin/node /srv/discord-hooks/bin/discord-hooks.mjs check'
echo "installed; the first tick runs within a minute: journalctl -u igneum-discord-hooks -n 20"

788
tools/community/discord-hooks.mjs Executable file
View file

@ -0,0 +1,788 @@
#!/usr/bin/env node
// Igneum Discord webhooks: the labelled bot's posts to #numbers, #announcements and #incidents.
// Node 22, standard library only. Dry run is the default: payloads go to tools/community/out/<key>.json and
// out/preview.html renders them as Discord embeds; --live posts through the webhooks in the credentials file.
//
// node tools/community/discord-hooks.mjs pulse [--live] network pulse → #numbers
// node tools/community/discord-hooks.mjs digest [--live] 24 h digest → #numbers (09:00 UK)
// node tools/community/discord-hooks.mjs weekly [--live] weekly numbers → #numbers (Monday 09:00 UK)
// node tools/community/discord-hooks.mjs release <version> --windows <url> --windows-sha <hex> --mac <url> --mac-sha <hex>
// --hive <url> --hive-sha <hex> --node-commit <sha> --digest <text> [--plan <file> --section <heading>] [--changed "line"]... [--live]
// node tools/community/discord-hooks.mjs incident open --what "..." --affected "..." --doing "..." [--at <UTC ISO>] [--id <id>] [--live]
// 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 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.
// 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).
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
export const SITE = 'https://igneum.network';
export const API = { stats: `${SITE}/api/stats`, live: `${SITE}/api/live?window=300`, supply: `${SITE}/api/supply` };
export const FLEET_URL = 'https://dl.igneum.network/fleet-22adafa34bc2/fleet.json'; // read only; never appears in a post
export const ICON = `${SITE}/icon-512.png`;
export const EMBER = 0xF2541B;
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 };
const HERE = path.dirname(fileURLToPath(import.meta.url));
export const OUT_DIR = process.env.IGNEUM_DISCORD_OUT || path.join(HERE, 'out');
const CRED_FILE = process.env.IGNEUM_DISCORD_ENV || path.join(os.homedir(), '.config', 'igneum', 'discord');
const STATE_FILE_LIVE = process.env.IGNEUM_DISCORD_STATE || path.join(os.homedir(), '.config', 'igneum', 'discord-hooks-state.json');
// ---------- guards ----------
// Forbidden in any post: the founder's name and logins, rented-box and build hosts, machine ids, internal paths, IP addresses,
// a standalone 32-hex token (a sha256 is 64 hex and passes), any dl.igneum.network path outside /public/, any mention.
export const FORBIDDEN = [
{ re: /[user]/i, why: "the founder's name or login" },
{ re: /[removed]/i, why: "the founder's name" },
{ re: /hetzner|runpod|vast\.ai|\bvast\b/i, why: 'a hosting provider' },
{ re: /igneum-build-\d|seed\d?\.testnet|\btailscale\b/i, why: 'an internal host name' },
{ re: /DESKTOP-[A-Z0-9]{7}|\bae432dc7\b|\b1ccfe586\b|\bp[12]-\d{4}-\d\b/i, why: 'a machine id' },
{ re: /\b\d{1,3}(\.\d{1,3}){3}\b/, why: 'an IP address' },
{ re: /(^|[\s("'`])(\/Users\/|\/srv\/|\/root\b|\/var\/lib\/|\/home\/|~\/\.config|[A-Z]:\\)/, why: 'an internal path' },
{ re: /(?<![0-9a-f])[0-9a-f]{32}(?![0-9a-f])/i, why: 'a 32-hex token' },
{ re: /dl\.igneum\.network\/(?!public\/)/i, why: 'a downloads path outside /public/' },
{ re: /discord(app)?\.com\/api\/webhooks/i, why: 'a webhook URL' },
{ re: /@everyone|@here|<@[!&]?\d+>/, why: 'a mention' },
{ re: /\bBST\b|\+0100|CLAUDE\.md/, why: 'an internal reference' },
// the rest of tools/ci/forbidden-strings.txt (the public export's committed identity list)
{ re: /MacBook|ts\.net|~\/Desktop|log[-_]?intake|intake[_-]?key/i, why: 'a pattern from tools/ci/forbidden-strings.txt' },
];
export function guardText(text, label = 'post') {
for (const { re, why } of FORBIDDEN) {
const m = re.exec(text);
if (m) throw new Error(`refusing ${label}: it contains ${why} (${JSON.stringify(m[0].slice(0, 24))})`);
}
}
export function embedLength(e) {
let n = (e.title || '').length + (e.description || '').length + ((e.footer && e.footer.text) || '').length + ((e.author && e.author.name) || '').length;
for (const f of e.fields || []) n += (f.name || '').length + (f.value || '').length;
return n;
}
export function guardPayload(payload) {
if (!payload.embeds || payload.embeds.length !== 1) throw new Error('a post is exactly one embed');
const e = payload.embeds[0];
if ((payload.content || '').length > LIMITS.content) throw new Error(`content over ${LIMITS.content}`);
if ((e.title || '').length > LIMITS.title) throw new Error(`title over ${LIMITS.title}`);
if ((e.description || '').length > LIMITS.description) throw new Error(`description over ${LIMITS.description}`);
if ((e.fields || []).length > LIMITS.fields) throw new Error(`more than ${LIMITS.fields} fields`);
for (const f of e.fields || []) {
if (!f.name || !f.value) throw new Error('a field needs a name and a value');
if (f.name.length > LIMITS.fieldName) throw new Error(`field name over ${LIMITS.fieldName}`);
if (f.value.length > LIMITS.fieldValue) throw new Error(`field value over ${LIMITS.fieldValue}: ${f.name}`);
}
if (((e.footer && e.footer.text) || '').length > LIMITS.footer) throw new Error(`footer over ${LIMITS.footer}`);
const total = embedLength(e);
if (total > LIMITS.total) throw new Error(`embed over ${LIMITS.total} characters (${total})`);
const allText = [payload.content, e.title, e.description, e.footer && e.footer.text, ...(e.fields || []).flatMap(f => [f.name, f.value])].filter(Boolean).join('\n');
guardText(allText, 'post');
if (!payload.allowed_mentions || (payload.allowed_mentions.parse || []).length) throw new Error('allowed_mentions must be empty');
return total;
}
// ---------- formatting ----------
export const fmtInt = n => (n === null || n === undefined || Number.isNaN(Number(n)) ? 'n/a' : Math.round(Number(n)).toLocaleString('en-GB'));
export const fmtNum = (n, d = 2) => (n === null || n === undefined || Number.isNaN(Number(n)) ? 'n/a' : Number(n).toLocaleString('en-GB', { minimumFractionDigits: d, maximumFractionDigits: d }));
export function fmtHash(h) {
if (h === null || h === undefined || Number.isNaN(Number(h))) return 'n/a';
const n = Number(h);
if (n >= 1e12) return `${(n / 1e12).toFixed(2)} TH/s`;
if (n >= 1e9) return `${(n / 1e9).toFixed(2)} GH/s`;
if (n >= 1e6) return `${(n / 1e6).toFixed(1)} MH/s`;
if (n >= 1e3) return `${(n / 1e3).toFixed(1)} kH/s`;
return `${Math.round(n)} H/s`;
}
export function fmtDur(s) {
if (s === null || s === undefined || Number.isNaN(Number(s))) return 'n/a';
s = Math.max(0, Math.round(Number(s)));
if (s < 60) return `${s} s`;
const m = Math.round(s / 60);
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60), r = m % 60;
if (h < 48) return r ? `${h} h ${r} min` : `${h} h`;
return `${Math.round(h / 24)} days`;
}
export function fmtPct(x, d = 1) { return x === null || x === undefined || Number.isNaN(Number(x)) ? 'n/a' : `${(Number(x) * 100).toFixed(d)}%`; }
export function fmtDelta(now, prev, unit = '') {
if (now === null || prev === null || now === undefined || prev === undefined) return 'n/a';
const d = Number(now) - Number(prev);
const sign = d > 0 ? '+' : d < 0 ? '−' : '±';
return `${sign}${fmtInt(Math.abs(d))}${unit}`;
}
export function fmtChangePct(now, prev) {
if (!now || !prev) return 'n/a';
const p = (Number(now) - Number(prev)) / Number(prev) * 100;
const sign = p > 0 ? '+' : p < 0 ? '−' : '±';
return `${sign}${Math.abs(p).toFixed(1)}%`;
}
export const plural = (n, one, many = `${one}s`) => `${fmtInt(n)} ${Number(n) === 1 ? one : many}`;
export function fmtIgn(x, d = 2) { return x === null || x === undefined || Number.isNaN(Number(x)) ? 'n/a' : `${Number(x).toLocaleString('en-GB', { minimumFractionDigits: d, maximumFractionDigits: d })} IGN`; }
// London clock. Footer: "6 Oct 2026, 20:12 UK (19:12 UTC)".
const LONDON = new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/London', year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false, weekday: 'short' });
export function londonParts(d = new Date()) {
const p = Object.fromEntries(LONDON.formatToParts(d).map(x => [x.type, x.value]));
return { weekday: p.weekday, day: Number(p.day), month: p.month, year: Number(p.year), hour: Number(p.hour) % 24, minute: Number(p.minute) };
}
export function londonDateKey(d = new Date()) {
const p = Object.fromEntries(new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/London', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(d).map(x => [x.type, x.value]));
return `${p.year}-${p.month}-${p.day}`;
}
export function ukStamp(d = new Date()) {
const l = londonParts(d);
const hh = String(l.hour).padStart(2, '0'), mm = String(l.minute).padStart(2, '0');
const utc = `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
return `${l.day} ${l.month} ${l.year}, ${hh}:${mm} UK (${utc} UTC)`;
}
export const utcStamp = d => `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
export const ageOf = (iso, now = Date.now()) => (iso ? fmtDur((now - new Date(iso).getTime()) / 1000) : 'n/a');
// ---------- the embed ----------
export function embed({ title, url, description, fields = [], footerNote, now = new Date(), thumbnail = true }) {
const e = {
title, url, description, color: EMBER,
fields: fields.filter(Boolean).slice(0, LIMITS.fields).map(f => ({ name: f.name, value: f.value, inline: f.inline !== false })),
footer: { text: `${ukStamp(now)}${footerNote ? ` · ${footerNote}` : ''}` },
timestamp: now.toISOString(),
};
if (thumbnail) e.thumbnail = { url: ICON };
return { username: BOT_NAME, avatar_url: ICON, embeds: [e], allowed_mentions: { parse: [] } };
}
// ---------- data ----------
export async function fetchJson(url, timeoutMs = 15000) {
const r = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), headers: { 'user-agent': 'igneum-discord-hooks/1 (the project; posts numbers, never replies)' } });
if (!r.ok) throw new Error(`${url} answered ${r.status}`);
return r.json();
}
export async function fetchAll({ fleet = true } = {}) {
const [stats, live, supply] = await Promise.all([fetchJson(API.stats), fetchJson(API.live), fetchJson(API.supply)]);
let fleetJson = null;
if (fleet) { try { fleetJson = await fetchJson(FLEET_URL, 8000); } catch { fleetJson = null; } }
return { stats, live, supply, fleet: fleetJson, now: new Date() };
}
// One line of numbers from the three endpoints, stored per pulse so the next one has its window.
export function snapshot({ stats, live, supply, now }) {
const lock = newestLock(live);
return {
at: now.toISOString(),
height: stats.height, block_count: stats.block_count, daa: stats.daa, difficulty: stats.difficulty, hashrate: stats.hashrate,
miners_10m: stats.miners_10m, peers: stats.peers, node_version: stats.node_version,
block_time_measured_s: stats.block_time_measured_s, blocks_per_day_measured: stats.blocks_per_day_measured,
locked_index: (live.finality && live.finality.latest_locked_index) ?? (stats.finality && stats.finality.latest_locked_index) ?? null,
finality_active: !!(stats.finality && stats.finality.active),
voters: live.finality && live.finality.weights ? live.finality.weights.voters ?? null : null,
last_lock_fraction: lock ? lock.fraction_total : null,
paid_shards_total: live.proving && live.proving.paid_shards_total !== undefined ? live.proving.paid_shards_total : null,
median_proof_lag_s: live.proving ? live.proving.median_proof_lag_s ?? null : null,
provers_10m: live.proving ? live.proving.provers_10m ?? null : null,
proving_active: !!(live.proving && live.proving.active),
circulating_ign: supply.circulating_ign !== undefined ? Number(supply.circulating_ign) : null,
ramp_factor: supply.ramp ? supply.ramp.factor_now : null,
};
}
export function newestLock(live) {
const cps = (live.finality && live.finality.checkpoints) || [];
return cps.filter(c => c.state === 'locked').slice(-1)[0] || null;
}
export function paidInWindow(live) {
let ign = 0, shards = 0;
for (const b of live.blocks || []) for (const s of b.shards || []) if (s.state === 'paid') { shards++; ign += s.payout || 0; }
return { ign, shards };
}
export function versionShare(live) {
const engines = (live.miners || []).map(m => m.engine).filter(Boolean);
if (!engines.length) return null;
const count = new Map();
for (const e of engines) count.set(e, (count.get(e) || 0) + 1);
return [...count.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([v, n]) => `${v} ${Math.round(n / engines.length * 100)}%`).join(', ');
}
// Devnet 2 from the fleet file: numbers and the gate word only. Never its seed, its state text or its box names.
export function devnet2Line(fleet, now = Date.now()) {
const d = fleet && fleet.devnet2;
if (!d || d.height === undefined) return null;
const gate = typeof d.last_gate === 'string' ? (/^PASS/i.test(d.last_gate) ? 'PASS' : /^FAIL/i.test(d.last_gate) ? 'FAIL' : null) : null;
const parts = [`${fmtInt(d.boxes)} boxes`, `height ${fmtInt(d.height)}`];
if (gate) parts.push(`last gate ${gate} ${ageOf(d.last_gate_at, now)} ago`);
return parts.join(', ');
}
function closestBefore(snaps, targetMs, toleranceMs) {
let best = null;
for (const s of snaps) {
const t = new Date(s.at).getTime();
if (t > targetMs + toleranceMs) continue;
if (!best || Math.abs(t - targetMs) < Math.abs(new Date(best.at).getTime() - targetMs)) best = s;
}
return best;
}
// ---------- 1. the network pulse ----------
export function shapePulse(data, prev) {
const { stats, live, supply, fleet, now } = data;
const cur = snapshot(data);
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
: (live.state && live.state.blocks_per_second_60s) ?? null;
const bpsNote = prev ? `over ${fmtDur(windowS)}` : 'last 60 s';
const lock = newestLock(live);
const paid = paidInWindow(live);
const pv = live.proving || {};
const vshare = versionShare(live);
const dn2 = devnet2Line(fleet, now.getTime());
const stale = !!stats.stale;
const paused = !(stats.finality && stats.finality.active);
const pausedLine = paused ? `\nFinality paused since ${lock && lock.locked_at ? utcStamp(new Date(lock.locked_at)) : 'the last lock'}: nothing is final until two thirds of the 30-day weight signs again; mining and proving are paid as usual.` : '';
const description = (stale
? `Observer stale for ${fmtDur(stats.age_s)}: every number is last known. Live: ${SITE}/live`
: `${stats.network || 'devnet'}, node ${stats.node_version || 'n/a'}. Devnet coins have no value. Live: ${SITE}/live`) + pausedLine;
const fields = [
{ name: 'Chain block', value: `${fmtInt(stats.height)}\n${bps === null ? 'n/a' : bps.toFixed(2)} blocks/s ${bpsNote}` },
{ name: 'Hash rate', value: `${fmtHash(stats.hashrate)}\n${fmtInt(stats.miners_10m)} vote keys active (a card runs several)` },
{ name: 'Difficulty', value: `${fmtNum(stats.difficulty, 0)}\n${prev ? `${fmtChangePct(stats.difficulty, prev.difficulty)} in ${fmtDur(windowS)}` : 'first pulse, no window yet'}` },
{ 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')}`
: `${stats.finality && stats.finality.active ? 'active' : 'paused'}, latest index ${fmtInt(cur.locked_index)}` },
{ name: 'Proving', value: `${prev && prev.paid_shards_total !== null ? `${fmtDelta(cur.paid_shards_total, prev.paid_shards_total)} shards paid in ${fmtDur(windowS)}` : `${fmtInt(cur.paid_shards_total)} shards paid in total`}\n${fmtIgn(paid.ign, 2)} to provers in the last 5 min` },
{ name: 'Proof lag', value: `median ${pv.median_proof_lag_s === null || pv.median_proof_lag_s === undefined ? 'n/a' : fmtDur(pv.median_proof_lag_s)}\n${plural(pv.provers_10m, 'prover')} in 10 min${pv.verifier ? `, verifier ${pv.verifier}` : ''}` },
{ name: 'Reward', value: `${fmtIgn(stats.block_reward && stats.block_reward.ign, 4)} per block\nramp ${fmtPct(cur.ramp_factor, 1)}, ${fmtIgn(cur.circulating_ign, 0)} minted` },
];
if (vshare) fields.push({ name: 'Miner versions', value: vshare });
if (dn2 && fields.length < LIMITS.fields) fields.push({ name: 'Devnet 2 (staging)', value: dn2 });
const payload = embed({ title: 'Network pulse', url: `${SITE}/live`, description, fields, footerNote: 'every 6 hours from /api/stats, /api/live, /api/supply', now });
return { payload, snapshot: cur };
}
// ---------- the 24 h digest ----------
export function shapeDigest(data, snaps) {
const { now } = data;
const cur = snapshot(data);
const prev = closestBefore(snaps, now.getTime() - 24 * 3600e3, 3 * 3600e3);
const row = (label, a, b, fmt = fmtInt) => `${label.padEnd(11)} ${String(fmt(a)).padStart(11)} ${String(fmt(b)).padStart(11)}`;
const lines = [
`${'24 h'.padEnd(11)} ${'yesterday'.padStart(11)} ${'now'.padStart(11)}`,
row('chain block', prev && prev.height, cur.height),
row('DAG blocks', prev && prev.block_count, cur.block_count),
row('hash rate', prev && prev.hashrate, cur.hashrate, fmtHash),
row('difficulty', prev && prev.difficulty, cur.difficulty, v => (v === null || v === undefined ? 'n/a' : fmtNum(v / 1e6, 1) + 'M')),
row('vote keys', prev && prev.miners_10m, cur.miners_10m),
row('locks', prev && prev.locked_index, cur.locked_index),
row('shards paid', prev && prev.paid_shards_total, cur.paid_shards_total),
row('minted IGN', prev && prev.circulating_ign, cur.circulating_ign, v => (v === null || v === undefined ? 'n/a' : fmtInt(v))),
];
const deltas = prev ? [
`Blocks ${fmtDelta(cur.block_count, prev.block_count)}, locks ${fmtDelta(cur.locked_index, prev.locked_index)}, shards paid ${fmtDelta(cur.paid_shards_total, prev.paid_shards_total)}, minted ${fmtDelta(cur.circulating_ign, prev.circulating_ign, ' IGN')}.`,
`Hash rate ${fmtChangePct(cur.hashrate, prev.hashrate)}, difficulty ${fmtChangePct(cur.difficulty, prev.difficulty)}.`,
].join('\n') : 'No snapshot from 24 h ago yet; the table fills from tomorrow.';
const description = `\`\`\`\n${lines.join('\n')}\n\`\`\`\n${deltas}`;
const payload = embed({ title: 'Daily digest', url: `${SITE}/live`, description, fields: [], footerNote: 'yesterday is the 09:00 pulse 24 h ago', now, thumbnail: false });
return { payload, snapshot: cur };
}
// ---------- 4. the weekly numbers (the reddit kit's template as an embed) ----------
export function shapeWeekly(data, prev, incidents = []) {
const { stats, live, supply, now } = data;
const cur = snapshot(data);
const lw = (v, fmt = fmtInt) => (prev ? ` (last week ${fmt(v)})` : '');
const lock = newestLock(live);
const pv = live.proving || {};
const chk = supply.check || {};
const monday = new Date(now); const dow = (monday.getUTCDay() + 6) % 7; monday.setUTCDate(monday.getUTCDate() - dow);
const weekOf = `${monday.getUTCDate()} ${monday.toLocaleString('en-GB', { month: 'short', timeZone: 'UTC' })} ${monday.getUTCFullYear()}`;
const staleLine = stats.stale ? `Observer stale for ${fmtDur(stats.age_s)}: every number is last known.\n` : '';
const description = `${staleLine}Weekly numbers from the public API. Every line names its field; raw JSON at ${SITE}/api/stats, /api/live and /api/supply. Devnet coins have no value and the chain may be reset. ${prev ? '' : 'First weekly post: no last-week column yet. '}Questions go to #ask; this bot never replies.\nLedger: ${SITE}/ledger`;
const fields = [
{ name: 'Chain (/api/stats)', inline: false, value: [
`height ${fmtInt(stats.height)}${lw(prev && prev.height)}`,
`block_count ${fmtInt(stats.block_count)}${lw(prev && prev.block_count)}`,
`hashrate ${fmtHash(stats.hashrate)}${lw(prev && prev.hashrate, fmtHash)}`,
`difficulty ${fmtNum(stats.difficulty, 0)}`,
`block_time_measured_s ${fmtNum(stats.block_time_measured_s, 3)} against 1 s target`,
`blocks_per_day_measured ${fmtInt(stats.blocks_per_day_measured)} against 86,400`,
`miners_10m ${fmtInt(stats.miners_10m)} vote keys (a card runs several)${lw(prev && prev.miners_10m)}, peers ${fmtInt(stats.peers)}`,
].join('\n') },
{ name: 'Reward and supply (/api/stats block_reward, /api/supply)', inline: false, value: [
`block_reward.ign ${fmtIgn(stats.block_reward && stats.block_reward.ign, 8)} per blue block, 80% miner / 20% proving pool`,
`ramp_factor ${fmtPct(cur.ramp_factor, 1)} of the schedule (30-day launch ramp)`,
`circulating_ign ${fmtIgn(cur.circulating_ign, 2)} of 4,000,000,000`,
`check.rule_match ${fmtInt(chk.rule_match)} / ${fmtInt(chk.sampled)} blocks match the emission rule`,
].join('\n') },
{ name: 'Finality (/api/stats finality, /api/live finality)', inline: false, value: [
`active ${cur.finality_active ? 'yes' : 'no'}${prev ? ` (last week ${prev.finality_active ? 'yes' : 'no'})` : ''}`,
`latest_locked_index ${fmtInt(cur.locked_index)}${lw(prev && prev.locked_index)}, locks this week ${prev ? fmtDelta(cur.locked_index, prev.locked_index) : 'n/a'}`,
`voters in the window ${fmtInt(cur.voters)}${lw(prev && prev.voters)}`,
`last lock ${lock ? `checkpoint ${fmtInt(lock.index)} at ${fmtPct(lock.fraction_total)} of weight` : 'n/a'}`,
].join('\n') },
{ name: 'Proving (/api/live proving)', inline: false, value: [
`active ${cur.proving_active ? 'yes' : 'no'}, verifier ${pv.verifier || 'n/a'}`,
`paid_shards_total ${fmtInt(cur.paid_shards_total)}${lw(prev && prev.paid_shards_total)}, shards this week ${prev ? fmtDelta(cur.paid_shards_total, prev.paid_shards_total) : 'n/a'}`,
`shards_paid_10m ${fmtInt(pv.shards_paid_10m)} / blocks_10m ${fmtInt(pv.blocks_10m)}, blocks_fully_proven_10m ${fmtInt(pv.blocks_fully_proven_10m)}`,
`median_proof_lag_s ${pv.median_proof_lag_s === null || pv.median_proof_lag_s === undefined ? 'n/a' : fmtDur(pv.median_proof_lag_s)} (target under 60 s)${lw(prev && prev.median_proof_lag_s, v => (v === null || v === undefined ? 'n/a' : fmtDur(v)))}`,
`provers_10m ${fmtInt(pv.provers_10m)}${lw(prev && prev.provers_10m)}`,
].join('\n') },
{ name: 'Incidents this week', inline: false, value: incidents.length ? incidents.slice(0, 6).map(s => `• ${s}`).join('\n').slice(0, LIMITS.fieldValue) : 'None recorded by the project this week.' },
{ name: 'What these mean', inline: false, value: [
'• Divide the network hash rate by a card\'s MH/s for the share one card holds this week.',
'• A ramp under 100% means every block pays that fraction of the schedule.',
'• A proof lag over 60 s means the phase 3 gate is open.',
'• Vote keys are not machines; the machine count is owed (ledger X5).',
].join('\n') },
];
const payload = embed({ title: `Devnet numbers, week of ${weekOf}`, url: `${SITE}/live`, description, fields, footerNote: 'every Monday 09:00 UK', now });
return { payload, snapshot: cur };
}
// ---------- 2. the release ----------
// "What changed": up to five plain lines. From --changed flags, or read from the named section of the release plan:
// bullet lines as they are; table rows by their first cell, cut at the first semicolon, code ticks removed, cut to 160 characters
// at a clause boundary. A line that trips the guard is dropped with a note on stderr; the plan is an internal document.
export function changedLinesFromPlan(markdown, sectionHeading, max = 5, warn = () => {}) {
const lines = markdown.split('\n');
const start = lines.findIndex(l => /^#{1,6}\s/.test(l) && l.replace(/^#+\s*/, '').toLowerCase().startsWith(sectionHeading.toLowerCase()));
if (start < 0) throw new Error(`section not found in the plan: ${sectionHeading}`);
const out = [];
for (let i = start + 1; i < lines.length && out.length < max; i++) {
const l = lines[i];
if (/^#{1,6}\s/.test(l)) break;
let text = null;
if (/^\s*[-*]\s+/.test(l)) text = l.replace(/^\s*[-*]\s+/, '');
else if (/^\|/.test(l) && !/^\|\s*-{2,}/.test(l)) {
const cells = l.split('|').map(c => c.trim()).filter((c, k, a) => k > 0 && k < a.length - 1);
if (cells.length && !/^(change|what|item|step)$/i.test(cells[0])) text = cells[0];
}
if (!text) continue;
text = plainLine(text);
if (!text) continue;
try { guardText(text, 'a changed line'); out.push(text); } catch (e) { warn(`dropped a changed line: ${e.message}`); }
}
return out;
}
export function plainLine(text, max = 160) {
let t = text.replace(/`/g, '').replace(/\*\*/g, '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').trim();
const semi = t.indexOf(';'); if (semi > 20) t = t.slice(0, semi);
t = t.trim().replace(/[,:]$/, '');
if (t.length > max) {
// a parenthetical is the first detail to drop, then the last clause before the limit, then the last word
const paren = t.indexOf(' (', 30);
if (paren > 30) t = t.slice(0, paren) + (t.indexOf(')', paren) > 0 && t.indexOf(')', paren) < t.length - 1 ? t.slice(t.indexOf(')', paren) + 1) : '');
}
if (t.length > max) {
const cut = Math.max(t.lastIndexOf(', ', max), t.lastIndexOf(' and ', max));
t = (cut > 40 ? t.slice(0, cut) : t.slice(0, t.lastIndexOf(' ', max))).trim();
}
return t.replace(/[,:(]$/, '').trim();
}
export function shapeRelease({ version, windows, windowsSha, mac, macSha, hive, hiveSha, nodeCommit, digest, changed = [], now = new Date() }) {
for (const [k, v] of Object.entries({ version, windows, windowsSha, mac, macSha, hive, hiveSha, nodeCommit, digest })) if (!v) throw new Error(`release needs ${k}`);
for (const [k, v] of Object.entries({ windowsSha, macSha, hiveSha })) if (!/^[0-9a-f]{64}$/i.test(v)) throw new Error(`${k} is not a sha256`);
const lines = changed.slice(0, 5).map(l => plainLine(l)).filter(Boolean);
const description = `Igneum Miner ${version} is live for Windows, macOS and HiveOS. The app updates itself; a fresh install is at ${SITE}/miner#get.${lines.length ? `\n\n**What changed**\n${lines.map(l => `• ${l}`).join('\n')}` : ''}`;
const dl = (url, sha) => `[Download](${url})\n\`${sha}\``;
const fields = [
{ name: 'Windows', value: dl(windows, windowsSha) },
{ name: 'macOS', value: dl(mac, macSha) },
{ name: 'HiveOS', value: dl(hive, hiveSha) },
{ name: 'Node commit', value: `\`${nodeCommit}\`` },
{ name: 'Consensus digest', value: `\`${digest}\`` },
{ name: 'Verify', value: '`shasum -a 256 <file>` on Mac and Linux, `certutil -hashfile <file> SHA256` on Windows; the hash must match the line above.', inline: false },
];
return { payload: embed({ title: `Igneum Miner ${version}`, url: `${SITE}/miner`, description, fields, footerNote: 'signed release notes on igneum.network/miner', now }) };
}
// ---------- 3. incidents ----------
export function shapeIncidentOpen({ id, at = new Date(), what, affected, doing, now = new Date() }) {
for (const [k, v] of Object.entries({ id, what, affected, doing })) if (!v) throw new Error(`incident open needs ${k}`);
const fields = [
{ name: 'Opened', value: utcStamp(at) },
{ name: 'Incident', value: `\`${id}\`` },
{ name: 'Who is affected', value: affected, inline: false },
{ name: 'What is being done', value: doing, inline: false },
];
return { payload: embed({ title: 'Incident open', url: `${SITE}/live`, description: what, fields, footerNote: 'resolved here when it clears', now }) };
}
export function shapeIncidentResolve({ id, openedAt, at = new Date(), cause, fix, what, now = new Date() }) {
for (const [k, v] of Object.entries({ id, cause, fix })) if (!v) throw new Error(`incident resolve needs ${k}`);
const dur = openedAt ? fmtDur((new Date(at).getTime() - new Date(openedAt).getTime()) / 1000) : 'n/a';
const fields = [
{ name: 'Resolved', value: utcStamp(at) },
{ name: 'Duration', value: dur },
{ name: 'Incident', value: `\`${id}\`` },
{ name: 'Cause', value: cause, inline: false },
{ name: 'The rule or fix', value: fix, inline: false },
];
return { payload: embed({ title: 'Incident resolved', url: `${SITE}/live`, description: what || 'Resolved.', fields, footerNote: 'UTC times; the project\'s own record', now }) };
}
// The watcher: three conditions from the public API. One open incident per condition; a condition opens after it has held
// for its threshold and resolves after it has been clear for WATCH.clearHoldS. State carries since/clear_since per condition.
export const WATCH_CONDITIONS = {
finality_paused: {
holdS: WATCH.finalityPausedS,
test: ({ stats, live }, st, now) => {
const active = !!(stats.finality && stats.finality.active);
const idx = (live.finality && live.finality.latest_locked_index) ?? (stats.finality && stats.finality.latest_locked_index) ?? null;
// the index not moving for the threshold counts as paused too
if (idx !== null && st.lastIdx !== idx) { st.lastIdx = idx; st.lastIdxAt = now.toISOString(); }
const stuck = st.lastIdxAt ? (now.getTime() - new Date(st.lastIdxAt).getTime()) / 1000 > WATCH.finalityPausedS : false;
return !active || stuck;
},
what: ({ stats }) => `Finality paused: the node reports ${stats.finality && stats.finality.active ? 'no new lock' : 'finality inactive'} for over ${fmtDur(WATCH.finalityPausedS)}. Blocks keep being produced; the chain is not locking checkpoints.`,
affected: 'Everyone equally: no transaction is final until locks resume. Miners keep mining and are paid; provers keep proving; wallets and the explorer show unlocked blocks.',
doing: 'The project is reading the vote weight in the window (finality needs two thirds of all 30-day weight connected and signing). If miners left the network, the lock resumes as they return or as the window rolls.',
cause: 'Under two thirds of the 30-day vote weight was connected and signing, or the observer saw no lock advance.',
fix: 'Locks resumed by themselves once enough weight was back. Rule unchanged: finality pauses rather than locks on a minority.',
},
proof_lag: {
holdS: 0,
test: ({ live }) => !!(live.proving && live.proving.median_proof_lag_s !== null && live.proving.median_proof_lag_s !== undefined && live.proving.median_proof_lag_s > WATCH.proofLagS),
what: ({ live }) => `Proving behind: the median proof lag is ${fmtDur(live.proving.median_proof_lag_s)} against a target under 60 s.`,
affected: 'Blocks are produced and final as usual; their proofs arrive late. Provers earn the same per shard. Anyone reading proven state (bridges, the explorer\'s proven badge) sees it minutes behind the tip.',
doing: 'The project is checking prover count and shard sizes. More provers or smaller shards close the gap; the 8 to 16 GB prover is the open item.',
cause: 'Fewer provers than the block rate needs, or large shards.',
fix: 'The median lag is back under the threshold. The phase 3 target stays under 60 s.',
},
observer_silent: {
holdS: WATCH.observerSilentS,
test: ({ stats, fetchFailed }) => !!fetchFailed || (!!stats.stale && (stats.age_s ?? Infinity) > WATCH.observerSilentS),
what: ({ stats, fetchFailed }) => fetchFailed ? 'The public API did not answer for over 3 minutes.' : `The observer has not written for ${fmtDur(stats.age_s)}: /api/stats and /api/live show last-known numbers.`,
affected: 'Nobody on the chain: blocks, mining and proving continue. The live page, the explorer and this bot show stale numbers until the observer returns.',
doing: 'The observer is being restarted or its node reconnected. Numbers resume by themselves.',
cause: 'The observer process or its node stopped writing.',
fix: 'The observer is writing again; the numbers are current.',
},
};
export function watchPass(data, state, now = data.now) {
state.watch = state.watch || {};
const actions = [];
for (const [key, c] of Object.entries(WATCH_CONDITIONS)) {
const st = state.watch[key] = state.watch[key] || {};
let firing = false;
try { firing = c.test(data, st, now); } catch { firing = false; }
// A condition already firing when the watcher first looks is pre-existing: it was not observed to start, so it is
// recorded and never opened (the resolve would have no open to match). It clears silently; the next start is a
// transition the watcher saw and opens as normal. The pulse carries "finality paused since" in the meantime.
if (!st.seen) { st.seen = now.toISOString(); if (firing) { st.preexisting = { since: now.toISOString() }; continue; } }
if (st.preexisting) { if (firing) continue; st.preexisting = null; st.since = null; continue; }
if (firing) {
st.clear_since = null;
if (!st.since) st.since = now.toISOString();
const heldS = (now.getTime() - new Date(st.since).getTime()) / 1000;
if (!st.open && heldS >= c.holdS) {
const id = `auto-${key}-${now.toISOString().slice(0, 16).replace(/[-:T]/g, '')}`;
st.open = { id, at: now.toISOString() };
actions.push({ kind: 'open', key, id, what: c.what(data), affected: c.affected, doing: c.doing, at: now });
}
} else {
st.since = null;
if (st.open) {
if (!st.clear_since) st.clear_since = now.toISOString();
const clearS = (now.getTime() - new Date(st.clear_since).getTime()) / 1000;
if (clearS >= WATCH.clearHoldS) {
actions.push({ kind: 'resolve', key, id: st.open.id, openedAt: st.open.at, cause: c.cause, fix: c.fix, at: now });
st.open = null; st.clear_since = null;
}
}
}
}
return actions;
}
// ---------- credentials, state, posting ----------
export function readCredentials(file = CRED_FILE) {
if (!fs.existsSync(file)) return null;
const out = {};
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const m = /^\s*([A-Z_]+)\s*=\s*(.*?)\s*$/.exec(line);
if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
return out;
}
export const CHANNEL_KEY = { numbers: 'DISCORD_WEBHOOK_NUMBERS', announcements: 'DISCORD_WEBHOOK_ANNOUNCEMENTS', incidents: 'DISCORD_WEBHOOK_INCIDENTS' };
export function loadState(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return { posts: {}, pulses: [], incidents: {}, watch: {} }; }
}
export function saveState(file, state) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(state, null, 1), { mode: 0o600 });
fs.renameSync(tmp, file);
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
// POST with ?wait=true so the message id comes back; exponential backoff on 429 (retry_after honoured) and on 5xx.
export async function postWebhook(url, payload, { fetchImpl = fetch, maxTries = 6, sleepImpl = sleep } = {}) {
let wait = 1000;
for (let i = 1; i <= maxTries; i++) {
const r = await fetchImpl(`${url}${url.includes('?') ? '&' : '?'}wait=true`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) });
if (r.status === 429 || r.status >= 500) {
let retryAfter = Number(r.headers.get('retry-after')) || 0;
try { const j = await r.json(); if (j && j.retry_after) retryAfter = Math.max(retryAfter, Number(j.retry_after)); } catch { /* no body */ }
if (i === maxTries) throw new Error(`webhook answered ${r.status} after ${maxTries} tries`);
await sleepImpl(Math.max(wait, retryAfter * 1000));
wait = Math.min(wait * 2, 60000);
continue;
}
if (!r.ok) throw new Error(`webhook answered ${r.status}`); // the body may echo the URL; never printed
const j = await r.json().catch(() => ({}));
return { id: j.id || null, channel_id: j.channel_id || null };
}
throw new Error('unreachable');
}
export class Poster {
constructor({ live = false, outDir = OUT_DIR, credFile = CRED_FILE, stateFile, force = false, log = msg => process.stderr.write(`${msg}\n`), fetchImpl = fetch } = {}) {
this.live = live; this.outDir = outDir; this.force = force; this.log = log; this.fetchImpl = fetchImpl;
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'));
this.state = loadState(this.stateFile);
this.state.posts = this.state.posts || {}; this.state.pulses = this.state.pulses || []; this.state.incidents = this.state.incidents || {}; this.state.watch = this.state.watch || {};
}
already(key) { return !this.force && !!this.state.posts[key]; }
// 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);
if (this.already(key)) { this.log(`skip ${key}: already posted ${this.state.posts[key].at}`); return { posted: false, skipped: true, id: this.state.posts[key].id }; }
if (!this.live) {
fs.mkdirSync(this.outDir, { recursive: true });
const file = path.join(this.outDir, `${key.replace(/[^a-zA-Z0-9_.-]+/g, '_')}.json`);
fs.writeFileSync(file, JSON.stringify({ channel, key, at: new Date().toISOString(), payload }, null, 2));
this.state.posts[key] = { id: null, at: new Date().toISOString(), dry: true, channel };
this.log(`dry-run ${channel} ${key}: ${total} characters → ${file}`);
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 r = await postWebhook(url, payload, { fetchImpl: this.fetchImpl });
this.state.posts[key] = { id: r.id, at: new Date().toISOString(), channel };
this.save();
this.log(`posted ${channel} ${key}: ${total} characters, message ${r.id || 'id unknown'}`);
return { posted: true, skipped: false, id: r.id };
}
save() { saveState(this.stateFile, this.state); }
}
// ---------- the preview (Discord-like rendering of out/*.json) ----------
function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function md(s) {
if (!s) return '';
let t = esc(s);
t = t.replace(/```\n?([\s\S]*?)```/g, (_, c) => `<pre>${c.replace(/\n$/, '')}</pre>`);
t = t.replace(/`([^`]+)`/g, '<code>$1</code>');
t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
t = t.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, '<a href="$2">$1</a>');
t = t.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, '$1<a href="$2">$2</a>');
return t.replace(/\n/g, '<br>');
}
export function renderPreview(items) {
const cards = items.map(({ channel, key, payload }) => {
const e = payload.embeds[0];
const fields = (e.fields || []).map(f => `<div class="f ${f.inline ? 'in' : 'full'}"><div class="fn">${md(f.name)}</div><div class="fv">${md(f.value)}</div></div>`).join('');
return `<section class="msg"><div class="meta">#${esc(channel)} <span class="key">${esc(key)}</span> <span class="len">${embedLength(e)} / ${LIMITS.total} chars</span></div>
<div class="row"><img class="av" src="${esc(payload.avatar_url)}" alt=""><div class="body"><div class="hdr"><span class="name">${esc(payload.username)}</span><span class="bot">APP</span><span class="time">Today at ${esc(ukStamp(new Date(e.timestamp)).replace(/^.*?, /, '').replace(/ UK.*$/, ''))}</span></div>
<div class="embed" style="border-left-color:#${e.color.toString(16).padStart(6, '0')}"><div class="grid"><div class="main">
${e.title ? `<div class="title">${e.url ? `<a href="${esc(e.url)}">${md(e.title)}</a>` : md(e.title)}</div>` : ''}
${e.description ? `<div class="desc">${md(e.description)}</div>` : ''}
<div class="fields">${fields}</div>
${e.footer ? `<div class="footer">${md(e.footer.text)}</div>` : ''}
</div>${e.thumbnail ? `<img class="thumb" src="${esc(e.thumbnail.url)}" alt="">` : ''}</div></div></div></div></section>`;
}).join('\n');
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Igneum Discord posts</title>
<style>
:root{--bg:#313338;--embed:#2b2d31;--text:#dbdee1;--muted:#949ba4;--link:#00a8fc;--ember:#F2541B;--code:#1e1f22}
body{margin:0;background:var(--bg);color:var(--text);font:15px/1.375 "gg sans","Noto Sans",system-ui,-apple-system,"Segoe UI",sans-serif;padding:24px 16px}
h1{font-size:16px;font-weight:600;color:var(--muted);margin:0 0 16px;letter-spacing:.02em} h1 b{color:var(--ember)}
.msg{max-width:720px;margin:0 auto 28px} .meta{font-size:12px;color:var(--muted);margin:0 0 6px 56px} .key{opacity:.7;margin-left:8px} .len{float:right}
.row{display:flex;gap:16px} .av{width:40px;height:40px;border-radius:50%;flex:none;background:#000}
.hdr{display:flex;align-items:center;gap:6px;margin-bottom:4px} .name{font-weight:500;color:#f2f3f5} .bot{background:#5865f2;color:#fff;font-size:10px;font-weight:600;border-radius:3px;padding:0 4px;line-height:15px} .time{font-size:12px;color:var(--muted)}
.embed{background:var(--embed);border-left:4px solid var(--ember);border-radius:4px;padding:8px 16px 16px 12px;max-width:520px}
.grid{display:grid;grid-template-columns:1fr auto;gap:16px} .thumb{width:80px;height:80px;border-radius:4px;margin-top:8px}
.title{font-weight:600;color:#f2f3f5;margin-top:8px} .title a{color:var(--link);text-decoration:none} .title a:hover{text-decoration:underline}
.desc{font-size:14px;margin-top:8px;white-space:normal} .desc a,.fv a{color:var(--link);text-decoration:none}
.fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:8px} .f.full{grid-column:1/-1}
.fn{font-size:14px;font-weight:600;color:#f2f3f5;margin-bottom:2px} .fv{font-size:14px;overflow-wrap:anywhere}
code{background:var(--code);border-radius:3px;padding:1px 4px;font:12px ui-monospace,Menlo,Consolas,monospace;overflow-wrap:anywhere}
pre{background:var(--code);border-radius:4px;padding:8px;font:12px/1.35 ui-monospace,Menlo,Consolas,monospace;overflow-x:auto;margin:4px 0}
.footer{font-size:12px;color:var(--muted);margin-top:8px}
@media (max-width:600px){.fields{grid-template-columns:1fr 1fr}.grid{grid-template-columns:1fr}.thumb{display:none}.meta{margin-left:0}}
</style></head><body><h1><b>Igneum</b> Discord posts, dry-run render (${items.length})</h1>${cards}</body></html>`;
}
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 };
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));
return file;
}
// ---------- the scheduler ----------
export function dueNow(now, state) {
const l = londonParts(now);
const date = londonDateKey(now);
const due = [];
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}` });
return due.filter(d => !(state.posts && state.posts[d.key]));
}
// ---------- commands ----------
function argsOf(argv) {
const flags = {}; const rest = []; const multi = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) {
const name = a.slice(2);
const next = argv[i + 1];
if (next !== undefined && !next.startsWith('--')) {
if (name === 'changed') (multi.changed = multi.changed || []).push(next); else flags[name] = next;
i++;
} else flags[name] = true;
} else rest.push(a);
}
return { flags, rest, multi };
}
function keepPulses(state, snap) {
state.pulses.push(snap);
state.pulses = state.pulses.slice(-120); // 30 days at four a day
}
export async function runPulse(poster, data, key) {
const prev = poster.state.pulses.slice(-1)[0] || null;
const { payload, snapshot: snap } = shapePulse(data, prev);
const r = await poster.post('numbers', key || `pulse:${londonDateKey(data.now)}:${String(londonParts(data.now).hour).padStart(2, '0')}`, payload);
if (!r.skipped) { keepPulses(poster.state, snap); poster.save(); }
return r;
}
export async function runDigest(poster, data, key) {
const { payload } = shapeDigest(data, poster.state.pulses);
const r = await poster.post('numbers', key || `digest:${londonDateKey(data.now)}`, payload);
if (!r.skipped) poster.save();
return r;
}
export async function runWeekly(poster, data, key) {
const weekIncidents = Object.values(poster.state.incidents || {}).filter(i => data.now.getTime() - new Date(i.at).getTime() < 7 * 86400e3)
.map(i => `${utcStamp(new Date(i.at))}: ${i.what}${i.resolved ? ` Resolved after ${fmtDur((new Date(i.resolved.at).getTime() - new Date(i.at).getTime()) / 1000)}: ${i.resolved.fix}` : ' Open.'}`);
const { payload, snapshot: snap } = shapeWeekly(data, poster.state.weekly_prev || null, weekIncidents);
const r = await poster.post('numbers', key || `weekly:${londonDateKey(data.now)}`, payload);
if (!r.skipped) { poster.state.weekly_prev = snap; poster.save(); }
return r;
}
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);
if (!r.skipped) { poster.state.incidents[id] = { id, at: new Date(at).toISOString(), what, affected, doing, auto, message_id: r.id }; poster.save(); }
return r;
}
export async function resolveIncident(poster, { id, at, cause, fix }) {
const inc = poster.state.incidents[id];
const { payload } = shapeIncidentResolve({ id, openedAt: inc && inc.at, at, cause, fix, what: inc && inc.what, now: new Date() });
const r = await poster.post('incidents', `incident:${id}:resolve`, payload);
if (!r.skipped) { poster.state.incidents[id] = { ...(inc || { id, at: new Date(at).toISOString(), what: '' }), resolved: { at: new Date(at).toISOString(), cause, fix, message_id: r.id } }; poster.save(); }
return r;
}
export async function runWatch(poster, data) {
const actions = watchPass(data, poster.state);
const results = [];
for (const a of actions) {
if (a.kind === 'open') results.push(await openIncident(poster, { id: a.id, at: a.at, what: a.what, affected: a.affected, doing: a.doing, auto: true }));
else results.push(await resolveIncident(poster, { id: a.id, at: a.at, cause: a.cause, fix: a.fix }));
}
poster.save();
return results;
}
async function fetchForWatch() {
try { return await fetchAll({ fleet: false }); } catch (e) { return { stats: { stale: true, age_s: null, finality: null }, live: {}, supply: {}, fleet: null, now: new Date(), fetchFailed: true, error: String(e.message || e) }; }
}
export async function main(argv = process.argv.slice(2)) {
const { flags, rest, multi } = argsOf(argv);
const cmd = rest[0];
const live = !!flags.live;
if (!cmd || flags.help) { process.stdout.write(fs.readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n').slice(1, 22).map(l => l.replace(/^\/\/ ?/, '')).join('\n') + '\n'); return 0; }
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;
}
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 });
if (cmd === 'pulse') { const data = await fetchAll(); await runPulse(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') {
const version = rest[1]; if (!version) throw new Error('release needs a version');
let changed = multi.changed || [];
if (flags.plan) changed = changed.concat(changedLinesFromPlan(fs.readFileSync(flags.plan, 'utf8'), flags.section || 'What changed', 5 - changed.length, poster.log));
const { payload } = shapeRelease({ version, windows: flags.windows, windowsSha: flags['windows-sha'], mac: flags.mac, macSha: flags['mac-sha'], hive: flags.hive, hiveSha: flags['hive-sha'], nodeCommit: flags['node-commit'], digest: flags.digest, changed });
await poster.post('announcements', `release:${version}`, payload);
return 0;
}
if (cmd === 'incident') {
const sub = rest[1];
const at = flags.at ? new Date(flags.at) : new Date();
if (Number.isNaN(at.getTime())) throw new Error('--at must be an ISO UTC time');
if (sub === 'open') {
const id = flags.id || `inc-${at.toISOString().slice(0, 16).replace(/[-:T]/g, '')}`;
await openIncident(poster, { id, at, what: flags.what, affected: flags.affected, doing: flags.doing });
process.stdout.write(`${id}\n`);
return 0;
}
if (sub === 'resolve') { if (!flags.id) throw new Error('incident resolve needs --id'); await resolveIncident(poster, { id: flags.id, at, cause: flags.cause, fix: flags.fix }); return 0; }
throw new Error('incident open | incident resolve');
}
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);
let data = null;
if (due.length) {
data = await fetchAll();
for (const d of due) {
try {
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);
} catch (e) { poster.log(`tick ${d.key} failed: ${e.message}`); }
}
}
const wdata = data && !data.fetchFailed ? data : await fetchForWatch();
const r = await runWatch(poster, wdata);
poster.log(`tick ${ukStamp(now)}: ${due.length} due, watch ${r.length} action(s)`);
return 0;
}
throw new Error(`unknown command ${cmd}`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().then(code => process.exit(code), e => { process.stderr.write(`${String(e.message || e).replace(/https:\/\/discord(app)?\.com\/api\/webhooks\/\S+/g, '<webhook>')}\n`); process.exit(1); });
}

View file

@ -0,0 +1,354 @@
// node --test tools/community/discord-hooks.test.mjs
// Shaping against fixtures cut from the live API on 6 October 2026 (fixtures/discord), the guards, the limits,
// idempotency, the 429 backoff, the watcher's state machine and the London scheduler. Nothing here touches the network.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
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,
} from './discord-hooks.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const fx = name => JSON.parse(fs.readFileSync(path.join(HERE, 'fixtures', 'discord', `${name}.json`), 'utf8'));
const NOW = new Date('2026-10-06T18:44:50Z'); // the fixtures' moment, 19:44 UK
const data = () => ({ stats: fx('stats'), live: fx('live'), supply: fx('supply'), fleet: fx('fleet'), now: NOW });
const tmpDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'discord-hooks-'));
// ---------- the pulse ----------
test('pulse: one embed, ember, icon, at most eight fields, UK footer with UTC in brackets', () => {
const { payload } = shapePulse(data(), null);
guardPayload(payload);
const e = payload.embeds[0];
assert.equal(payload.embeds.length, 1);
assert.equal(e.color, EMBER);
assert.equal(e.thumbnail.url, ICON);
assert.ok(e.fields.length <= 8);
assert.match(e.footer.text, /^6 Oct 2026, 19:44 UK \(18:44 UTC\)/);
assert.deepEqual(payload.allowed_mentions, { parse: [] });
assert.equal(payload.username, 'Igneum');
});
test('pulse: first pulse has no window; the second computes blocks per second and the difficulty change from the snapshot', () => {
const first = shapePulse(data(), null);
assert.match(first.payload.embeds[0].fields[0].value, /blocks\/s last 60 s/);
assert.match(first.payload.embeds[0].fields[2].value, /first pulse/);
const prev = { ...first.snapshot, at: new Date(NOW.getTime() - 6 * 3600e3).toISOString(), block_count: first.snapshot.block_count - 21600, difficulty: first.snapshot.difficulty / 1.1, paid_shards_total: first.snapshot.paid_shards_total - 40 };
const second = shapePulse(data(), prev);
const f = second.payload.embeds[0].fields;
assert.match(f[0].value, /1\.00 blocks\/s over 6 h/);
assert.match(f[2].value, /\+10\.0% in 6 h/);
assert.match(f[4].value, /\+40 shards paid in 6 h/);
});
test('pulse: the fields carry the asked numbers in plain words', () => {
const { payload } = shapePulse(data(), null);
const text = payload.embeds[0].fields.map(f => `${f.name}: ${f.value}`).join('\n');
assert.match(text, /Chain block: 141,642/);
assert.match(text, /2\.56 GH\/s/);
assert.match(text, /56 vote keys active \(a card runs several\)/);
assert.match(text, /checkpoint 6,842 at \d+\.\d% of weight/);
assert.match(text, /1\.09 IGN to provers in the last 5 min/);
assert.match(text, /median n\/a/); // the fixture's median_proof_lag_s is null
assert.match(text, /0 provers in 10 min/);
assert.match(text, /ramp 17\.3%/);
});
test('pulse: Devnet 2 is numbers and the gate word only; the seed address and the state text never pass', () => {
const line = devnet2Line(fx('fleet'), NOW.getTime());
assert.equal(line, '4 boxes, height 3,848, last gate FAIL 37 min ago');
assert.equal(devnet2Line(null), null);
assert.equal(devnet2Line({ devnet2: { boxes: 2, height: 10, last_gate: 'PASS: abc', last_gate_at: NOW.toISOString() } }, NOW.getTime()), '2 boxes, height 10, last gate PASS 0 s ago');
assert.throws(() => guardText(fx('fleet').devnet2.seed), /IP address/);
});
test('pulse: finality paused puts a since line in the description, from the newest lock', () => {
const { payload } = shapePulse(paused(), null);
assert.match(payload.embeds[0].description, /\nFinality paused since 2026-10-06 18:4\d UTC: nothing is final until two thirds/);
assert.doesNotMatch(shapePulse(data(), null).payload.embeds[0].description, /Finality paused/);
});
test('pulse: a stale observer changes the first line and keeps the numbers', () => {
const d = data(); d.stats.stale = true; d.stats.age_s = 95;
const { payload } = shapePulse(d, null);
assert.match(payload.embeds[0].description, /^Observer stale for 2 min: every number is last known/);
});
test('pulse: miner version share appears only when engines are exposed', () => {
const d = data();
assert.equal(versionShare(d.live), null);
d.live.miners[0].engine = '0.3.14'; d.live.miners[1].engine = '0.3.14'; d.live.miners[2].engine = '0.3.13';
assert.equal(versionShare(d.live), '0.3.14 67%, 0.3.13 33%');
const { payload } = shapePulse(d, null);
assert.ok(payload.embeds[0].fields.some(f => f.name === 'Miner versions'));
assert.ok(payload.embeds[0].fields.length <= 8);
});
// ---------- digest and weekly ----------
test('digest: a monospace table with yesterday from the snapshot closest to 24 h ago', () => {
const cur = snapshot(data());
const yesterday = { ...cur, at: new Date(NOW.getTime() - 24 * 3600e3 - 600e3).toISOString(), height: cur.height - 80000, locked_index: cur.locked_index - 2800, paid_shards_total: cur.paid_shards_total - 400, circulating_ign: cur.circulating_ign - 450000 };
const { payload } = shapeDigest(data(), [yesterday, { ...cur, at: new Date(NOW.getTime() - 6 * 3600e3).toISOString() }]);
guardPayload(payload);
const d = payload.embeds[0].description;
assert.match(d, /```\n24 h/);
assert.match(d, /chain block\s+61,642\s+141,642/);
assert.match(d, /locks \+2,800/);
assert.match(d, /minted \+450,000 IGN/);
assert.equal(payload.embeds[0].thumbnail, undefined);
const none = shapeDigest(data(), []);
assert.match(none.payload.embeds[0].description, /No snapshot from 24 h ago yet/);
});
test('weekly: the reddit template as an embed with the ledger link and a last-week column', () => {
const first = shapeWeekly(data(), null, []);
guardPayload(first.payload);
const e = first.payload.embeds[0];
assert.equal(e.title, 'Devnet numbers, week of 5 Oct 2026');
assert.match(e.description, /Ledger: https:\/\/igneum\.network\/ledger/);
assert.match(e.description, /First weekly post/);
assert.equal(e.fields.length, 6);
const prev = { ...first.snapshot, height: 100000, locked_index: 4000, paid_shards_total: 1000 };
const second = shapeWeekly(data(), prev, ['Example incident sentence.']);
const text = second.payload.embeds[0].fields.map(f => f.value).join('\n');
assert.match(text, /height 141,642 \(last week 100,000\)/);
assert.match(text, /locks this week \+2,842/);
assert.match(text, /shards this week \+1,258/);
assert.match(text, /• Example incident sentence\./);
assert.match(text, /check\.rule_match 500 \/ 500/);
});
// ---------- the release ----------
const REL = {
version: '0.3.14',
windows: 'https://dl.igneum.network/public/igneum-miner-windows.exe', windowsSha: '08e75effc732f88bca11006b3b7cdf2bc533576f56819f79509fee47a8f5dd59',
mac: 'https://dl.igneum.network/public/igneum-miner-mac.dmg', macSha: 'e9e4c24db81a262a36b6bac11609ce6d24ed03220973da4142df935d41fc4e40',
hive: 'https://dl.igneum.network/public/igneum-miner-hive.tar.gz', hiveSha: 'c4699f2487580dcf06b5c22a36a2eb8383a3307b6137699c76c45eefebdd4c88',
nodeCommit: '4c6b129d', digest: 'b18ed271 (thirteen fields, unchanged)', now: NOW,
};
test('release: three downloads with full sha256, node commit, digest, up to five changed lines; a sha256 passes the 32-hex guard', () => {
const { payload } = shapeRelease({ ...REL, changed: ['One.', 'Two.', 'Three.', 'Four.', 'Five.', 'Six.'] });
guardPayload(payload);
const e = payload.embeds[0];
assert.equal(e.title, 'Igneum Miner 0.3.14');
assert.equal(e.fields.length, 6);
assert.match(e.fields[0].value, /\[Download\]\(https:\/\/dl\.igneum\.network\/public\/igneum-miner-windows\.exe\)\n`08e75eff[0-9a-f]{56}`/);
assert.equal((e.description.match(/^• /gm) || []).length, 5);
assert.doesNotMatch(e.description, /Six/);
});
test('release: refuses a missing value or a hash that is not sha256', () => {
assert.throws(() => shapeRelease({ ...REL, hive: '' }), /release needs hive/);
assert.throws(() => shapeRelease({ ...REL, macSha: 'abc' }), /macSha is not a sha256/);
});
test('release: changed lines from a plan section, table rows by first cell, bullets as they are, guarded lines dropped', () => {
const plan = `# Plan\n\n## 1. Why\n\nIntro.\n\n| Change | Where |\n|---|---|\n| A deep reorg reloads the newest generation; never a genesis replay | there |\n| \`exec_restart_state_root\`, the fourteenth field (block 27,276's root 0x${'e'.repeat(64)}, read on node 1 and confirmed by the fleet on both node kinds; set) | there |\n| A flag under /root was ignored silently | there |\n\n- A bullet line.\n\n## 2. Next\n\n| Not | this |\n`;
const warned = [];
const lines = changedLinesFromPlan(plan, '1. Why', 5, m => warned.push(m));
assert.deepEqual(lines, ['A deep reorg reloads the newest generation', 'exec_restart_state_root, the fourteenth field', 'A bullet line.']);
assert.equal(warned.length, 1);
assert.match(warned[0], /internal path/);
assert.throws(() => changedLinesFromPlan(plan, 'Missing'), /section not found/);
});
test('plainLine: strips code ticks, cuts at the first semicolon, drops a long parenthetical, never cuts mid-word', () => {
assert.equal(plainLine('`A thing` that **reloads** the generation; never a replay'), 'A thing that reloads the generation');
const long = `Start of a long line that goes on (${'x'.repeat(200)}) and ends here with more words`;
assert.equal(plainLine(long), 'Start of a long line that goes on and ends here with more words');
const words = Array.from({ length: 60 }, (_, i) => `word${i}`).join(' ');
const cut = plainLine(words);
assert.ok(cut.length <= 160);
assert.ok(words.startsWith(cut));
assert.ok(!cut.endsWith(' '));
});
// ---------- incidents ----------
test('incident open and resolve: UTC times, the three answers, the duration', () => {
const open = shapeIncidentOpen({ id: 'inc-1', at: new Date('2026-10-06T15:44:00Z'), what: 'What happened.', affected: 'Miners: fine. Provers: paused.', doing: 'Replaying.', now: NOW });
guardPayload(open.payload);
assert.equal(open.payload.embeds[0].title, 'Incident open');
assert.equal(open.payload.embeds[0].fields[0].value, '2026-10-06 15:44 UTC');
const res = shapeIncidentResolve({ id: 'inc-1', openedAt: '2026-10-06T15:44:00Z', at: new Date('2026-10-06T17:10:00Z'), cause: 'A cause.', fix: 'A rule.', what: 'What happened.', now: NOW });
guardPayload(res.payload);
assert.equal(res.payload.embeds[0].title, 'Incident resolved');
assert.equal(res.payload.embeds[0].fields[1].value, '1 h 26 min');
assert.throws(() => shapeIncidentOpen({ id: 'x', what: 'w', affected: '', doing: 'd' }), /needs affected/);
});
// ---------- the watcher ----------
const at = (base, s) => new Date(base.getTime() + s * 1000);
const paused = () => { const d = data(); d.stats.finality.active = false; return d; };
// clear data at +s seconds: finality active and the lock index advancing one checkpoint per 30 s (an unchanged index reads as paused)
const advancing = s => { const d = data(); const idx = 6842 + Math.floor(s / 30); d.stats.finality.latest_locked_index = idx; d.live.finality.latest_locked_index = idx; return d; };
test('watcher: a condition already firing at the first look is pre-existing: never opened, cleared silently; the next start opens', () => {
const state = { watch: {} };
assert.deepEqual(watchPass(paused(), state, NOW), [], 'first look: paused already, nothing observed to start');
assert.ok(state.watch.finality_paused.preexisting);
assert.deepEqual(watchPass(paused(), state, at(NOW, 900)), [], 'held 15 min, still nothing: no open to match a resolve');
assert.deepEqual(watchPass(advancing(1200), state, at(NOW, 1200)), [], 'clears silently');
assert.equal(state.watch.finality_paused.preexisting, null);
assert.deepEqual(watchPass(paused(), state, at(NOW, 1300)), [], 'a start the watcher saw: the hold begins');
const a = watchPass(paused(), state, at(NOW, 1300 + WATCH.finalityPausedS + 1));
assert.equal(a.length, 1); assert.equal(a[0].kind, 'open');
});
test('watcher: finality paused opens after five minutes, once, and resolves after two clear minutes', () => {
const state = { watch: {} };
assert.deepEqual(watchPass(data(), state, at(NOW, -60)), [], 'the first look is clear');
assert.deepEqual(watchPass(paused(), state, NOW), []);
assert.deepEqual(watchPass(paused(), state, at(NOW, 240)), []);
const a = watchPass(paused(), state, at(NOW, 301));
assert.equal(a.length, 1); assert.equal(a[0].kind, 'open'); assert.equal(a[0].key, 'finality_paused');
assert.match(a[0].what, /Finality paused/);
assert.match(a[0].affected, /Miners keep mining/);
assert.deepEqual(watchPass(paused(), state, at(NOW, 600)), [], 'no second open while one is open');
assert.deepEqual(watchPass(advancing(700), state, at(NOW, 700)), [], 'clear must hold');
const r = watchPass(advancing(700 + WATCH.clearHoldS), state, at(NOW, 700 + WATCH.clearHoldS));
assert.equal(r.length, 1); assert.equal(r[0].kind, 'resolve'); assert.equal(r[0].id, a[0].id);
assert.equal(state.watch.finality_paused.open, null);
});
test('watcher: a lock index that does not move for five minutes counts as paused even with active true', () => {
const state = { watch: {} };
watchPass(data(), state, NOW);
assert.deepEqual(watchPass(data(), state, at(NOW, 299)), []);
const a = watchPass(data(), state, at(NOW, 301));
// the hold starts when the stuck test first fires (at 301 s), so the open comes one threshold later
assert.deepEqual(a, []);
const b = watchPass(data(), state, at(NOW, 301 + WATCH.finalityPausedS));
assert.equal(b.length, 1); assert.match(b[0].what, /no new lock/);
});
test('watcher: proof lag over 15 minutes opens at once; observer silence needs three minutes; a failed fetch counts as silence', () => {
const state = { watch: {} };
watchPass(data(), state, at(NOW, -60));
const lag = data(); lag.live.proving.median_proof_lag_s = 1000;
const a = watchPass(lag, state, NOW);
assert.equal(a.length, 1); assert.equal(a[0].key, 'proof_lag'); assert.match(a[0].what, /17 min against a target under 60 s/);
const s2 = { watch: {} };
watchPass(data(), s2, at(NOW, -60));
const failed = { stats: { stale: true, age_s: null, finality: null }, live: {}, supply: {}, now: NOW, fetchFailed: true };
assert.deepEqual(watchPass(failed, s2, NOW), []);
const b = watchPass(failed, s2, at(NOW, WATCH.observerSilentS + 1));
assert.equal(b.length, 1); assert.equal(b[0].key, 'observer_silent'); assert.match(b[0].what, /did not answer/);
});
test('watcher: every automatic text passes the guard', () => {
for (const c of Object.values(WATCH_CONDITIONS)) {
const d = data(); d.stats.age_s = 200; d.live.proving.median_proof_lag_s = 1000;
guardText([c.what(d), c.affected, c.doing, c.cause, c.fix].join('\n'));
}
});
// ---------- guards and limits ----------
test('guard: refuses the founder, hosts, machine ids, paths, IPs, 32-hex tokens, token download paths, webhook URLs, mentions', () => {
const bad = {
'the project lead fixed it': /founder/, 'by [user] at night': /founder/, 'on Hetzner': /hosting/, 'RunPod box': /hosting/, 'host igneum-build-1': /host name/,
'from DESKTOP-ABC1234': /machine id/, 'target ae432dc7': /machine id/, 'box p2-3090-4': /machine id/,
'seed 213.173.110.229:11927': /IP address/, 'see /Users/x/file': /internal path/, 'under /srv/discord-hooks/env': /internal path/, 'in C:\\Users': /internal path/,
[`token ${'a1'.repeat(16)}`]: /32-hex/, 'https://dl.igneum.network/fleet-22adafa34bc2/fleet.json': /outside \/public/, 'https://dl.igneum.network/dl/abc/x.exe': /outside \/public/,
'https://discord.com/api/webhooks/1/x': /webhook URL/, 'hello @everyone': /mention/, 'ping <@123>': /mention/, 'at 19:00 BST': /internal reference/, 'on the MacBook': /forbidden-strings/, 'box.ts.net': /forbidden-strings/, 'LOG_INTAKE_KEY=x': /forbidden-strings/,
};
for (const [text, why] of Object.entries(bad)) assert.throws(() => guardText(text), why, text);
});
test('guard: passes a sha256, a public download URL, an igneum.network link, a 16-hex checkpoint hash and plain words', () => {
guardText(`sha ${'ab'.repeat(32)} public https://dl.igneum.network/public/igneum-miner-mac.dmg live https://igneum.network/live hash a56a00dfb657036d 4 boxes, height 3,848`);
});
test('limits: more than eight fields, a value over 1,024, a total over 6,000, a stray mention setting are refused', () => {
const big = embed({ title: 'T', fields: Array.from({ length: 9 }, (_, i) => ({ name: `f${i}`, value: 'v' })), now: NOW });
assert.equal(big.embeds[0].fields.length, 8, 'embed() itself caps at eight');
big.embeds[0].fields.push({ name: 'f9', value: 'v' });
assert.throws(() => guardPayload(big), /more than 8 fields/);
const long = embed({ title: 'T', fields: [{ name: 'f', value: 'x'.repeat(1025) }], now: NOW });
assert.throws(() => guardPayload(long), /field value over 1024/);
const total = embed({ title: 'T', description: 'd'.repeat(4000), fields: [{ name: 'a', value: 'y'.repeat(1024) }, { name: 'b', value: 'y'.repeat(1024) }], now: NOW });
assert.ok(embedLength(total.embeds[0]) > LIMITS.total);
assert.throws(() => guardPayload(total), /embed over 6000/);
const mention = embed({ title: 'T', fields: [{ name: 'a', value: 'b' }], now: NOW }); mention.allowed_mentions = { parse: ['everyone'] };
assert.throws(() => guardPayload(mention), /allowed_mentions/);
});
// ---------- idempotency, dry run, backoff ----------
test('poster: dry run writes the payload and the preview, a rerun under the same key is skipped, --force posts again', async () => {
const out = tmpDir();
const p = new Poster({ live: false, outDir: out, log: () => {} });
const { payload } = shapePulse(data(), null);
const r1 = await p.post('numbers', 'pulse:test', payload);
assert.equal(r1.skipped, false);
assert.ok(fs.existsSync(path.join(out, 'pulse_test.json')));
assert.ok(fs.existsSync(path.join(out, 'preview.html')));
const r2 = await p.post('numbers', 'pulse:test', payload);
assert.equal(r2.skipped, true);
const f = new Poster({ live: false, outDir: out, force: true, log: () => {} });
const r3 = await f.post('numbers', 'pulse:test', payload);
assert.equal(r3.skipped, false);
const html = fs.readFileSync(path.join(out, 'preview.html'), 'utf8');
assert.match(html, /Network pulse/);
assert.match(html, /#numbers/);
});
test('poster: live mode without a credentials file refuses to start, and never prints a URL', () => {
assert.throws(() => new Poster({ live: true, credFile: path.join(tmpDir(), 'none'), outDir: tmpDir() }), /no credentials file/);
});
test('poster: live mode posts once, stores the message id, skips the rerun; the state is written only after success', async () => {
const dir = tmpDir();
const cred = path.join(dir, 'discord'); fs.writeFileSync(cred, 'DISCORD_WEBHOOK_NUMBERS=https://discord.com/api/webhooks/1/secret\n', { mode: 0o600 });
const calls = [];
const fetchImpl = async (url, init) => { calls.push({ url, body: JSON.parse(init.body) }); return { ok: true, status: 200, headers: new Map(), json: async () => ({ id: '42', channel_id: '7' }) }; };
const p = new Poster({ live: true, credFile: cred, stateFile: path.join(dir, 'state.json'), outDir: dir, log: () => {}, fetchImpl });
const { payload } = shapePulse(data(), null);
const r = await p.post('numbers', 'pulse:live', payload);
assert.equal(r.posted, true); assert.equal(r.id, '42');
assert.equal(calls.length, 1); assert.match(calls[0].url, /\?wait=true$/);
assert.deepEqual(calls[0].body.allowed_mentions, { parse: [] });
const saved = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
assert.equal(saved.posts['pulse:live'].id, '42');
const again = new Poster({ live: true, credFile: cred, stateFile: path.join(dir, 'state.json'), outDir: dir, log: () => {}, fetchImpl });
assert.equal((await again.post('numbers', 'pulse:live', payload)).skipped, true);
assert.equal(calls.length, 1);
await assert.rejects(() => p.post('incidents', 'inc:x', payload), /no DISCORD_WEBHOOK_INCIDENTS/);
});
test('backoff: a 429 waits retry_after then doubles; success returns the id; six failures give up', async () => {
const sleeps = [];
let n = 0;
const fetchImpl = async () => { n++; if (n <= 2) return { ok: false, status: 429, headers: new Map([['retry-after', '2']]), json: async () => ({ retry_after: 2.5 }) }; return { ok: true, status: 200, headers: new Map(), json: async () => ({ id: '9' }) }; };
const r = await postWebhook('https://example.invalid/hook', { embeds: [] }, { fetchImpl, sleepImpl: async ms => sleeps.push(ms) });
assert.equal(r.id, '9'); assert.equal(n, 3);
assert.deepEqual(sleeps, [2500, 2500]);
const always = async () => ({ ok: false, status: 429, headers: new Map(), json: async () => ({}) });
const s2 = [];
await assert.rejects(() => postWebhook('https://example.invalid/hook', {}, { fetchImpl: always, sleepImpl: async ms => s2.push(ms) }), /after 6 tries/);
assert.deepEqual(s2, [1000, 2000, 4000, 8000, 16000]);
const bad = async () => ({ ok: false, status: 400, headers: new Map(), json: async () => ({}) });
await assert.rejects(() => postWebhook('https://example.invalid/hook', {}, { fetchImpl: bad }), /answered 400/);
});
// ---------- the scheduler and the clock ----------
test('scheduler: Monday 09:xx UK is pulse + digest + weekly, each once; other hours only the pulse slots; nothing twice', () => {
const monday9 = new Date('2026-10-12T08:05:00Z'); // 09:05 UK in BST
assert.equal(londonParts(monday9).weekday, 'Mon'); assert.equal(londonParts(monday9).hour, 9);
const due = dueNow(monday9, { posts: {} });
assert.deepEqual(due.map(d => d.kind), ['pulse', 'digest', 'weekly']);
assert.equal(due[0].key, 'pulse:2026-10-12:09');
const posted = { posts: Object.fromEntries(due.map(d => [d.key, { id: '1' }])) };
assert.deepEqual(dueNow(monday9, posted), []);
assert.deepEqual(dueNow(new Date('2026-10-13T09:30:00Z'), { posts: {} }), [], '10:30 UK is not a slot');
assert.deepEqual(dueNow(new Date('2026-10-13T20:59:00Z'), { posts: {} }).map(d => d.key), ['pulse:2026-10-13:21']);
assert.deepEqual(dueNow(new Date('2026-10-13T02:00:00Z'), { posts: {} }).map(d => d.key), ['pulse:2026-10-13:03']);
});
test('clock: the footer carries UK time with UTC in brackets, in summer and in winter', () => {
assert.equal(ukStamp(new Date('2026-10-06T18:51:00Z')), '6 Oct 2026, 19:51 UK (18:51 UTC)');
assert.equal(ukStamp(new Date('2026-11-06T18:51:00Z')), '6 Nov 2026, 18:51 UK (18:51 UTC)');
assert.equal(ukStamp(new Date('2026-10-25T00:30:00Z')), '25 Oct 2026, 01:30 UK (00:30 UTC)', 'the clocks go back at 01:00 UTC on 25 Oct 2026');
});
test('formatting: hash units, durations, deltas with signs', () => {
assert.equal(fmtHash(2562646398), '2.56 GH/s'); assert.equal(fmtHash(169e6), '169.0 MH/s'); assert.equal(fmtHash(null), 'n/a');
assert.equal(fmtDur(45), '45 s'); assert.equal(fmtDur(406.5), '7 min'); assert.equal(fmtDur(5160), '1 h 26 min'); assert.equal(fmtDur(3 * 86400), '3 days');
assert.equal(fmtDelta(10, 4), '+6'); assert.equal(fmtDelta(4, 10), '−6'); assert.equal(fmtDelta(4, 4), '±0'); assert.equal(fmtDelta(null, 4), 'n/a');
assert.equal(fmtChangePct(110, 100), '+10.0%'); assert.equal(fmtChangePct(90, 100), '−10.0%');
});
test('preview: renders every item as a Discord-like card with the channel and the length', () => {
const items = [{ channel: 'numbers', key: 'pulse:x', payload: shapePulse(data(), null).payload }, { channel: 'announcements', key: 'release:0.3.14', payload: shapeRelease({ ...REL, changed: ['One.'] }).payload }];
const html = renderPreview(items);
assert.equal((html.match(/class="msg"/g) || []).length, 2);
assert.match(html, /#announcements/);
assert.match(html, /<a href="https:\/\/dl\.igneum\.network\/public\/igneum-miner-windows\.exe">Download<\/a>/);
assert.match(html, /border-left-color:#f2541b/);
assert.doesNotMatch(html, /<script/);
});

View file

@ -0,0 +1,14 @@
{
"devnet2": {
"boxes": 4,
"seed": "203.0.113.5:11927",
"updated_at": "2026-10-06T18:21:51Z",
"height": 3848,
"daa": 3848,
"peers": 3,
"state": "paused for the class v4 rehearsal (the rehearsal chain on the same boxes from 18:22Z); the 10 bps run follows",
"last_gate": "FAIL: d6350586fe837b1f current object, 4 boxes, 8 min",
"last_gate_at": "2026-10-06T18:07:26Z",
"note_at": "2026-10-06T18:23:47Z"
}
}

View file

@ -0,0 +1,648 @@
{
"ok": true,
"now": "2026-10-06T18:44:47.559Z",
"state": {
"stale": false,
"age_s": 1.8,
"network": "igneum-devnet",
"node_version": "2.1.0",
"height": 141642,
"block_count": 120745,
"header_count": 120745,
"blue_score": 205566,
"difficulty": 1247142070.3541756,
"hashes_per_second_estimate": 2562646398,
"peers": 5,
"mempool": 0,
"blocks_60s": 42,
"blocks_per_second_60s": 0.7,
"blocks_per_minute": [
[
1791308700000,
85
],
[
1791308760000,
70
],
[
1791308820000,
58
],
[
1791308880000,
58
],
[
1791308940000,
79
],
[
1791309000000,
68
],
[
1791309060000,
68
],
[
1791309120000,
84
],
[
1791309180000,
70
],
[
1791309240000,
59
],
[
1791309300000,
62
],
[
1791309360000,
61
],
[
1791309420000,
64
],
[
1791309480000,
61
],
[
1791309540000,
59
],
[
1791309600000,
46
],
[
1791309660000,
61
],
[
1791309720000,
66
],
[
1791309780000,
51
],
[
1791309840000,
49
],
[
1791309900000,
74
],
[
1791309960000,
66
],
[
1791310020000,
56
],
[
1791310080000,
62
],
[
1791310140000,
62
],
[
1791310200000,
69
],
[
1791310260000,
68
],
[
1791310320000,
52
],
[
1791310380000,
50
],
[
1791310440000,
58
],
[
1791310500000,
70
],
[
1791310560000,
53
],
[
1791310620000,
63
],
[
1791310680000,
64
],
[
1791310740000,
57
],
[
1791310800000,
51
],
[
1791310860000,
55
],
[
1791310920000,
58
],
[
1791310980000,
52
],
[
1791311040000,
69
],
[
1791311100000,
26
],
[
1791311160000,
73
],
[
1791311220000,
85
],
[
1791311280000,
70
],
[
1791311340000,
54
],
[
1791311400000,
26
],
[
1791311460000,
0
],
[
1791311520000,
0
],
[
1791311580000,
0
],
[
1791311640000,
0
],
[
1791311700000,
0
],
[
1791311760000,
0
],
[
1791311820000,
0
],
[
1791311880000,
0
],
[
1791311940000,
0
],
[
1791312000000,
0
],
[
1791312060000,
0
],
[
1791312120000,
51
],
[
1791312180000,
44
],
[
1791312240000,
36
]
],
"miners_10m": 56,
"observer_started_at": "2026-10-06T18:07:48.726+00:00",
"observer_lag_s": 2.7,
"queue_depth": 0,
"updated_at": "2026-10-06T18:44:45.798072+00:00"
},
"blocks": [
{
"hash": "7bb5494ec823edc0",
"blue_score": 205563,
"daa": 209503,
"ts": 1791312277706,
"rx": 1791312279704,
"parents": [
"ac1a743dd856fdee",
"634dcf57198e9421"
],
"chain": true,
"miner": "4f8e0ec8",
"color": "blue",
"locked": false,
"final": false,
"shards": [
{
"i": 0,
"n": 1,
"state": "paid",
"prover": "abcd1234",
"lag": 120,
"payout": 1.0948,
"pgas": 7500000
}
],
"proven": false
},
{
"hash": "a86c2008ed97f170",
"blue_score": 205564,
"daa": 209504,
"ts": 1791312279313,
"rx": 1791312281208,
"parents": [
"7bb5494ec823edc0"
],
"chain": true,
"miner": "b6499bc1",
"color": "blue",
"locked": false,
"final": false,
"shards": [
{
"i": 0,
"n": 1,
"state": "planned",
"prover": null,
"lag": null,
"payout": null,
"pgas": 0
}
],
"proven": false
},
{
"hash": "0888c63dc1955f86",
"blue_score": 205565,
"daa": 209505,
"ts": 1791312280876,
"rx": 1791312283218,
"parents": [
"a86c2008ed97f170"
],
"chain": true,
"miner": "bc7bf267",
"color": "blue",
"locked": false,
"final": false,
"shards": [
{
"i": 0,
"n": 1,
"state": "planned",
"prover": null,
"lag": null,
"payout": null,
"pgas": 0
}
],
"proven": false
},
{
"hash": "c6b1ade00a11b9cc",
"blue_score": 205566,
"daa": 209506,
"ts": 1791312282865,
"rx": 1791312284213,
"parents": [
"0888c63dc1955f86"
],
"chain": true,
"miner": "e2ffade0",
"color": "blue",
"locked": false,
"final": false,
"shards": [
{
"i": 0,
"n": 1,
"state": "planned",
"prover": null,
"lag": null,
"payout": null,
"pgas": 0
}
],
"proven": false
},
{
"hash": "d45fcfe51d5f44e9",
"blue_score": 205565,
"daa": 209505,
"ts": 1791312281863,
"rx": 1791312284716,
"parents": [
"a86c2008ed97f170"
],
"chain": false,
"miner": "4f342bdd",
"color": "blue",
"locked": false,
"final": false,
"shards": [],
"proven": false
},
{
"hash": "6b02375e3ed60d3c",
"blue_score": 205568,
"daa": 209508,
"ts": 1791312285474,
"rx": 1791312286741,
"parents": [
"c6b1ade00a11b9cc",
"d45fcfe51d5f44e9"
],
"chain": true,
"miner": "9fe4048a",
"color": "pending",
"locked": false,
"final": false,
"shards": [
{
"i": 0,
"n": 1,
"state": "planned",
"prover": null,
"lag": null,
"payout": null,
"pgas": 0
}
],
"proven": false
}
],
"miners": [
{
"id": "b6499bc1",
"blocks": 6,
"share": 4.5,
"last_seen": "2026-10-06 18:44:41.208363+00",
"engine": null
},
{
"id": "4f8e0ec8",
"blocks": 5,
"share": 3.8,
"last_seen": "2026-10-06 18:44:39.704897+00",
"engine": null
},
{
"id": "641334f9",
"blocks": 5,
"share": 3.8,
"last_seen": "2026-10-06 18:44:28.181243+00",
"engine": null
},
{
"id": "0ccfff9e",
"blocks": 5,
"share": 3.8,
"last_seen": "2026-10-06 18:44:18.667566+00",
"engine": null
},
{
"id": "3e60c2aa",
"blocks": 5,
"share": 3.8,
"last_seen": "2026-10-06 18:43:03.050563+00",
"engine": null
}
],
"events": [
{
"ts": "2026-10-06T18:44:44.51572+00:00",
"kind": "miner_back",
"text": "Miner 4f342bdd is back after a quiet spell"
},
{
"ts": "2026-10-06T18:44:42.969382+00:00",
"kind": "miner_back",
"text": "Miner bc7bf267 is back after a quiet spell"
},
{
"ts": "2026-10-06T18:44:28.304782+00:00",
"kind": "miner_back",
"text": "Miner 83271dfd is back after a quiet spell"
}
],
"finality": {
"supported": true,
"active": true,
"next_index": 6852,
"latest_locked_index": 6842,
"latest_locked_hash": "a56a00dfb657036d",
"latest_locked_blue_score": 205262,
"params": {
"dust": 5,
"minDaa": 7200,
"aggregators": 8,
"weightWindow": 7200,
"presenceWindow": 20,
"checkpointDepth": 20,
"equivocationBan": 7200,
"checkpointInterval": 30
},
"weights": {
"keys": [
{
"id": "d5996917",
"voter": true,
"blocks": 248,
"revealed": true,
"participation": 0.6,
"stripped_until_daa": 0
},
{
"id": "39d2dafc",
"voter": true,
"blocks": 246,
"revealed": true,
"participation": 0.6,
"stripped_until_daa": 0
},
{
"id": "52d9d8c8",
"voter": true,
"blocks": 233,
"revealed": true,
"participation": 0.6,
"stripped_until_daa": 0
}
],
"voters": 93,
"daa_score": 209470,
"total_weight": 7090,
"active_weight": 5020.2,
"checkpoint_hash": "2d2b92663a96dc6e7c14e411169a16fd37a96094050b3c981a04667e1cdd1b39",
"checkpoint_index": 6851
},
"checkpoints": [
{
"index": 6842,
"hash": "a56a00dfb657036d",
"blue_score": 205262,
"daa": 209200,
"state": "locked",
"signed": 5650,
"active": 5100,
"total": 7088,
"fraction_active": 0.9,
"fraction_total": 0.797,
"votes": 91,
"voters": 91,
"locked_at": "2026-10-06T18:42:10.000+00:00",
"updated_at": "2026-10-06 18:42:10+00"
},
{
"index": 6846,
"hash": "71b7cd0410fe86c3",
"blue_score": 205380,
"daa": 209320,
"state": "proposed",
"signed": 3825,
"active": 5408,
"total": 7083,
"fraction_active": 0.7072855029585798,
"fraction_total": 0.5400254129606099,
"votes": 75,
"voters": 92,
"locked_at": null,
"updated_at": "2026-10-06 18:44:45.663789+00"
},
{
"index": 6847,
"hash": "ba5f9b11b9f612ee",
"blue_score": 205410,
"daa": 209350,
"state": "proposed",
"signed": 3849,
"active": 5329,
"total": 7086,
"fraction_active": 0.722274347907675,
"fraction_total": 0.5431837425910245,
"votes": 75,
"voters": 93,
"locked_at": null,
"updated_at": "2026-10-06 18:44:45.688084+00"
},
{
"index": 6848,
"hash": "dab231fba7808515",
"blue_score": 205440,
"daa": 209380,
"state": "proposed",
"signed": 3870,
"active": 5250,
"total": 7088,
"fraction_active": 0.7371428571428571,
"fraction_total": 0.5459932279909706,
"votes": 75,
"voters": 93,
"locked_at": null,
"updated_at": "2026-10-06 18:44:45.705592+00"
},
{
"index": 6849,
"hash": "8134dff377a12749",
"blue_score": 205471,
"daa": 209411,
"state": "proposed",
"signed": 3893,
"active": 5170,
"total": 7088,
"fraction_active": 0.7529980657640232,
"fraction_total": 0.5492381489841986,
"votes": 75,
"voters": 93,
"locked_at": null,
"updated_at": "2026-10-06 18:44:45.723747+00"
},
{
"index": 6850,
"hash": "b3a6d46ef7143d01",
"blue_score": 205500,
"daa": 209440,
"state": "proposed",
"signed": 3913,
"active": 5092,
"total": 7083,
"fraction_active": 0.7684603299293009,
"fraction_total": 0.5524495270365665,
"votes": 75,
"voters": 93,
"locked_at": null,
"updated_at": "2026-10-06 18:44:45.739213+00"
}
]
},
"proving": {
"supported": true,
"active": true,
"activation_daa": 84100,
"tip_daa": 209506,
"verifier": "Off",
"pool": {
"failed": 0,
"entries": 0,
"pending": 0,
"verified": 0
},
"paid_shards_total": 2258,
"shard_budget_pgas": 7500000,
"blocks_10m": 87,
"blocks_fully_proven_10m": 0,
"shards_proven_10m": 0,
"shards_paid_10m": 0,
"median_proof_lag_s": null,
"provers_10m": 0
}
}

View file

@ -0,0 +1,58 @@
{
"ok": true,
"now": "2026-10-06T18:44:46.965Z",
"network": "igneum-devnet",
"chain_id": 4463,
"node_version": "2.1.0",
"algorithm": "Igneum lottery hash: random-program GPU hash, new program every hour, generator v2 (docs/spec/01-lottery-hash.md)",
"stale": false,
"age_s": 1.2,
"height": 141642,
"block_count": 120745,
"header_count": 120745,
"daa": 209508,
"blue_score": 205566,
"difficulty": 1247142070.3541756,
"hashrate": 2562646398,
"hashrate_unit": "H/s",
"hashrate_source": "the node's estimateNetworkHashesPerSecond over a 1,000-block window; blue work added per second over 10 min when the node refuses the window",
"block_time_target_s": 1,
"block_time_measured_s": 1.429,
"blocks_per_day_target": 86400,
"blocks_per_day_measured": 70872,
"block_reward": {
"sompi": "547398513",
"ign": "5.47398513",
"miner_ign": "4.37918811",
"proving_pool_ign": "1.09479702",
"split": "80% block producer, 20% proving pool",
"daa_used": 209508,
"ramp_factor": 0.172746,
"halving_period": 0,
"next_halving_daa": 63115200,
"next_halving_in_s": 62905692
},
"last_block": {
"hash": "6b02375e3ed60d3c896d354d77dc76d3706cbe055bbd8bcda5a4087160132718",
"time": "2026-10-06T18:44:45.474Z",
"ts_ms": 1791312285474,
"age_s": 1.5,
"blue_score": 205568,
"daa": 209508,
"miner": "igneumdev:qqrhqjeufh77cd50tdyycnq80vdwgp6av0nd5knkatazfc0mg50gj4ca46t8c",
"miner_id": "9fe4048a",
"tx_count": 0,
"chain": true
},
"finality": {
"active": true,
"latest_locked_index": 6842,
"latest_locked_blue_score": 205262,
"chain_id": "igneum-devnet"
},
"peers": 5,
"mempool": 0,
"miners_10m": 56,
"observer_updated_at": "2026-10-06T18:44:45.798072+00:00",
"source": "tools/observer reading one node every 2 s; reward from docs/spec/02-consensus.md 2.5 at the node's DAA score"
}

View file

@ -0,0 +1,75 @@
{
"ok": true,
"now": "2026-10-06T18:44:48.217Z",
"network": "igneum-devnet",
"chain_id": 4463,
"unit": {
"symbol": "IGN",
"decimals_consensus": 8,
"decimals_evm": 18,
"note": "the coinbase pays in 8-decimal units (open item O-2.6 keeps Kaspa's SOMPI_PER_KASPA); the EVM shows the same amount at 18 decimals"
},
"daa": 209508,
"max_supply_ign": "4000000000",
"circulating_ign": "905366.08029103",
"circulating_sompi": "90536608029103",
"minted_at_end_ign": "3963038988.86765648",
"never_minted_ign": "36961011.13234352",
"emission_per_second_ign": "5.47398513",
"block_reward_ign": "5.47398513",
"halving": {
"interval_daa_s": 63115200,
"interval_years": 2,
"period": 0,
"next_halving_daa": 63115200,
"next_halving_in_s": 62905692,
"next_halving_estimate": "2028-10-03T20:33:00.217Z",
"estimate_note": "the estimate assumes one DAA second per wall-clock second from now"
},
"ramp": {
"start_percent": 10,
"length_daa_s": 2592000,
"length_days": 30,
"factor_now": 0.172746,
"complete": false,
"remaining_s": 2382492,
"withheld_ign": "36961011.13234352",
"withheld_note": "the ramp withholds about 37 million IGN that are never minted; integer floors withhold the rest (spec 2.5)"
},
"schedule": [
{
"period": 0,
"start_daa": 0,
"end_daa": 63115200,
"years_from_genesis": "0 to 2",
"per_second_ign": "31.68808781",
"per_block_ign_at_1bps": "31.68808781",
"minted_by_end_ign": "1963038999.85152848",
"share_of_cap_by_end": 49.0759
},
{
"period": 1,
"start_daa": 63115200,
"end_daa": 126230400,
"years_from_genesis": "2 to 4",
"per_second_ign": "15.8440439",
"per_block_ign_at_1bps": "15.8440439",
"minted_by_end_ign": "2963038999.40880848",
"share_of_cap_by_end": 74.0759
}
],
"check": {
"bps": 1,
"sampled": 500,
"examples": [],
"sum_match": 498,
"checked_at": "2026-10-06T18:08:11.206Z",
"rule_match": 500,
"sum_skipped": 2,
"sum_mismatch": 0,
"rule_mismatch": 0
},
"rule": "E(t) = ramp(t) * floor(10^9 * UNIT / 31,557,600) >> floor(t / 63,115,200); ramp(t) = min(1, 1/10 + 9/10 * t / 2,592,000); t = DAA score / bps, bps = 1",
"source": "docs/spec/02-consensus.md 2.5; vendor/igneum-node consensus/core/src/igneum.rs block_subsidy and launch_ramp; circulating = the rule summed over every DAA second from 0 to the node's DAA score (site/lib/emission.mjs mintedByRule, exact)",
"note": "circulating is the schedule at this DAA score. The chain pays E per blue block it merges and per red inside the DAA window, which tracks the schedule one block per DAA step; the devnet of 3 October 2026 ran 4.7x the schedule for eight minutes during a retarget lag (spec 2.5). check reports what the observer measured on the newest blocks."
}