Public testnet launch, the public side: dl/public downloads path, site download buttons, HiveOS package, faucet

Downloads: packaging/ota/publish-public.sh publishes the current installers, the HiveOS package and the two signed
manifests into dl/public/ with no token in any URL, writes the four /public/ aliases as vercel.json rewrites and an
unsigned index for the site; publish-manifest.sh --public and ship-app.mjs --public run it on every release (dry run
and self-test cover it). Nothing removed from the token folders.

Site: the miner and wallet buttons link the public aliases and show the version and size from the index, read at
build time (site/downloads.json is the offline snapshot); TESTNET_OPEN in build.mjs drops the "Public testnet: not yet
open" line on the go; the HiveOS Flight Sheet install line on the miner page; /faucet page.

HiveOS: igneum-hive-0.3.8.tar.gz from the 0.3.8 node (2b6d23ef, PC build job) and the zig-built Linux workers.

Faucet: site/api/faucet.mjs (10 IGN per address and per IP per day, Neon table faucet_grants, EIP-1559 transfer signed
by site/lib/eth.mjs with no dependencies: keccak, RLP, secp256k1 with RFC 6979), FAUCET_KEY and FAUCET_RPC from the
Vercel env only; 15 unit tests with a fake database and node, run in CI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-josh 2026-10-05 16:59:07 +01:00
parent 23d11d5c5e
commit ba66ca28fc
22 changed files with 1138 additions and 22 deletions

View file

@ -65,6 +65,10 @@ jobs:
run: bash tools/ci/copied-sources-check.sh
- name: pinned guest programs match their manifest and are built only by pin-guests.sh
run: bash tools/ci/pinned-guests-check.sh
- name: faucet unit tests (validation, the daily limits, the signed transaction; keccak, RLP and secp256k1 vectors)
run: node --test site/api/faucet.test.mjs
- name: ship tool self-test (version bump, the dl-both and public manifest helpers)
run: node tools/ship-app.mjs --self-test
- name: relay unit tests (parsers, secret compare, the wake endpoint)
run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs
- name: miner app notice strip and update card (ordering, keys, wording, timers, when the card shows)

View file

@ -42,6 +42,28 @@ The six version files: `app/igneum-app/Cargo.toml`, `app/igneum-app/Cargo.lock`,
| `--min-supported`, `--activation-height`, `--deadline-note`, `--channel` | passed to `publish-manifest.sh` |
| `--rebuild` | build the DMG again even when a fresh one exists |
| `--branch <name>` | accept another branch than master (a dry run from a worktree; the Windows build still runs on pushes to master) |
| `--public` | the manifest step also publishes the version into `dl/public/` (no token in any URL; its own signed manifest; the `/public/` aliases rewritten), the same deploy carries it, verify checks every public file and alias. The token folders are untouched |
## The public downloads path (5 October 2026, testnet launch)
`dl/public/` in the downloads folder holds only the current installers, the HiveOS package and the two signed manifests
(app and wallet) with URLs under `https://dl.igneum.network/dl/public/`, plus an unsigned index `igneum-downloads.json`
that the site build reads. Four stable aliases are rewrites in the downloads folder's `vercel.json`, written from what the
folder holds, so they follow every release by themselves:
| Alias | Rewrites to |
|---|---|
| `https://dl.igneum.network/public/igneum-miner-windows.exe` | `dl/public/Igneum-Miner-Setup-<v>.exe` |
| `https://dl.igneum.network/public/igneum-miner-mac.dmg` | `dl/public/Igneum-Miner-<v>.dmg` |
| `https://dl.igneum.network/public/igneum-miner-hive.tar.gz` | `dl/public/igneum-hive-<v>.tar.gz` |
| `https://dl.igneum.network/public/igneum-wallet-mac.dmg` | `dl/public/Igneum-Wallet-<v>.dmg` |
`packaging/ota/publish-public.sh --app | --wallet | --hive <tar.gz> [--deploy] [--verify] [--dry-run]` does the work;
`publish-manifest.sh --public` and `ship-app.mjs --public` call it, so every future release lands there too. The
HiveOS package comes from `packaging/hive/make-hive-package.sh` (Linux node and miner from a PC `build` job, the two GPU
workers from `infra/cross/build-workers-linux.sh`) and is published with `--hive`. The site (`site/build.mjs`) reads the
index at build time and stamps every download button with the version and size; `TESTNET_OPEN` there removes the
"Public testnet: not yet open" line on the go.
## When a step fails

View file

@ -11,6 +11,9 @@
# consensus.override: the exact object every app writes to its override.json (the node's
# --override-params-file), so it carries EVERY height switch, not just the new one;
# carried over from the current manifest when not given
# [--public] also publish into dl/public/ (no token: the site's download
# links, packaging/ota/publish-public.sh --app, plus --wallet
# when the wallet manifest is in the folder); the same deploy
# [--tuning tuning.json | --no-tuning] the fleet's per-card kernel tuning (tools/tuning.mjs writes it;
# docs/design/miner-tuning.md); carried over from the current
# manifest when not given, as is consensus.override
@ -36,7 +39,7 @@ TOKEN_FILE="$HOME/.config/igneum/dl-token"
SIGNER="$ROOT/app/igneum-app/target/release/igneum-ota-sign"
VERSION="" MAC="" WIN="" NOTES="" ACTIVATION="" DEADLINE="" MIN_SUPPORTED="" CHANNEL="devnet" BASE="" DEST="" DEPLOY=0 VERIFY_ONLY=0 TRIES=12
OVERRIDE="" TUNING_FILE="" NO_TUNING=0
OVERRIDE="" TUNING_FILE="" NO_TUNING=0 PUBLIC=0
while [ $# -gt 0 ]; do
case "$1" in
--version) VERSION="$2"; shift 2 ;;
@ -54,6 +57,7 @@ while [ $# -gt 0 ]; do
--dest) DEST="$2"; shift 2 ;;
--deploy) DEPLOY=1; shift ;;
--no-deploy) DEPLOY=0; shift ;;
--public) PUBLIC=1; shift ;; # dl/public/ too (publish-public.sh --app [--wallet]); needs the real downloads folder
--verify-only) VERIFY_ONLY=1; shift ;; # no write, no deploy: check the live manifest against the one in the folder
--tries) TRIES="$2"; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
@ -200,12 +204,20 @@ cat "$DEST/igneum-app-latest.json"; echo
echo "signature: $(cat "$DEST/igneum-app-latest.json.sig")"
echo "key fingerprint: $("$SIGNER" fingerprint "$PUB" | tail -1)"
# --public: the same version into dl/public/ with public URLs (its own signed manifest), the aliases rewritten
if [ "$PUBLIC" = 1 ]; then
[ -n "$DLSITE" ] || { echo "--public needs the real downloads folder (no --dest)" >&2; exit 1; }
pub_args=(--app); [ -f "$DEST/igneum-wallet-latest.json" ] && pub_args+=(--wallet)
"$HERE/publish-public.sh" "${pub_args[@]}" || { echo "publish-public.sh failed; dl/public/ not updated" >&2; exit 1; }
fi
if [ "$DEPLOY" = 1 ]; then
[ -n "$DLSITE" ] || { echo "--deploy needs the real downloads folder (no --dest)" >&2; exit 1; }
echo "deploying $DLSITE"
(cd "$DLSITE" && npx --yes vercel@latest --global-config "$HOME/.config/igneum/vercel" deploy --prod --yes 2>&1 | sed "s#$TOKEN#<token>#g"; exit "${PIPESTATUS[0]}") \
|| { echo "the deploy failed (the Vercel CLI's exit status above); nothing verified" >&2; exit 1; }
verify_live_manifest || exit 1
if [ "$PUBLIC" = 1 ]; then "$HERE/publish-public.sh" --verify --no-prune || exit 1; fi
# the console's Builds tab (relay/): one build event and a fresh downloads listing; never fatal
node "$ROOT/tools/console.mjs" post --kind build --title "OTA manifest $VERSION ($CHANNEL) live" --body "$NOTES" >/dev/null 2>&1 || true
node "$ROOT/tools/console.mjs" sync-dl >/dev/null 2>&1 || true

253
packaging/ota/publish-public.sh Executable file
View file

@ -0,0 +1,253 @@
#!/usr/bin/env bash
# The PUBLIC downloads path (5 October 2026, testnet launch): dl/public/ in the downloads folder holds only the current
# installers, the HiveOS package and the two signed manifests, with no token in any URL, plus one stable alias per
# platform that the site links:
#
# https://dl.igneum.network/public/igneum-miner-windows.exe -> dl/public/Igneum-Miner-Setup-<v>.exe
# https://dl.igneum.network/public/igneum-miner-mac.dmg -> dl/public/Igneum-Miner-<v>.dmg
# https://dl.igneum.network/public/igneum-miner-hive.tar.gz -> dl/public/igneum-hive-<v>.tar.gz
# https://dl.igneum.network/public/igneum-wallet-mac.dmg -> dl/public/Igneum-Wallet-<v>.dmg
#
# The aliases are rewrites in <dlsite>/vercel.json, written by this script from what dl/public/ holds, so they can
# never point at a file that is not there and they follow every release by themselves (the bytes exist once).
# Nothing is ever removed from the token folders; the public folder is pruned to the current files only.
#
# packaging/ota/publish-public.sh --app copy the files named by dl/<token>/igneum-app-latest.json, write
# dl/public/igneum-app-latest.json(.sig) with public URLs, signed
# packaging/ota/publish-public.sh --wallet the same for igneum-wallet-latest.json
# packaging/ota/publish-public.sh --hive <igneum-hive-<v>.tar.gz> copy the HiveOS package (plus its .sha256)
# packaging/ota/publish-public.sh --aliases only rewrite vercel.json from the folder (runs after every step above)
# --dry-run read everything, print the plan, write nothing --deploy deploy the downloads folder afterwards
# --verify HEAD every public file and alias, GET the manifests (also after --deploy) --no-prune keep old files
# --dest <dir> --base-url <url> a scratch folder instead of the downloads folder (tests)
#
# publish-manifest.sh --public calls this with --app (and --wallet when that manifest exists), so every future
# release lands here too. Reads ~/.config/igneum/dlsite-dir, dl-token (fresh on every run; it is being rotated),
# ota-signing-key(.pub); never prints a token.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
CFG="$HOME/.config/igneum"
KEY="$CFG/ota-signing-key"; PUB_KEY="$CFG/ota-signing-key.pub"
SIGNER="${IGNEUM_OTA_SIGN:-$ROOT/app/igneum-app/target/release/igneum-ota-sign}" # a built signer elsewhere (another worktree)
HOST="https://dl.igneum.network"
DO_APP=0 DO_WALLET=0 HIVE="" DO_ALIASES=0 DRY=0 DEPLOY=0 VERIFY=0 PRUNE=1 DEST="" BASE="" TRIES=12
while [ $# -gt 0 ]; do
case "$1" in
--app) DO_APP=1; shift ;;
--wallet) DO_WALLET=1; shift ;;
--hive) HIVE="$2"; shift 2 ;;
--aliases) DO_ALIASES=1; shift ;;
--dry-run) DRY=1; shift ;;
--deploy) DEPLOY=1; shift ;;
--verify) VERIFY=1; shift ;;
--no-prune) PRUNE=0; shift ;;
--dest) DEST="$2"; shift 2 ;;
--base-url) BASE="$2"; shift 2 ;;
--tries) TRIES="$2"; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[ "$DO_APP$DO_WALLET$DO_ALIASES$VERIFY" != 0000 ] || [ -n "$HIVE" ] || { echo "nothing to do: --app, --wallet, --hive <file>, --aliases or --verify" >&2; exit 2; }
command -v python3 >/dev/null || { echo "python3 is needed" >&2; exit 1; }
[ -x "$SIGNER" ] || { echo "no $SIGNER (cargo build --release --bin igneum-ota-sign in app/igneum-app)" >&2; exit 1; }
TOKEN_FILE="$CFG/dl-token"
[ -f "$TOKEN_FILE" ] || { echo "no $TOKEN_FILE" >&2; exit 1; }
TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
if [ -z "$DEST" ]; then
DLSITE="${IGNEUM_DLSITE:-}"
[ -n "$DLSITE" ] || { [ -f "$CFG/dlsite-dir" ] && DLSITE="$(tr -d '[:space:]' < "$CFG/dlsite-dir")"; } || true
[ -n "$DLSITE" ] && [ -d "$DLSITE/dl/$TOKEN" ] || { echo "no downloads folder: ~/.config/igneum/dlsite-dir must hold dl/<token>/" >&2; exit 1; }
else
DLSITE="$DEST"; mkdir -p "$DLSITE/dl/$TOKEN"
fi
SRC="$DLSITE/dl/$TOKEN"
PUB="$DLSITE/dl/public"
[ -n "$BASE" ] || BASE="$HOST"
BASE="${BASE%/}"
BASE_PUB="$BASE/dl/public"
scrub() { sed "s#$TOKEN#<token>#g"; }
log() { printf '%s\n' "$*" | scrub; }
sha() { "$SIGNER" sha256 "$1" | cut -d' ' -f1; }
[ "$DRY" = 1 ] || mkdir -p "$PUB"
# one manifest: copy the files it names from the token folder, rewrite every URL to the public base, sign, verify
publish_manifest() { # <manifest name>
local name="$1" src="$SRC/$1" out="$PUB/$1" files f sum
[ -f "$src" ] || { log "no $src: nothing to publish for $name"; return 1; }
files="$(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print("\n".join(e["url"].rsplit("/",1)[1] for e in m.get("platforms",{}).values()))' "$src")"
for f in $files; do
[ -f "$SRC/$f" ] || { log "ERROR: $name names $f but dl/<token>/$f is not there" >&2; return 1; }
sum="$(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print([e["sha256"] for e in m["platforms"].values() if e["url"].endswith("/"+sys.argv[2])][0])' "$src" "$f")"
[ "$(sha "$SRC/$f")" = "$sum" ] || { log "ERROR: dl/<token>/$f does not match the sha256 in $name" >&2; return 1; }
if [ -f "$PUB/$f" ] && [ "$(sha "$PUB/$f")" = "$sum" ]; then log " $f: already in dl/public/ (same sha256)"
elif [ "$DRY" = 1 ]; then log " $f: would copy into dl/public/ ($(stat -f %z "$SRC/$f") B)"
else cp "$SRC/$f" "$PUB/$f.part" && mv "$PUB/$f.part" "$PUB/$f"; log " $f: copied into dl/public/ ($(stat -f %z "$PUB/$f") B)"; fi
done
# the public manifest: the same fields, URLs under the public base, canonical bytes, our signature
local tmp; tmp="$(mktemp)"
python3 - "$src" "$tmp" "$BASE_PUB" <<'PY'
import json, sys
src, out, base = sys.argv[1:4]
m = json.load(open(src))
for e in m.get("platforms", {}).values():
e["url"] = base + "/" + e["url"].rsplit("/", 1)[1]
open(out, "w").write(json.dumps(m, sort_keys=True, separators=(",", ":"), ensure_ascii=False))
PY
if grep -q "$TOKEN" "$tmp"; then rm -f "$tmp"; log "ERROR: the public $name would still carry the token" >&2; return 1; fi
if [ "$DRY" = 1 ]; then
log " $name: would write $(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print(m.get("version"), "+".join(m.get("platforms",{})))' "$tmp") with URLs under $BASE_PUB, signed"
rm -f "$tmp"; return 0
fi
"$SIGNER" sign "$KEY" "$tmp" > "$tmp.sig"
"$SIGNER" verify "$PUB_KEY" "$tmp" "$tmp.sig" >/dev/null
chmod 644 "$tmp" "$tmp.sig"; mv "$tmp" "$out"; mv "$tmp.sig" "$out.sig"
log " $name: $(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); print(m.get("version"), "+".join(m.get("platforms",{})))' "$out") written, signed, verified; URLs under $BASE_PUB"
}
if [ "$DO_APP" = 1 ]; then log "app manifest -> dl/public/"; publish_manifest igneum-app-latest.json; fi
if [ "$DO_WALLET" = 1 ]; then log "wallet manifest -> dl/public/"; publish_manifest igneum-wallet-latest.json; fi
if [ -n "$HIVE" ]; then
[ -f "$HIVE" ] || { echo "missing: $HIVE" >&2; exit 1; }
hn="$(basename "$HIVE")"
case "$hn" in igneum-hive-*.tar.gz) ;; *) echo "$HIVE: the HiveOS package is igneum-hive-<version>.tar.gz (packaging/hive/make-hive-package.sh)" >&2; exit 1 ;; esac
log "HiveOS package -> dl/public/"
if [ -f "$PUB/$hn" ] && [ "$(sha "$PUB/$hn")" = "$(sha "$HIVE")" ]; then log " $hn: already in dl/public/ (same sha256)"
elif [ "$DRY" = 1 ]; then log " $hn: would copy into dl/public/ ($(stat -f %z "$HIVE") B, sha256 $(sha "$HIVE"))"
else cp "$HIVE" "$PUB/$hn.part" && mv "$PUB/$hn.part" "$PUB/$hn"; "$SIGNER" sha256 "$PUB/$hn" | awk -v n="$hn" '{print $1 " " n}' > "$PUB/$hn.sha256"; log " $hn: copied into dl/public/ ($(stat -f %z "$PUB/$hn") B, sha256 $(sha "$PUB/$hn"))"; fi
fi
# the aliases: what the two manifests and the newest HiveOS package in dl/public/ name, nothing else
ALIASES_JSON="$(python3 - "$PUB" "$DRY" <<'PY'
import json, os, re, sys
pub, dry = sys.argv[1], sys.argv[2] == "1"
def entry(name, plat):
p = os.path.join(pub, name)
if not os.path.exists(p): return None
e = json.load(open(p)).get("platforms", {}).get(plat)
if not e: return None
f = e["url"].rsplit("/", 1)[1]
return f if os.path.exists(os.path.join(pub, f)) or dry else None
hive = sorted([f for f in os.listdir(pub) if re.fullmatch(r"igneum-hive-\d+\.\d+\.\d+\.tar\.gz", f)],
key=lambda f: tuple(int(x) for x in re.findall(r"\d+", f)[:3])) if os.path.isdir(pub) else []
aliases = {
"igneum-miner-windows.exe": entry("igneum-app-latest.json", "windows"),
"igneum-miner-mac.dmg": entry("igneum-app-latest.json", "mac"),
"igneum-miner-hive.tar.gz": hive[-1] if hive else None,
"igneum-wallet-mac.dmg": entry("igneum-wallet-latest.json", "mac"),
}
print(json.dumps(aliases))
PY
)"
KEEP="$(python3 -c 'import json,sys; a=json.loads(sys.argv[1]); print(" ".join(v for v in a.values() if v))' "$ALIASES_JSON")"
log "aliases (vercel.json rewrites under /public/):"
python3 -c 'import json,sys; a=json.loads(sys.argv[1]); [print(" /public/%s -> %s" % (k, ("/dl/public/" + v) if v else "(no file yet; alias left out)")) for k,v in a.items()]' "$ALIASES_JSON" | scrub
VERCEL_JSON="$(python3 - "$ALIASES_JSON" <<'PY'
import json, sys
a = json.loads(sys.argv[1])
rewrites = [{"source": "/public/" + k, "destination": "/dl/public/" + v} for k, v in a.items() if v]
cfg = {
"cleanUrls": False,
"trailingSlash": False,
"rewrites": rewrites,
"headers": [
{"source": "/dl/public/(.*)\\.json", "headers": [{"key": "Cache-Control", "value": "public, max-age=60"}, {"key": "Access-Control-Allow-Origin", "value": "*"}]},
{"source": "/dl/public/(.*)\\.sig", "headers": [{"key": "Cache-Control", "value": "public, max-age=60"}, {"key": "Access-Control-Allow-Origin", "value": "*"}]},
{"source": "/public/(.*)", "headers": [{"key": "Cache-Control", "value": "public, max-age=300"}, {"key": "X-Content-Type-Options", "value": "nosniff"}]},
],
}
print(json.dumps(cfg, indent=2))
PY
)"
if [ "$DRY" = 1 ]; then log " would write $DLSITE/vercel.json ($(printf '%s' "$VERCEL_JSON" | grep -c '"source"') rules)"
else printf '%s\n' "$VERCEL_JSON" > "$DLSITE/vercel.json.new" && mv "$DLSITE/vercel.json.new" "$DLSITE/vercel.json"; log " wrote $DLSITE/vercel.json"; fi
# the index the site build reads (unsigned, a convenience: alias, file, version, size, sha256 per platform; the signed
# manifests stay the source of truth for the apps)
if [ "$DRY" = 0 ]; then
python3 - "$PUB" "$ALIASES_JSON" "$BASE" <<'PYIDX'
import json, os, re, sys, hashlib, datetime
pub, aliases, base = sys.argv[1], json.loads(sys.argv[2]), sys.argv[3]
def sha(p):
h = hashlib.sha256()
with open(p, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk)
return h.hexdigest()
def version_of(name, f):
if name.startswith("igneum-miner-hive"): return ".".join(re.findall(r"\d+", f)[:3])
m = json.load(open(os.path.join(pub, "igneum-wallet-latest.json" if name.startswith("igneum-wallet") else "igneum-app-latest.json")))
return m.get("version")
keys = {"igneum-miner-windows.exe": "miner-windows", "igneum-miner-mac.dmg": "miner-mac", "igneum-miner-hive.tar.gz": "miner-hive", "igneum-wallet-mac.dmg": "wallet-mac"}
files = {}
for alias, f in aliases.items():
if not f: continue
p = os.path.join(pub, f)
files[keys[alias]] = {"alias": "/public/" + alias, "file": f, "path": "/dl/public/" + f, "version": version_of(alias, f), "size": os.path.getsize(p), "sha256": sha(p)}
out = {"updated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "base": base, "files": files}
tmp = os.path.join(pub, "igneum-downloads.json.new")
open(tmp, "w").write(json.dumps(out, indent=2, sort_keys=True) + "\n"); os.chmod(tmp, 0o644); os.replace(tmp, os.path.join(pub, "igneum-downloads.json"))
print(" wrote dl/public/igneum-downloads.json (%d platform(s))" % len(files))
PYIDX
fi
# prune: dl/public/ holds only the current files (the manifests, what they name, the newest HiveOS package and its sha256)
if [ "$PRUNE" = 1 ] && [ -d "$PUB" ]; then
for f in "$PUB"/*; do
[ -f "$f" ] || continue
n="$(basename "$f")"
case "$n" in igneum-app-latest.json|igneum-app-latest.json.sig|igneum-wallet-latest.json|igneum-wallet-latest.json.sig|igneum-downloads.json) continue ;; esac
keep=0; for k in $KEEP; do [ "$n" = "$k" ] || [ "$n" = "$k.sha256" ] && keep=1; done
if [ "$keep" = 0 ]; then
if [ "$DRY" = 1 ]; then log " would remove $n from dl/public/ (not current)"; else rm -f "$f"; log " removed $n from dl/public/ (not current)"; fi
fi
done
fi
if [ "$DRY" = 1 ]; then log "dry run: nothing written"; fi
if [ "$DEPLOY" = 1 ]; then
[ "$DRY" = 0 ] || { echo "--deploy and --dry-run together make no sense" >&2; exit 2; }
[ -z "$DEST" ] || { echo "--deploy needs the real downloads folder (no --dest)" >&2; exit 1; }
log "deploying $DLSITE"
(cd "$DLSITE" && npx --yes vercel@latest --global-config "$CFG/vercel" deploy --prod --yes 2>&1 | scrub; exit "${PIPESTATUS[0]}") \
|| { echo "the deploy failed (the Vercel CLI's exit status above); nothing verified" >&2; exit 1; }
VERIFY=1
fi
if [ "$VERIFY" = 1 ]; then
fail=0; t=0
check_one() { # <url> <local file> -> 0 when HEAD is 200 with the local size
local url="$1" f="$2" hdr code len size
size="$(stat -f %z "$f")"
hdr="$(curl -sI -L -H 'Cache-Control: no-cache' "$url" 2>/dev/null | tr -d '\r')"
code="$(printf '%s\n' "$hdr" | awk 'toupper($1) ~ /^HTTP\// {c=$2} END{print c+0}')"
len="$(printf '%s\n' "$hdr" | awk 'tolower($1)=="content-length:"{l=$2} END{print l+0}')"
printf ' %s %s B %s (local %s B)\n' "$code" "$len" "$url" "$size" | scrub
[ "$code" = 200 ] && [ "$len" = "$size" ]
}
while [ "$t" -lt "$TRIES" ]; do
t=$((t + 1)); fail=0
for f in "$PUB"/*; do
[ -f "$f" ] || continue
n="$(basename "$f")"
check_one "$BASE_PUB/$n" "$f" || fail=1
case "$n" in igneum-app-latest.json|igneum-wallet-latest.json)
tmp="$(mktemp)"; curl -fsSL -H 'Cache-Control: no-cache' -o "$tmp" "$BASE_PUB/$n" 2>/dev/null || true
if cmp -s "$tmp" "$f" && curl -fsSL -H 'Cache-Control: no-cache' -o "$tmp.sig" "$BASE_PUB/$n.sig" 2>/dev/null && "$SIGNER" verify "$PUB_KEY" "$tmp" "$tmp.sig" >/dev/null 2>&1; then echo " $n: byte-identical, signature OK"; else echo " $n: NOT the local bytes yet (or the signature fails)"; fail=1; fi
rm -f "$tmp" "$tmp.sig" ;;
igneum-downloads.json)
tmp="$(mktemp)"; curl -fsSL -H 'Cache-Control: no-cache' -o "$tmp" "$BASE_PUB/$n" 2>/dev/null || true
if cmp -s "$tmp" "$f"; then echo " $n: byte-identical"; else echo " $n: NOT the local bytes yet"; fail=1; fi
rm -f "$tmp" ;;
esac
done
for pair in $(python3 -c 'import json,sys; a=json.loads(sys.argv[1]); print(" ".join(k+"="+v for k,v in a.items() if v))' "$ALIASES_JSON"); do
check_one "$BASE/public/${pair%%=*}" "$PUB/${pair#*=}" || fail=1
done
[ "$fail" = 0 ] && break
[ "$t" -lt "$TRIES" ] && { echo " not all served yet (try $t of $TRIES); again in 10 s"; sleep 10; }
done
if [ "$fail" = 1 ]; then echo "public folder: NOT fully served after $t tries" >&2; exit 1; fi
echo "public folder verified: every file and alias answers 200 with the local size (try $t of $TRIES)"
fi

View file

@ -166,6 +166,7 @@ p{margin:0;color:var(--ink-2);max-width:52ch}
<a href="/wallet">The wallet</a>
<a href="/miners">GPU bench table</a>
<a href="/miner#fee">The dev fee</a>
<a href="/faucet">Testnet faucet</a>
</nav>
<nav class="foot-col" aria-label="Follow">
<div class="eyebrow">Follow</div>

110
site/api/faucet.mjs Normal file
View file

@ -0,0 +1,110 @@
// Igneum testnet faucet. POST /api/faucet {address} sends 10 IGN of testnet coin to that address: once per address per
// day, once per IP per day. The key is only in the Vercel env (FAUCET_KEY), the node in FAUCET_RPC; without either the
// faucet answers "not open yet" and sends nothing. Rate limits live in the Neon table faucet_grants (the same HTTP SQL
// pattern as api/log.mjs). Zero dependencies: the transaction is signed by site/lib/eth.mjs.
// Prepared on the devnet (chain id 4463) on 5 October 2026; the public testnet (4462) gets its own key and RPC.
import { signTransaction, addressOf, isAddress, toWei } from '../lib/eth.mjs';
export const AMOUNT_IGN = 10;
export const WINDOW_HOURS = 24;
export function neon(url) {
if (!url) throw new Error('DATABASE_URL is not set');
const host = new URL(url).hostname.replace('-pooler', '');
return async (query, params = []) => {
const r = await fetch(`https://${host}/sql`, {
method: 'POST',
headers: { 'Neon-Connection-String': url, 'Content-Type': 'application/json' },
body: JSON.stringify({ query, params }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.message || JSON.stringify(j));
return j;
};
}
export function rpcClient(url, fetchImpl = fetch) {
let id = 0;
return async (method, params = []) => {
const r = await fetchImpl(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params }) });
const j = await r.json();
if (j.error) throw new Error(`${method}: ${j.error.message || JSON.stringify(j.error)}`);
return j.result;
};
}
async function readBody(req) {
if (req.body !== undefined && req.body !== null) {
if (typeof req.body === 'string') return JSON.parse(req.body);
if (Buffer.isBuffer(req.body)) return JSON.parse(req.body.toString('utf8'));
return req.body;
}
const chunks = [];
for await (const c of req) chunks.push(c);
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
}
export function clientIp(req) {
const fwd = req.headers['x-forwarded-for'];
const first = (Array.isArray(fwd) ? fwd[0] : fwd || '').split(',')[0].trim();
return first || String(req.headers['x-real-ip'] || req.socket?.remoteAddress || '').trim() || 'unknown';
}
// The handler, with its dependencies injectable for the tests: {env, sql, rpc}. `sql` is (query, params) -> {rows},
// `rpc` is (method, params) -> result. The default export wires the real ones from the environment.
export function createHandler({ env = process.env, sql, rpc } = {}) {
return async function handler(req, res) {
res.setHeader('Cache-Control', 'no-store');
if (req.method !== 'POST') { res.setHeader('Allow', 'POST'); return res.status(405).json({ ok: false, error: 'method not allowed' }); }
const key = env.FAUCET_KEY, rpcUrl = env.FAUCET_RPC;
const chainId = Number(env.FAUCET_CHAIN_ID || 4463);
if (!key || !/^0x[0-9a-fA-F]{64}$/.test(key) || !rpcUrl) {
return res.status(503).json({ ok: false, error: 'The faucet is not open yet. It opens with the public testnet.' });
}
let body;
try { body = await readBody(req); } catch { return res.status(400).json({ ok: false, error: 'bad json' }); }
const address = String(body.address || '').trim();
if (!isAddress(address)) return res.status(400).json({ ok: false, error: 'That is not an address. It is 0x followed by 40 hex characters.' });
const to = address.toLowerCase();
const ip = clientIp(req);
try {
sql = sql || neon(env.DATABASE_URL);
const recent = await sql(
`SELECT address, ip FROM faucet_grants WHERE created_at > now() - interval '${WINDOW_HOURS} hours' AND (address = $1 OR ip = $2)`,
[to, ip],
);
const rows = recent.rows || [];
if (rows.some(r => r.address === to)) return res.status(429).json({ ok: false, error: `This address had its ${AMOUNT_IGN} IGN in the last ${WINDOW_HOURS} hours. Come back tomorrow.` });
if (rows.some(r => r.ip === ip)) return res.status(429).json({ ok: false, error: `This connection had its ${AMOUNT_IGN} IGN in the last ${WINDOW_HOURS} hours. Come back tomorrow.` });
// reserve the grant first, so two requests in the same second cannot both pass the count
const ins = await sql('INSERT INTO faucet_grants (address, ip, amount_wei, chain_id) VALUES ($1, $2, $3, $4) RETURNING id', [to, ip, toWei(AMOUNT_IGN).toString(), chainId]);
const grantId = Number(ins.rows[0].id);
try {
rpc = rpc || rpcClient(rpcUrl);
const from = addressOf(key);
const [nonceHex, block, tipHex] = await Promise.all([
rpc('eth_getTransactionCount', [from, 'pending']),
rpc('eth_getBlockByNumber', ['latest', false]),
rpc('eth_maxPriorityFeePerGas').catch(() => '0x3b9aca00'), // 1 gwei when the node has no tip oracle
]);
const baseFee = BigInt(block?.baseFeePerGas || '0x3b9aca00');
const tip = BigInt(tipHex || '0x3b9aca00');
const value = toWei(AMOUNT_IGN);
let gas = 21000n;
try { gas = BigInt(await rpc('eth_estimateGas', [{ from, to, value: '0x' + value.toString(16) }])); } catch { /* the plain-transfer default */ }
const tx = { chainId, nonce: BigInt(nonceHex), maxPriorityFeePerGas: tip, maxFeePerGas: baseFee * 2n + tip, gas, to, value, data: '0x' };
const signed = signTransaction(tx, key);
const hash = await rpc('eth_sendRawTransaction', [signed.raw]);
await sql('UPDATE faucet_grants SET tx_hash = $1 WHERE id = $2', [hash, grantId]);
return res.status(200).json({ ok: true, tx: hash, amount: String(AMOUNT_IGN), chainId, to });
} catch (e) {
await sql('DELETE FROM faucet_grants WHERE id = $1', [grantId]).catch(() => {});
return res.status(502).json({ ok: false, error: `The node did not take the transaction: ${String(e.message || e).slice(0, 200)}` });
}
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e).slice(0, 200) });
}
};
}
export default createHandler();

154
site/api/faucet.test.mjs Normal file
View file

@ -0,0 +1,154 @@
// node --test site/api/faucet.test.mjs
// The faucet handler against a fake database and a fake node: validation, the per-address and per-IP limits, the
// reservation row, the signed transaction it sends; and the signing primitives against known vectors.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHandler, AMOUNT_IGN } from './faucet.mjs';
import { keccak256, rlp, hex, unhex, addressOf, signTransaction, sign, toWei, isAddress, utf8 } from '../lib/eth.mjs';
// a well-known test key (the first Hardhat account; never funded on Igneum)
const KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
const KEY_ADDRESS = '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266';
function fakeDb(rows = []) {
const grants = rows.map((r, i) => ({ id: i + 1, address: r.address, ip: r.ip, tx_hash: r.tx_hash || null }));
const calls = [];
const sql = async (query, params) => {
calls.push({ query, params });
if (query.startsWith('SELECT')) return { rows: grants.filter(g => g.address === params[0] || g.ip === params[1]) };
if (query.startsWith('INSERT')) { const id = grants.length + 1; grants.push({ id, address: params[0], ip: params[1] }); return { rows: [{ id }] }; }
if (query.startsWith('UPDATE')) { grants.find(g => g.id === Number(params[1])).tx_hash = params[0]; return { rows: [] }; }
if (query.startsWith('DELETE')) { const i = grants.findIndex(g => g.id === Number(params[0])); if (i >= 0) grants.splice(i, 1); return { rows: [] }; }
throw new Error('unexpected query ' + query);
};
return { sql, grants, calls };
}
function fakeRpc({ fail } = {}) {
const sent = [];
const rpc = async (method, params) => {
if (method === 'eth_getTransactionCount') return '0x5';
if (method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x3b9aca00' };
if (method === 'eth_maxPriorityFeePerGas') return '0x3b9aca00';
if (method === 'eth_estimateGas') return '0x5208';
if (method === 'eth_sendRawTransaction') { if (fail) throw new Error('nonce too low'); sent.push(params[0]); return '0x' + 'ab'.repeat(32); }
throw new Error('unexpected rpc ' + method);
};
return { rpc, sent };
}
function call(handler, { method = 'POST', body = {}, ip = '203.0.113.7' } = {}) {
const res = { status: null, body: null, headers: {}, setHeader(k, v) { this.headers[k] = v; }, status(c) { this.status = c; return this; }, json(b) { this.body = b; return this; } };
const req = { method, body, headers: { 'x-forwarded-for': ip }, socket: {} };
return handler(req, res).then(() => res);
}
const ENV = { FAUCET_KEY: KEY, FAUCET_RPC: 'http://127.0.0.1:1', FAUCET_CHAIN_ID: '4463' };
test('keccak-256 known vectors', () => {
assert.equal(hex(keccak256(new Uint8Array(0))), '0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470');
assert.equal(hex(keccak256(utf8('abc'))), '0x4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45');
assert.equal(hex(keccak256(utf8('The quick brown fox jumps over the lazy dog'))), '0x4d741b6f1eb29cb2a9b9911c82f56fa8d73b04959d3d9d222895df6c0b28aa15');
assert.equal(hex(keccak256(new Uint8Array(200).fill(0x61))), hex(keccak256(utf8('a'.repeat(200))))); // two blocks
});
test('rlp', () => {
assert.equal(hex(rlp(utf8('dog'))), '0x83646f67');
assert.equal(hex(rlp([utf8('cat'), utf8('dog')])), '0xc88363617483646f67');
assert.equal(hex(rlp(0n)), '0x80');
assert.equal(hex(rlp(15n)), '0x0f');
assert.equal(hex(rlp(1024n)), '0x820400');
assert.equal(hex(rlp([])), '0xc0');
assert.equal(hex(rlp(utf8('Lorem ipsum dolor sit amet, consectetur adipisicing elit'))), '0xb8384c6f72656d20697073756d20646f6c6f722073697420616d65742c20636f6e7365637465747572206164697069736963696e6720656c6974');
});
test('address of a key', () => {
assert.equal(addressOf(KEY), KEY_ADDRESS);
assert.equal(addressOf('0x0000000000000000000000000000000000000000000000000000000000000001'), '0x7e5f4552091a69125d5dfcb7b8c2659029395bdf');
});
test('ecdsa: deterministic, low s, verifiable recovery id', () => {
const h = keccak256(utf8('igneum'));
const a = sign(h, KEY), b = sign(h, KEY);
assert.deepEqual(a, b);
assert.ok(a.s <= 0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0n);
assert.ok(a.v === 0 || a.v === 1);
});
test('eip-1559 envelope: type 2, the chain id first, the hash is keccak of the raw bytes', () => {
const tx = { chainId: 4463, nonce: 5, maxPriorityFeePerGas: 1_000_000_000n, maxFeePerGas: 3_000_000_000n, gas: 21000, to: KEY_ADDRESS, value: toWei(10), data: '0x' };
const s = signTransaction(tx, KEY);
assert.ok(s.raw.startsWith('0x02'));
assert.equal(s.hash, hex(keccak256(unhex(s.raw))));
// the unsigned fields are in the payload: chain id 4463 = 0x116f, nonce 5, gas 21000 = 0x5208
assert.ok(s.raw.includes('82116f05'));
assert.ok(s.raw.includes('825208'));
assert.equal(signTransaction(tx, KEY).raw, s.raw); // deterministic
});
test('toWei and isAddress', () => {
assert.equal(toWei(10), 10n * 10n ** 18n);
assert.equal(toWei(0.5), 5n * 10n ** 17n);
assert.ok(isAddress(KEY_ADDRESS)); assert.ok(!isAddress('0x123')); assert.ok(!isAddress(KEY_ADDRESS + '0'));
});
test('GET is refused', async () => {
const r = await call(createHandler({ env: ENV, sql: fakeDb().sql, rpc: fakeRpc().rpc }), { method: 'GET' });
assert.equal(r.status, 405);
});
test('not configured: 503, nothing touched', async () => {
const db = fakeDb();
const r = await call(createHandler({ env: {}, sql: db.sql, rpc: fakeRpc().rpc }), { body: { address: KEY_ADDRESS } });
assert.equal(r.status, 503);
assert.match(r.body.error, /not open yet/);
assert.equal(db.calls.length, 0);
});
test('a bad address is refused before the database', async () => {
const db = fakeDb();
for (const address of ['', 'abc', '0x12', KEY_ADDRESS.slice(0, 41), 'xyz' + KEY_ADDRESS]) {
const r = await call(createHandler({ env: ENV, sql: db.sql, rpc: fakeRpc().rpc }), { body: { address } });
assert.equal(r.status, 400, address);
}
assert.equal(db.calls.length, 0);
});
test('the first request sends 10 IGN and records the grant with the hash', async () => {
const db = fakeDb(); const node = fakeRpc();
const r = await call(createHandler({ env: ENV, sql: db.sql, rpc: node.rpc }), { body: { address: KEY_ADDRESS.toUpperCase().replace('0X', '0x') } });
assert.equal(r.status, 200);
assert.equal(r.body.ok, true);
assert.equal(r.body.amount, String(AMOUNT_IGN));
assert.equal(r.body.to, KEY_ADDRESS);
assert.equal(node.sent.length, 1);
assert.ok(node.sent[0].startsWith('0x02'));
assert.equal(db.grants.length, 1);
assert.equal(db.grants[0].address, KEY_ADDRESS);
assert.equal(db.grants[0].ip, '203.0.113.7');
assert.equal(db.grants[0].tx_hash, '0x' + 'ab'.repeat(32));
assert.equal(db.calls.find(c => c.query.startsWith('INSERT')).params[2], (10n * 10n ** 18n).toString());
});
test('the same address again within a day: 429, nothing sent', async () => {
const db = fakeDb([{ address: KEY_ADDRESS, ip: '198.51.100.1' }]); const node = fakeRpc();
const r = await call(createHandler({ env: ENV, sql: db.sql, rpc: node.rpc }), { body: { address: KEY_ADDRESS } });
assert.equal(r.status, 429);
assert.match(r.body.error, /address/);
assert.equal(node.sent.length, 0);
assert.equal(db.grants.length, 1);
});
test('the same IP again within a day with another address: 429, nothing sent', async () => {
const db = fakeDb([{ address: '0x' + '11'.repeat(20), ip: '203.0.113.7' }]); const node = fakeRpc();
const r = await call(createHandler({ env: ENV, sql: db.sql, rpc: node.rpc }), { body: { address: KEY_ADDRESS } });
assert.equal(r.status, 429);
assert.match(r.body.error, /connection/);
assert.equal(node.sent.length, 0);
});
test('a different IP and address after a grant: served', async () => {
const db = fakeDb([{ address: '0x' + '11'.repeat(20), ip: '198.51.100.1' }]); const node = fakeRpc();
const r = await call(createHandler({ env: ENV, sql: db.sql, rpc: node.rpc }), { body: { address: KEY_ADDRESS }, ip: '203.0.113.9' });
assert.equal(r.status, 200);
assert.equal(db.grants.length, 2);
});
test('the node refuses: 502 and the reservation is released', async () => {
const db = fakeDb(); const node = fakeRpc({ fail: true });
const r = await call(createHandler({ env: ENV, sql: db.sql, rpc: node.rpc }), { body: { address: KEY_ADDRESS } });
assert.equal(r.status, 502);
assert.match(r.body.error, /nonce too low/);
assert.equal(db.grants.length, 0);
});
test('the database fails: 500, nothing sent', async () => {
const node = fakeRpc();
const r = await call(createHandler({ env: ENV, sql: async () => { throw new Error('db down'); }, rpc: node.rpc }), { body: { address: KEY_ADDRESS } });
assert.equal(r.status, 500);
assert.equal(node.sent.length, 0);
});

View file

@ -522,6 +522,7 @@ th{font-family:var(--f-mono);font-size:12px;letter-spacing:.12em;text-transform:
<a href="/wallet">The wallet</a>
<a href="/miners">GPU bench table</a>
<a href="/miner#fee">The dev fee</a>
<a href="/faucet">Testnet faucet</a>
</nav>
<nav class="foot-col" aria-label="Follow">
<div class="eyebrow">Follow</div>

View file

@ -263,7 +263,56 @@ function stampProduct(html, file) {
return `<span data-product="${k}">${esc(PRODUCT[k])}</span>`;
});
}
const PAGES = [['index.html', ''], ['litepaper.html', 'litepaper'], ['live.html', 'live'], ['evidence.html', 'evidence'], ['miner.html', 'miner'], ['wallet.html', 'wallet'], ['metamask.html', ''], ['404.html', '']];
// Downloads (5 October 2026, testnet launch). The buttons link the PUBLIC downloads path, which carries no token:
// https://dl.igneum.network/public/<alias>, four aliases that packaging/ota/publish-public.sh rewrites to the current
// files. The version and the size on every button come from the index that script writes next to the signed manifests
// (dl/public/igneum-downloads.json), read here at build time; site/downloads.json is the last copy that was read, so a
// build without network (CI, a laptop offline) still stamps real numbers, and a successful read refreshes it.
// Pages carry: <a data-dl="miner-windows|miner-mac|miner-hive|wallet-mac"> (href stamped), <span data-dl-meta="key">
// (version and size), <code data-dl-url="key"> (the versioned URL, for the HiveOS Flight Sheet) and <span data-dl-sha="key">.
// TESTNET_OPEN: the one flag of the go. While false every download section keeps its "Public testnet: not yet open" line
// (elements marked data-testnet-notice); true removes them.
const TESTNET_OPEN = false;
const DL_HOST = 'https://dl.igneum.network';
const DL_SNAPSHOT = join(here, 'downloads.json');
async function loadDownloads() {
const snapshot = existsSync(DL_SNAPSHOT) ? JSON.parse(readFileSync(DL_SNAPSHOT, 'utf8')) : { files: {} };
if (process.env.SITE_DOWNLOADS_OFFLINE) return { ...snapshot, source: 'snapshot (offline)' };
try {
const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 8000);
const r = await fetch(`${DL_HOST}/dl/public/igneum-downloads.json`, { signal: ctl.signal, headers: { 'Cache-Control': 'no-cache' } });
clearTimeout(t);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const live = await r.json();
if (!live || typeof live.files !== 'object') throw new Error('no files object');
for (const [k, f] of Object.entries(live.files)) if (!/^\/public\/[a-z0-9.-]+$/.test(f.alias) || !/^\d+\.\d+\.\d+$/.test(String(f.version)) || !(f.size > 0)) throw new Error(`bad entry ${k}`);
writeFileSync(DL_SNAPSHOT, JSON.stringify(live, null, 2) + '\n');
return { ...live, source: 'live' };
} catch (e) {
console.warn(`downloads: using the snapshot (${e.message})`);
return { ...snapshot, source: 'snapshot' };
}
}
const fmtMB = n => (n / 1e6).toLocaleString('en-GB', { maximumFractionDigits: 1, minimumFractionDigits: 1 }) + ' MB';
function stampDownloads(html, file, dl) {
const f = dl.files || {};
html = html.replace(/<a([^>]*)\sdata-dl="([a-z-]+)"([^>]*)>/g, (m, pre, key, post) => {
const attrs = (pre + post).replace(/\shref="[^"]*"/, '').replace(/\sdata-dl-missing(="[^"]*")?/, '').replace(/\saria-disabled="[^"]*"/, '');
if (!f[key]) return `<a data-dl="${key}" href="#get" data-dl-missing aria-disabled="true"${attrs}>`;
return `<a data-dl="${key}" href="${DL_HOST}${f[key].alias}"${attrs}>`;
});
// the stamped elements keep their other attributes (class, style); only the text between the tags is replaced
html = html.replace(/<span([^>]*\sdata-dl-meta="([a-z-]+)"[^>]*)>[^<]*<\/span>/g, (m, attrs, key) => `<span${attrs}>${f[key] ? `v${esc(String(f[key].version))} · ${fmtMB(f[key].size)}` : 'not published yet'}</span>`);
html = html.replace(/<code([^>]*\sdata-dl-url="([a-z-]+)"[^>]*)>[^<]*<\/code>/g, (m, attrs, key) => `<code${attrs}>${f[key] ? `${DL_HOST}${esc(f[key].path)}` : '(not published yet)'}</code>`);
html = html.replace(/<span([^>]*\sdata-dl-sha="([a-z-]+)"[^>]*)>[^<]*<\/span>/g, (m, attrs, key) => `<span${attrs}>${f[key] ? esc(String(f[key].sha256)) : 'not published yet'}</span>`);
if (TESTNET_OPEN) html = html.replace(/\n?[ \t]*<p data-testnet-notice[^>]*>[\s\S]*?<\/p>/g, '');
for (const key of ['miner-windows', 'miner-mac', 'miner-hive', 'wallet-mac']) if (html.includes(`data-dl="${key}"`) && !f[key]) console.warn(`${file}: ${key} has no published file yet; its button points at #get`);
return html;
}
const downloads = await loadDownloads();
built.push(`downloads (${downloads.source}: ${Object.keys(downloads.files || {}).join(', ') || 'none'})`);
const PAGES = [['index.html', ''], ['litepaper.html', 'litepaper'], ['live.html', 'live'], ['evidence.html', 'evidence'], ['miner.html', 'miner'], ['wallet.html', 'wallet'], ['metamask.html', ''], ['faucet.html', ''], ['404.html', '']];
for (const [file, active] of PAGES) {
const p = join(here, file);
if (!existsSync(p)) throw new Error(`missing page ${file}`);
@ -272,6 +321,7 @@ for (const [file, active] of PAGES) {
html = inject(html, 'nav', navFor(active), file);
html = inject(html, 'footer', FOOT, file);
html = stampProduct(html, file);
html = stampDownloads(html, file, downloads);
if (file === 'index.html') html = inject(html, 'journey', `<script type="application/json" id="journey-data">${JSON.stringify(journey).replace(/</g, '\\u003c')}</script>`, file);
writeFileSync(p, html);
built.push(file);

38
site/downloads.json Normal file
View file

@ -0,0 +1,38 @@
{
"base": "https://dl.igneum.network",
"files": {
"miner-hive": {
"alias": "/public/igneum-miner-hive.tar.gz",
"file": "igneum-hive-0.3.8.tar.gz",
"path": "/dl/public/igneum-hive-0.3.8.tar.gz",
"sha256": "cc2fcbcaeab799364f41d613de52960337f703070c742d0df35961484002f927",
"size": 24831127,
"version": "0.3.8"
},
"miner-mac": {
"alias": "/public/igneum-miner-mac.dmg",
"file": "Igneum-Miner-0.3.8.dmg",
"path": "/dl/public/Igneum-Miner-0.3.8.dmg",
"sha256": "53c12b6e84a1c1e638d3e28d1cebb8eaf1980620c6dff5958292893033b62871",
"size": 41247419,
"version": "0.3.8"
},
"miner-windows": {
"alias": "/public/igneum-miner-windows.exe",
"file": "Igneum-Miner-Setup-0.3.8.exe",
"path": "/dl/public/Igneum-Miner-Setup-0.3.8.exe",
"sha256": "86cffd144adb89b9d886be9a3b4c76b91ed78afc737b3a9fc19ce44a2d71afcc",
"size": 19794320,
"version": "0.3.8"
},
"wallet-mac": {
"alias": "/public/igneum-wallet-mac.dmg",
"file": "Igneum-Wallet-0.1.3.dmg",
"path": "/dl/public/Igneum-Wallet-0.1.3.dmg",
"sha256": "d5b7945e4fe8dbd6db23fbc7bab3a8ac378827fe3842c7a3b0e091aae455717a",
"size": 19598469,
"version": "0.1.3"
}
},
"updated": "2026-10-05T15:56:23Z"
}

View file

@ -255,6 +255,7 @@ code{font-family:var(--f-mono);font-size:.92em;background:var(--obsidian);paddin
<a href="/wallet">The wallet</a>
<a href="/miners">GPU bench table</a>
<a href="/miner#fee">The dev fee</a>
<a href="/faucet">Testnet faucet</a>
</nav>
<nav class="foot-col" aria-label="Follow">
<div class="eyebrow">Follow</div>

263
site/faucet.html Normal file
View file

@ -0,0 +1,263 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Igneum testnet faucet. 10 IGN a day for testing</title>
<meta name="description" content="Testnet IGN for developers and miners: 10 IGN per address per day, no value, no account. Paste an address, get a transaction hash.">
<link rel="canonical" href="https://igneum.network/faucet">
<meta name="theme-color" content="#0C0C0E">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Igneum">
<meta property="og:title" content="Igneum testnet faucet">
<meta property="og:description" content="Testnet IGN for developers and miners: 10 IGN per address per day, no value, no account.">
<meta property="og:url" content="https://igneum.network/faucet">
<meta property="og:image" content="https://igneum.network/og.png?v=3">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Igneum testnet faucet">
<meta name="twitter:description" content="Testnet IGN for developers and miners: 10 IGN per address per day, no value, no account.">
<meta name="twitter:image" content="https://igneum.network/og.png?v=3">
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="manifest" href="/site.webmanifest">
<!-- head:start -->
<link rel="preload" href="/fonts/unbounded-900.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/plex-sans-400.woff2" as="font" type="font/woff2" crossorigin>
<style>
/* Shared by every page. Source: site/partials/head.html, injected by site/build.mjs between the head markers. Edit the partial, not the page. */
/* Fonts, self-hosted (latin subsets, OFL): Unbounded 500/700/900, IBM Plex Sans 400/500/600, IBM Plex Mono 400/500. Fallbacks carry size and ascent overrides so the swap does not move the layout. */
@font-face{font-family:'Unbounded';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/unbounded-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/unbounded-700.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(/fonts/unbounded-900.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-sans-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-sans-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(/fonts/plex-sans-600.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-mono-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-mono-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'Unbounded Fallback';src:local('Arial Black'),local('Arial-Black'),local('Impact');size-adjust:114%;ascent-override:87.5%;descent-override:21.5%;line-gap-override:0%}
@font-face{font-family:'Plex Sans Fallback';src:local('Arial'),local('Helvetica Neue'),local('Helvetica');size-adjust:100.5%;ascent-override:102%;descent-override:27.4%;line-gap-override:0%}
@font-face{font-family:'Plex Mono Fallback';src:local('Courier New'),local('Menlo');size-adjust:100%;ascent-override:102.5%;descent-override:27.5%;line-gap-override:0%}
:root{--f-sans:'IBM Plex Sans','Plex Sans Fallback',system-ui,-apple-system,sans-serif;--f-display:'Unbounded','Unbounded Fallback',sans-serif;--f-mono:'IBM Plex Mono','Plex Mono Fallback',ui-monospace,Menlo,monospace;
/* site chrome tokens (nav, footer, skip link). Dark by default; the litepaper sets light values and resets these in dark mode */
--nav-h:68px;--ui-bg:rgba(12,12,14,.84);--ui-menu:#0C0C0E;--ui-ink:#F4F1EC;--ui-ink-2:#C9C7C2;--ui-ash:#9A9A9E;--ui-line:#2A2A30;--ui-line-2:#3A3A42;--ui-accent:#F2541B;--ui-accent-ink:#0C0C0E;--ui-hot:#FFB35C;--ui-hover:#FF6A2B;--ui-tint:rgba(242,84,27,.12)}
html{-webkit-text-size-adjust:100%}
h1,h2,h3{text-wrap:balance}
p,li,dd,figcaption{text-wrap:pretty}
a:focus-visible,button:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--ui-accent);outline-offset:3px;border-radius:6px}
.skip{position:absolute;left:12px;top:-80px;z-index:50;background:var(--ui-accent);color:var(--ui-accent-ink);padding:10px 14px;border-radius:10px;font:600 15px/1.2 var(--f-sans);text-decoration:none}
.skip:focus{top:12px}
/* nav: one bar on every page, the mark in its black square, seven links and the miner button, a menu under 941 px (the eight items need 688 px beside the brand at 15 px, so the bar fits from 941 px up without a wrapped label) */
.nav{position:sticky;top:0;z-index:20;background:var(--ui-bg);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-bottom:1px solid var(--ui-line);color:var(--ui-ink)}
.nav .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:var(--nav-h)}
.nav a{color:inherit;text-decoration:none}
.brand{display:inline-flex;align-items:center;gap:10px;color:var(--ui-ink);text-decoration:none;flex:0 0 auto}
.brand .mark{display:block;flex:0 0 auto}
.brand .word{font-family:var(--f-display);font-weight:900;font-size:20px;letter-spacing:.06em;line-height:1}
.nav .links{display:flex;align-items:center;gap:clamp(14px,2vw,26px);font-size:15px;font-weight:500}
.nav .links a{color:var(--ui-ink-2);padding:4px 0;border-bottom:2px solid transparent;white-space:nowrap;transition:color .15s ease,border-color .15s ease}
.nav .links a:hover,.nav .brand:hover{color:var(--ui-hot);text-decoration:none}
.nav .brand:hover{color:var(--ui-ink)}
.nav .links a[aria-current="page"]{color:var(--ui-ink);border-bottom-color:var(--ui-accent)}
.nav .links a.cta{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:8px 16px;border-radius:10px;font-weight:600;background:var(--ui-accent);color:var(--ui-accent-ink);border:1px solid var(--ui-accent);transition:background .15s ease,transform .15s ease}
.nav .links a.cta:hover{background:var(--ui-hover);border-color:var(--ui-hover);color:var(--ui-accent-ink);transform:translateY(-1px)}
.burger{display:none;background:none;border:1px solid var(--ui-line-2);color:var(--ui-ink);border-radius:10px;width:44px;height:44px;align-items:center;justify-content:center;cursor:pointer;padding:0;flex:0 0 auto}
.burger .x{display:none}.burger[aria-expanded="true"] .x{display:block}.burger[aria-expanded="true"] .bars{display:none}
@media (max-width:940px){
.nav .links{display:none;position:absolute;left:0;right:0;top:100%;background:var(--ui-menu);border-bottom:1px solid var(--ui-line);box-shadow:0 24px 40px rgba(0,0,0,.35);padding:10px var(--gutter,16px) 18px;flex-direction:column;align-items:stretch;gap:2px}
.nav .links a{padding:13px 10px;border-radius:10px;border-bottom:0;font-size:16px}
.nav .links a[aria-current="page"]{background:var(--ui-tint)}
.nav .links a.cta{margin-top:8px;min-height:48px}
.nav .links.open{display:flex}
.burger{display:inline-flex}
}
/* footer, the same on every page */
.foot{border-top:1px solid var(--ui-line);color:var(--ui-ink);margin-top:var(--sec,64px)}
.foot .wrap{padding-block:48px 32px}
.foot-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,200px),1fr));gap:32px}
.foot-brand{display:flex;flex-direction:column;gap:12px;max-width:34ch}
.foot-brand .brand{align-self:flex-start}.foot-brand .word{font-size:16px}
.foot-brand p{color:var(--ui-ash);font-size:14px;margin:0}
.foot-col{display:flex;flex-direction:column;gap:10px;font-size:15px}
.foot-col .eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ui-ash);margin-bottom:4px}
.foot-col a{color:var(--ui-ink-2);text-decoration:none;display:inline-flex;align-items:center;gap:6px;align-self:flex-start}
.foot-col a:hover{color:var(--ui-hot);text-decoration:none}
.foot-base{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px 24px;margin-top:36px;padding-top:20px;border-top:1px solid var(--ui-line);font-size:13px;color:var(--ui-ash)}
.foot-base span{text-wrap:balance}.foot-base .mono{font-family:var(--f-mono)}
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}
</style>
<!-- head:end -->
<style>
:root{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-ink:#0C0C0E;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ok:#7BD88F;--bad:#FF8A6A;
--max:1200px;--gutter:clamp(16px,4vw,32px);--sec:clamp(56px,8vw,96px);--fs-h1:clamp(32px,5.5vw,56px);--fs-h3:clamp(18px,2vw,22px)}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:flex;flex-direction:column;background:var(--obsidian);color:var(--bone);font-family:var(--f-sans);font-size:17px;line-height:1.55;-webkit-font-smoothing:antialiased}
.wrap{max-width:var(--max);margin:0 auto;padding-inline:var(--gutter);width:100%}
main{flex:1}
a{color:var(--ember)}
.eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash)}
h1{font-family:var(--f-display);font-weight:900;font-size:var(--fs-h1);line-height:1.05;margin:12px 0 16px}
h2{font-family:var(--f-display);font-weight:700;font-size:var(--fs-h3);line-height:1.3;margin:0 0 10px}
p{margin:0;color:var(--ink-2);max-width:60ch}
.head{padding-top:var(--sec);padding-bottom:32px}
.grid{display:grid;gap:24px;grid-template-columns:minmax(0,1fr)}
@media(min-width:900px){.grid{grid-template-columns:minmax(0,1.2fr) minmax(0,.8fr);gap:40px}}
.card{border:1px solid var(--line);border-radius:18px;background:var(--graphite);padding:clamp(18px,3vw,28px)}
form{display:flex;flex-direction:column;gap:12px;margin-top:16px}
label{font-size:14px;color:var(--ash)}
input{width:100%;min-height:52px;padding:12px 14px;border-radius:12px;border:1px solid var(--line-2);background:var(--obsidian);color:var(--bone);font:500 16px/1.3 var(--f-mono);letter-spacing:.01em}
input:focus{outline:2px solid var(--ember);outline-offset:2px;border-color:var(--ember)}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:50px;padding:12px 22px;border-radius:12px;font:600 16px/1.2 var(--f-sans);border:1px solid var(--ember);color:var(--ember-ink);background:var(--ember);cursor:pointer;transition:background .15s ease,transform .15s ease}
.btn:hover{background:#FF6A2B;border-color:#FF6A2B;transform:translateY(-1px)}
.btn[disabled]{opacity:.6;cursor:default;transform:none}
.status{min-height:28px;margin-top:4px;font-size:15px;color:var(--ink-2);overflow-wrap:anywhere}
.status[data-state="ok"]{color:var(--ok)}
.status[data-state="bad"]{color:var(--bad)}
.status code{font-family:var(--f-mono);font-size:14px;background:var(--obsidian);padding:1px 6px;border-radius:6px}
.rules{display:grid;gap:12px;margin-top:8px}
.rule{display:flex;gap:12px;align-items:flex-start;padding:14px 16px;border:1px solid var(--line);border-radius:14px;background:var(--obsidian)}
.rule b{display:block;font-weight:600;color:var(--bone)}
.rule span{font-size:14px;color:var(--ash)}
.num{font-family:var(--f-mono);font-size:12px;color:var(--ember);padding-top:4px;min-width:22px}
.notice{margin-top:14px;padding:12px 14px;border-radius:12px;border:1px dashed var(--line-2);font-size:14px;color:var(--molten)}
dl{display:grid;grid-template-columns:auto 1fr;gap:6px 16px;margin:14px 0 0;font-size:15px}
dt{color:var(--ash)}dd{margin:0;font-family:var(--f-mono);font-size:14px;overflow-wrap:anywhere}
.foot{margin-top:var(--sec)}
@media (prefers-reduced-motion:reduce){.btn:hover{transform:none}}
</style>
</head>
<body>
<!-- nav:start -->
<a class="skip" href="#main">Skip to content</a>
<nav class="nav" aria-label="Main">
<div class="wrap">
<a href="/" class="brand" aria-label="Igneum home">
<svg class="mark" viewBox="0 0 1024 1024" width="36" height="36" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg>
<span class="word">IGNEUM</span>
</a>
<button class="burger" id="nav-burger" type="button" aria-expanded="false" aria-controls="nav-links" aria-label="Menu"><svg class="bars" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"></path></svg><svg class="x" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"></path></svg></button>
<div class="links" id="nav-links">
<a href="/litepaper" data-nav="litepaper">Litepaper</a>
<a href="/live" data-nav="live">Live devnet</a>
<a href="/bench" data-nav="bench">Engineering log</a>
<a href="/miner" data-nav="miner">Miner</a>
<a href="/wallet" data-nav="wallet">Wallet</a>
<a href="/evidence" data-nav="evidence">Evidence</a>
<a href="https://github.com/igneum-network/spec" rel="noopener">GitHub</a>
<a href="/miner#get" class="cta">Get the miner</a>
</div>
</div>
</nav>
<script>
(function(){
// the menu: open on the button, closed by any link, a tap outside, Escape (focus back on the button) or a resize past 940 px
var b=document.getElementById('nav-burger'),l=document.getElementById('nav-links');if(!b||!l)return;
function set(o){l.classList.toggle('open',o);b.setAttribute('aria-expanded',o?'true':'false');}
b.addEventListener('click',function(){set(!l.classList.contains('open'));});
l.addEventListener('click',function(e){if(e.target.closest('a'))set(false);});
document.addEventListener('click',function(e){if(l.classList.contains('open')&&!l.contains(e.target)&&!b.contains(e.target))set(false);});
document.addEventListener('keydown',function(e){if(e.key==='Escape'&&l.classList.contains('open')){set(false);b.focus();}});
var mq=window.matchMedia('(min-width:941px)');if(mq.addEventListener)mq.addEventListener('change',function(){set(false);});
})();
</script>
<!-- nav:end -->
<main id="main" class="wrap">
<div class="head">
<div class="eyebrow">Testnet faucet</div>
<h1>10 IGN a day, for testing.</h1>
<p>Paste an address. The faucet sends 10 testnet IGN and shows the transaction hash. Once per address per day, once per connection per day. No account, no sign-in.</p>
<p data-testnet-notice class="notice">Public testnet: not yet open. The faucet is prepared on the devnet and answers "not open yet" until the testnet starts.</p>
</div>
<div class="grid">
<section class="card" aria-labelledby="ask">
<h2 id="ask">Ask for 10 IGN</h2>
<p>An Ethereum-style address: 0x and 40 hex characters. The one the miner made for you, or any wallet set to the Igneum testnet.</p>
<form id="faucet-form" novalidate>
<label for="address">Address</label>
<input id="address" name="address" inputmode="text" autocomplete="off" spellcheck="false" placeholder="0x…" required pattern="^0x[0-9a-fA-F]{40}$" aria-describedby="status">
<button class="btn" id="send" type="submit">Send 10 IGN</button>
<div class="status" id="status" role="status" aria-live="polite"></div>
</form>
</section>
<aside class="card" aria-labelledby="rules">
<h2 id="rules">The rules</h2>
<div class="rules">
<div class="rule"><span class="num">01</span><div><b>No value</b><span>Testnet IGN cannot be sold, bought or redeemed, now or at mainnet. Mainnet starts from an empty genesis.</span></div></div>
<div class="rule"><span class="num">02</span><div><b>10 IGN per address per day</b><span>And 10 per connection per day. The limit resets 24 hours after the last grant.</span></div></div>
<div class="rule"><span class="num">03</span><div><b>Resets</b><span>The chain restarts from a fresh genesis when a consensus rule changes. Balances do not carry over.</span></div></div>
<div class="rule"><span class="num">04</span><div><b>What is stored</b><span>The address, the connection's IP, the transaction hash and the time, for 24 hours of rate limiting. Nothing else.</span></div></div>
</div>
<dl>
<dt>Chain id</dt><dd>4462 (testnet), 4463 (devnet)</dd>
<dt>Wallet set-up</dt><dd><a href="/metamask">MetaMask: chain id, RPC, one click</a></dd>
<dt>Mine instead</dt><dd><a href="/miner#get">Get the miner</a></dd>
</dl>
</aside>
</div>
</main>
<!-- footer:start -->
<footer class="foot">
<div class="wrap">
<div class="foot-grid">
<div class="foot-brand">
<a href="/" class="brand" aria-label="Igneum home"><svg class="mark" viewBox="0 0 1024 1024" width="30" height="30" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg><span class="word">IGNEUM</span></a>
<p>Mined by GPUs. Proven by fire.</p>
</div>
<nav class="foot-col" aria-label="Read">
<div class="eyebrow">Read</div>
<a href="/litepaper">Litepaper</a>
<a href="/litepaper#limits">What Igneum does not claim</a>
<a href="/litepaper#randomx">Igneum vs RandomX</a>
<a href="/litepaper#shoulders">Built on the shoulders</a>
<a href="/bench">Engineering log</a>
<a href="/evidence">Evidence</a>
</nav>
<nav class="foot-col" aria-label="Run">
<div class="eyebrow">Run</div>
<a href="/miner">The miner</a>
<a href="/wallet">The wallet</a>
<a href="/miners">GPU bench table</a>
<a href="/miner#fee">The dev fee</a>
<a href="/faucet">Testnet faucet</a>
</nav>
<nav class="foot-col" aria-label="Follow">
<div class="eyebrow">Follow</div>
<a href="/live">Live devnet</a>
<a href="/#journey">Journey</a>
<a href="/wallet">Add Igneum to MetaMask</a>
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
<a href="/miner#get">Get the miner</a>
</nav>
</div>
<div class="foot-base">
<span>© 2026 Igneum. Nothing on this page is an offer to sell anything.</span>
<span class="mono">igneum.network</span>
</div>
</div>
</footer>
<!-- footer:end -->
<script>
(function(){
var form=document.getElementById('faucet-form'),input=document.getElementById('address'),btn=document.getElementById('send'),out=document.getElementById('status');
function say(text,state,html){out.setAttribute('data-state',state||'');if(html){out.innerHTML=html;}else{out.textContent=text;}}
form.addEventListener('submit',function(e){
e.preventDefault();
var a=input.value.trim();
if(!/^0x[0-9a-fA-F]{40}$/.test(a)){say('That is not an address. It is 0x followed by 40 hex characters.','bad');input.focus();return;}
btn.disabled=true;say('Sending…','');
fetch('/api/faucet',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({address:a})})
.then(function(r){return r.json().then(function(j){return {status:r.status,body:j};});})
.then(function(r){
if(r.body&&r.body.ok){var h=String(r.body.tx||'');say('',"ok",'Sent 10 IGN. Transaction <code>'+h.replace(/[^0-9a-fx]/gi,'')+'</code>. It is in a block within a few seconds.');}
else{say((r.body&&r.body.error)||('The faucet answered '+r.status+'.'),'bad');}
})
.catch(function(){say('The faucet did not answer. Try again in a minute.','bad');})
.then(function(){btn.disabled=false;});
});
})();
</script>
</body>
</html>

View file

@ -499,10 +499,12 @@ pre{margin:0;font-family:var(--f-mono);font-size:13px;line-height:1.6;color:var(
<div class="pill"><b>Tunes itself, in public</b><span>Kernel variants raced every hour, hash per watt swept, every number in the bench table.</span></div>
</div>
<div class="cta" style="margin-top:24px;align-items:center">
<a href="/miner" class="btn dark">Read more about the miner</a>
<a href="/miner#get" class="btn" style="color:#0C0C0E;border-color:#0C0C0E">Windows · macOS · Linux · HiveOS</a>
<a data-dl="miner-windows" href="https://dl.igneum.network/public/igneum-miner-windows.exe" class="btn dark">Windows <span data-dl-meta="miner-windows" style="font-weight:400;opacity:.8">v0.3.8 · 19.8 MB</span></a>
<a data-dl="miner-mac" href="https://dl.igneum.network/public/igneum-miner-mac.dmg" class="btn dark">macOS <span data-dl-meta="miner-mac" style="font-weight:400;opacity:.8">v0.3.8 · 41.2 MB</span></a>
<a href="/miner#get" class="btn" style="color:#0C0C0E;border-color:#0C0C0E">Linux · HiveOS</a>
</div>
<p style="margin-top:16px;font-size:14px;color:#55534F">The protocol carries no fee. The <span data-product="name">Ember</span> software takes an optional 1% dev fee, like other GPU miners, off with one flag. Download only from this domain. Nobody from Igneum will ask for your seed.</p>
<p data-testnet-notice style="margin-top:14px;font-size:14px;color:#0C0C0E;font-weight:600">Public testnet: not yet open; the devnet build is here for people who want to look.</p>
<p style="margin-top:12px;font-size:14px;color:#55534F"><a href="/miner" style="color:#0C0C0E">Read more about the miner</a>. The protocol carries no fee. The <span data-product="name">Ember</span> software takes an optional 1% dev fee, like other GPU miners, off with one flag. Download only from this domain. Nobody from Igneum will ask for your seed.</p>
</div>
</div>
<div class="card reveal" id="testnet-terms" style="margin-top:32px;display:flex;flex-direction:column;gap:12px">
@ -635,6 +637,7 @@ pre{margin:0;font-family:var(--f-mono);font-size:13px;line-height:1.6;color:var(
<a href="/wallet">The wallet</a>
<a href="/miners">GPU bench table</a>
<a href="/miner#fee">The dev fee</a>
<a href="/faucet">Testnet faucet</a>
</nav>
<nav class="foot-col" aria-label="Follow">
<div class="eyebrow">Follow</div>

165
site/lib/eth.mjs Normal file
View file

@ -0,0 +1,165 @@
// Ethereum signing with no dependencies, for the faucet function (site/api/faucet.mjs). The site project installs
// nothing at build time (its install command is `echo skip`), so viem is not an option there; this is the minimum:
// keccak-256, RLP, secp256k1 ECDSA with RFC 6979 nonces, the EIP-1559 envelope and the address of a key.
// Checked against the live devnet node (eth_sendRawTransaction accepts the transaction and the receipt's `from` is
// this key's address) and against known keccak vectors in site/api/faucet.test.mjs.
import { createHmac } from 'node:crypto';
// ---- keccak-256 (the original Keccak padding 0x01..0x80, not SHA-3's 0x06) ------------------------------------------
const RC = [
0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an, 0x8000000080008000n, 0x000000000000808bn, 0x0000000080000001n,
0x8000000080008081n, 0x8000000000008009n, 0x000000000000008an, 0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an,
0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n, 0x8000000000008003n, 0x8000000000008002n, 0x8000000000000080n,
0x000000000000800an, 0x800000008000000an, 0x8000000080008081n, 0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n,
];
// rotation offsets r[x + 5y]
const ROT = [0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14];
const M64 = (1n << 64n) - 1n;
const rotl = (v, n) => n === 0 ? v : (((v << BigInt(n)) | (v >> BigInt(64 - n))) & M64);
function keccakF(A) {
const C = new Array(5), D = new Array(5), B = new Array(25);
for (let round = 0; round < 24; round++) {
for (let x = 0; x < 5; x++) C[x] = A[x] ^ A[x + 5] ^ A[x + 10] ^ A[x + 15] ^ A[x + 20];
for (let x = 0; x < 5; x++) D[x] = C[(x + 4) % 5] ^ rotl(C[(x + 1) % 5], 1);
for (let i = 0; i < 25; i++) A[i] ^= D[i % 5];
for (let x = 0; x < 5; x++) for (let y = 0; y < 5; y++) B[y + 5 * ((2 * x + 3 * y) % 5)] = rotl(A[x + 5 * y], ROT[x + 5 * y]);
for (let x = 0; x < 5; x++) for (let y = 0; y < 5; y++) A[x + 5 * y] = B[x + 5 * y] ^ ((~B[(x + 1) % 5 + 5 * y] & M64) & B[(x + 2) % 5 + 5 * y]);
A[0] ^= RC[round];
}
}
export function keccak256(bytes) {
const rate = 136;
const padded = new Uint8Array(Math.ceil((bytes.length + 1) / rate) * rate);
padded.set(bytes); padded[bytes.length] ^= 0x01; padded[padded.length - 1] ^= 0x80;
const A = new Array(25).fill(0n);
for (let off = 0; off < padded.length; off += rate) {
for (let i = 0; i < rate / 8; i++) {
let lane = 0n;
for (let b = 7; b >= 0; b--) lane = (lane << 8n) | BigInt(padded[off + i * 8 + b]);
A[i] ^= lane;
}
keccakF(A);
}
const out = new Uint8Array(32);
for (let i = 0; i < 4; i++) { let lane = A[i]; for (let b = 0; b < 8; b++) { out[i * 8 + b] = Number(lane & 0xffn); lane >>= 8n; } }
return out;
}
// ---- bytes and hex --------------------------------------------------------------------------------------------------
export const hex = bytes => '0x' + Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
export function unhex(s) {
const h = String(s).replace(/^0x/i, '');
if (h.length % 2 || /[^0-9a-f]/i.test(h)) throw new Error(`bad hex ${s}`);
const out = new Uint8Array(h.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
return out;
}
const bigToBytes = (v, len) => { const h = v.toString(16).padStart(len * 2, '0'); return unhex(h); };
const bytesToBig = b => { let v = 0n; for (const x of b) v = (v << 8n) | BigInt(x); return v; };
const concat = (...parts) => { const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return out; };
const utf8 = s => new TextEncoder().encode(s);
// ---- RLP -----------------------------------------------------------------------------------------------------------
// an item is a Uint8Array, a bigint or number (minimal big-endian, 0 = empty), a hex string, or an array of items
function toBytes(item) {
if (item instanceof Uint8Array) return item;
if (typeof item === 'bigint' || typeof item === 'number') { const v = BigInt(item); if (v < 0n) throw new Error('negative'); if (v === 0n) return new Uint8Array(0); let h = v.toString(16); if (h.length % 2) h = '0' + h; return unhex(h); }
if (typeof item === 'string') return unhex(item);
throw new Error('rlp: unsupported item');
}
function rlpLength(len, offset) {
if (len < 56) return new Uint8Array([offset + len]);
const l = toBytes(len);
return concat(new Uint8Array([offset + 55 + l.length]), l);
}
export function rlp(item) {
if (Array.isArray(item)) { const body = concat(...item.map(rlp)); return concat(rlpLength(body.length, 0xc0), body); }
const b = toBytes(item);
if (b.length === 1 && b[0] < 0x80) return b;
return concat(rlpLength(b.length, 0x80), b);
}
// ---- secp256k1 ------------------------------------------------------------------------------------------------------
const P = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn;
export const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
const G = [0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n, 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n];
const mod = (a, m) => { const r = a % m; return r < 0n ? r + m : r; };
function inv(a, m) { // extended Euclid
let lm = 1n, hm = 0n, low = mod(a, m), high = m;
if (low === 0n) throw new Error('no inverse of 0');
while (low > 1n) { const r = high / low; [lm, hm] = [hm - lm * r, lm]; [low, high] = [high - low * r, low]; }
return mod(lm, m);
}
function pointAdd(a, b) {
if (!a) return b; if (!b) return a;
const [x1, y1] = a, [x2, y2] = b;
if (x1 === x2) { if (mod(y1 + y2, P) === 0n) return null; return pointDouble(a); }
const l = mod((y2 - y1) * inv(x2 - x1, P), P);
const x3 = mod(l * l - x1 - x2, P);
return [x3, mod(l * (x1 - x3) - y1, P)];
}
function pointDouble(a) {
const [x, y] = a;
const l = mod(3n * x * x * inv(2n * y, P), P);
const x3 = mod(l * l - 2n * x, P);
return [x3, mod(l * (x - x3) - y, P)];
}
function pointMul(k, point = G) {
let r = null, q = point;
for (let e = mod(k, N); e > 0n; e >>= 1n) { if (e & 1n) r = pointAdd(r, q); q = pointDouble(q); }
return r;
}
export function publicKey(priv) { // uncompressed, 64 bytes (x || y), no 0x04 prefix
const d = bytesToBig(unhex(priv));
if (d <= 0n || d >= N) throw new Error('private key out of range');
const [x, y] = pointMul(d);
return concat(bigToBytes(x, 32), bigToBytes(y, 32));
}
export function addressOf(priv) { return hex(keccak256(publicKey(priv)).slice(12)); }
// RFC 6979 nonce for secp256k1 with HMAC-SHA256 (deterministic: the same key and hash give the same signature)
function rfc6979(privBytes, hash) {
const hmac = (k, ...msgs) => { const h = createHmac('sha256', k); for (const m of msgs) h.update(m); return new Uint8Array(h.digest()); };
let v = new Uint8Array(32).fill(1), k = new Uint8Array(32);
const x = privBytes, h1 = bigToBytes(mod(bytesToBig(hash), N), 32);
k = hmac(k, v, new Uint8Array([0]), x, h1); v = hmac(k, v);
k = hmac(k, v, new Uint8Array([1]), x, h1); v = hmac(k, v);
for (;;) {
v = hmac(k, v);
const t = bytesToBig(v);
if (t >= 1n && t < N) return t;
k = hmac(k, v, new Uint8Array([0])); v = hmac(k, v);
}
}
// returns {r, s, v} with low s and the recovery id v (0 or 1)
export function sign(hash, priv) {
const privBytes = unhex(priv); const d = bytesToBig(privBytes);
if (d <= 0n || d >= N) throw new Error('private key out of range');
const z = mod(bytesToBig(hash), N);
for (let attempt = 0; attempt < 8; attempt++) {
const k = rfc6979(privBytes, attempt ? keccak256(concat(hash, new Uint8Array([attempt]))) : hash);
const R = pointMul(k);
const r = mod(R[0], N);
if (r === 0n) continue;
let s = mod(inv(k, N) * (z + r * d), N);
if (s === 0n) continue;
let v = Number(R[1] & 1n);
if (s > N / 2n) { s = N - s; v ^= 1; }
return { r, s, v };
}
throw new Error('no signature');
}
// ---- the EIP-1559 transaction ------------------------------------------------------------------------------------------
// fields: chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gas, to (hex), value, data (hex, default 0x)
export function signTransaction(tx, priv) {
const fields = [BigInt(tx.chainId), BigInt(tx.nonce), BigInt(tx.maxPriorityFeePerGas), BigInt(tx.maxFeePerGas), BigInt(tx.gas), unhex(tx.to), BigInt(tx.value), unhex(tx.data || '0x'), []];
const signingHash = keccak256(concat(new Uint8Array([2]), rlp(fields)));
const { r, s, v } = sign(signingHash, priv);
const raw = concat(new Uint8Array([2]), rlp([...fields, BigInt(v), r, s]));
return { raw: hex(raw), hash: hex(keccak256(raw)), v, r, s };
}
export const toWei = ign => BigInt(Math.round(Number(ign) * 1e6)) * 10n ** 12n; // whole-number IGN or up to 6 decimals
export const isAddress = s => typeof s === 'string' && /^0x[0-9a-fA-F]{40}$/.test(s);
export { utf8, bytesToBig, bigToBytes };

View file

@ -758,6 +758,7 @@ body.all .pager{display:none}
<a href="/wallet">The wallet</a>
<a href="/miners">GPU bench table</a>
<a href="/miner#fee">The dev fee</a>
<a href="/faucet">Testnet faucet</a>
</nav>
<nav class="foot-col" aria-label="Follow">
<div class="eyebrow">Follow</div>

View file

@ -291,6 +291,7 @@ main{padding-bottom:var(--sec)}
<a href="/wallet">The wallet</a>
<a href="/miners">GPU bench table</a>
<a href="/miner#fee">The dev fee</a>
<a href="/faucet">Testnet faucet</a>
</nav>
<nav class="foot-col" aria-label="Follow">
<div class="eyebrow">Follow</div>

View file

@ -229,7 +229,7 @@ pre{margin:0 0 16px;padding:16px 18px;background:var(--graphite);border-radius:v
<h2 id="app-wallet">The app and the Igneum Wallet</h2>
<p class="note">The Igneum Miner app makes an address for your earnings and shows you its seed phrase once. That address is an ordinary Ethereum account: import the seed into MetaMask and the balance is there. An Igneum Wallet with the Apps tab and the explorer built in is in the roadmap; until it ships, MetaMask is the wallet.</p>
<p class="asof">Chain ids 4461 (mainnet), 4462 (testnet) and 4463 (devnet) are fixed in the node. The testnet RPC URL above is a placeholder until the testnet opens; this page is updated the day it does.</p>
<p class="asof">Testnet coin for testing: <a href="/faucet">the faucet</a> sends 10 IGN per address per day. Chain ids 4461 (mainnet), 4462 (testnet) and 4463 (devnet) are fixed in the node. The testnet RPC URL above is a placeholder until the testnet opens; this page is updated the day it does.</p>
</main>
<!-- footer:start -->
<footer class="foot">
@ -254,6 +254,7 @@ pre{margin:0 0 16px;padding:16px 18px;background:var(--graphite);border-radius:v
<a href="/wallet">The wallet</a>
<a href="/miners">GPU bench table</a>
<a href="/miner#fee">The dev fee</a>
<a href="/faucet">Testnet faucet</a>
</nav>
<nav class="foot-col" aria-label="Follow">
<div class="eyebrow">Follow</div>

View file

@ -453,15 +453,22 @@ pre b{color:var(--molten);font-weight:500}
<div class="wrap">
<div class="sec-head reveal">
<h2>Get the miner</h2>
<p>Public testnet first. Until then the devnet runs on machines we invite. Download only from this domain. Nobody from Igneum will ask for your seed.</p>
<p>Download only from this domain. Nobody from Igneum will ask for your seed. Every file below is the one the app's signed manifest names, with its version and size.</p>
<p data-testnet-notice style="margin-top:10px;font-weight:600;color:var(--molten)">Public testnet: not yet open; the devnet build is here for people who want to look.</p>
</div>
<div class="cta reveal" style="align-items:center">
<a href="/#journey" class="btn"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M3 5.5l7.5-1v7H3zM11.5 4.3L21 3v8.5h-9.5zM3 12.5h7.5v7L3 18.5zM11.5 12.5H21V21l-9.5-1.3z"/></svg>Windows</a>
<a href="/#journey" class="btn"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M16.4 12.6c0-2.3 1.9-3.4 2-3.5-1.1-1.6-2.8-1.8-3.4-1.8-1.4-.1-2.8.8-3.5.8-.7 0-1.8-.8-3-.8-1.5 0-3 .9-3.8 2.3-1.6 2.8-.4 7 1.2 9.3.8 1.1 1.7 2.4 2.9 2.3 1.2 0 1.6-.7 3-.7s1.8.7 3 .7c1.3 0 2-1.1 2.8-2.3.9-1.3 1.2-2.6 1.3-2.6-.1 0-2.5-.9-2.5-3.7zM14.1 5.8c.6-.8 1.1-1.9.9-3-.9 0-2 .6-2.7 1.4-.6.7-1.1 1.8-1 2.9 1.1.1 2.1-.5 2.8-1.3z"/></svg>macOS</a>
<a href="/#journey" class="btn"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M12 2c-2.4 0-4 1.9-4 4.6 0 1.2.2 2 0 2.8-.6 1.4-2.1 2.9-2.6 4.9-.3 1.1-.1 2 .3 2.6-.6.4-1.3 1-1.1 1.7.3 1 2.1 1.2 3.2 1.8.7.4 1.5.6 2.1.1.6.2 1.3.3 2.1.3s1.5-.1 2.1-.3c.6.5 1.4.3 2.1-.1 1.1-.6 2.9-.8 3.2-1.8.2-.7-.5-1.3-1.1-1.7.4-.6.6-1.5.3-2.6-.5-2-2-3.5-2.6-4.9-.2-.8 0-1.6 0-2.8C16 3.9 14.4 2 12 2zm-1.4 4.2c.5 0 .8.5.8 1.2s-.3 1.2-.8 1.2-.8-.5-.8-1.2.3-1.2.8-1.2zm2.8 0c.5 0 .8.5.8 1.2s-.3 1.2-.8 1.2-.8-.5-.8-1.2.3-1.2.8-1.2zM12 9.3c.9 0 1.9.5 1.9 1s-1 1.2-1.9 1.2-1.9-.7-1.9-1.2 1-1 1.9-1zm0 3.4c2.2 0 3.6 2.6 3.6 4.4 0 1.5-1.6 2.3-3.6 2.3s-3.6-.8-3.6-2.3c0-1.8 1.4-4.4 3.6-4.4z"/></svg>Linux</a>
<a href="/#journey" class="btn"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><path d="M12 2.5l8.2 4.75v9.5L12 21.5l-8.2-4.75v-9.5z"/><path d="M12 7.5l4.3 2.5v5L12 17.5l-4.3-2.5v-5z"/></svg>HiveOS</a>
<a data-dl="miner-windows" href="https://dl.igneum.network/public/igneum-miner-windows.exe" class="btn primary"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M3 5.5l7.5-1v7H3zM11.5 4.3L21 3v8.5h-9.5zM3 12.5h7.5v7L3 18.5zM11.5 12.5H21V21l-9.5-1.3z"/></svg>Windows <span data-dl-meta="miner-windows" style="font-weight:400;opacity:.85">v0.3.8 · 19.8 MB</span></a>
<a data-dl="miner-mac" href="https://dl.igneum.network/public/igneum-miner-mac.dmg" class="btn primary"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M16.4 12.6c0-2.3 1.9-3.4 2-3.5-1.1-1.6-2.8-1.8-3.4-1.8-1.4-.1-2.8.8-3.5.8-.7 0-1.8-.8-3-.8-1.5 0-3 .9-3.8 2.3-1.6 2.8-.4 7 1.2 9.3.8 1.1 1.7 2.4 2.9 2.3 1.2 0 1.6-.7 3-.7s1.8.7 3 .7c1.3 0 2-1.1 2.8-2.3.9-1.3 1.2-2.6 1.3-2.6-.1 0-2.5-.9-2.5-3.7zM14.1 5.8c.6-.8 1.1-1.9.9-3-.9 0-2 .6-2.7 1.4-.6.7-1.1 1.8-1 2.9 1.1.1 2.1-.5 2.8-1.3z"/></svg>macOS <span data-dl-meta="miner-mac" style="font-weight:400;opacity:.85">v0.3.8 · 41.2 MB</span></a>
<a data-dl="miner-hive" href="https://dl.igneum.network/public/igneum-miner-hive.tar.gz" class="btn"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M12 2c-2.4 0-4 1.9-4 4.6 0 1.2.2 2 0 2.8-.6 1.4-2.1 2.9-2.6 4.9-.3 1.1-.1 2 .3 2.6-.6.4-1.3 1-1.1 1.7.3 1 2.1 1.2 3.2 1.8.7.4 1.5.6 2.1.1.6.2 1.3.3 2.1.3s1.5-.1 2.1-.3c.6.5 1.4.3 2.1-.1 1.1-.6 2.9-.8 3.2-1.8.2-.7-.5-1.3-1.1-1.7.4-.6.6-1.5.3-2.6-.5-2-2-3.5-2.6-4.9-.2-.8 0-1.6 0-2.8C16 3.9 14.4 2 12 2zm-1.4 4.2c.5 0 .8.5.8 1.2s-.3 1.2-.8 1.2-.8-.5-.8-1.2.3-1.2.8-1.2zm2.8 0c.5 0 .8.5.8 1.2s-.3 1.2-.8 1.2-.8-.5-.8-1.2.3-1.2.8-1.2zM12 9.3c.9 0 1.9.5 1.9 1s-1 1.2-1.9 1.2-1.9-.7-1.9-1.2 1-1 1.9-1zm0 3.4c2.2 0 3.6 2.6 3.6 4.4 0 1.5-1.6 2.3-3.6 2.3s-3.6-.8-3.6-2.3c0-1.8 1.4-4.4 3.6-4.4z"/></svg>Linux <span data-dl-meta="miner-hive" style="font-weight:400;opacity:.85">v0.3.8 · 24.8 MB</span></a>
<a data-dl="miner-hive" href="https://dl.igneum.network/public/igneum-miner-hive.tar.gz" class="btn"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><path d="M12 2.5l8.2 4.75v9.5L12 21.5l-8.2-4.75v-9.5z"/><path d="M12 7.5l4.3 2.5v5L12 17.5l-4.3-2.5v-5z"/></svg>HiveOS <span data-dl-meta="miner-hive" style="font-weight:400;opacity:.85">v0.3.8 · 24.8 MB</span></a>
</div>
<p class="pt reveal" style="margin-top:20px">Any 4 GB card. The dataset starts at 2 GB and grows by half a gigabyte a year, approximate. Updates arrive signed; the app installs them itself. The <a href="/wallet" style="color:var(--ember)">wallet</a> reads this machine's node when the miner is installed.</p>
<div class="card reveal" id="hive" style="margin-top:28px;overflow-wrap:anywhere;word-break:break-word;min-width:0">
<div class="eyebrow">HiveOS · Flight Sheet</div>
<h3 style="margin:8px 0 10px">Install on a Hive rig</h3>
<p style="font-size:15px;color:var(--ink-2)">Miner: <b>Custom</b>. Installation URL: <code data-dl-url="miner-hive">https://dl.igneum.network/dl/public/igneum-hive-0.3.8.tar.gz</code>. Miner name <code>igneum</code>, wallet and worker <code>0x&lt;your 40-hex payout address&gt;.%WORKER_NAME%</code>, pool URL <code>grpc://&lt;your node&gt;:26610</code> or <code>local</code> for the bundled node, extra config <code>DEV_FEE=1 IDENTITIES=8 WORKER=auto VOTE=1</code>. The Linux button above is the same archive: the Linux miner, node and both GPU workers. Hive itself is untested so far. Report what breaks.</p>
<p style="font-size:13px;color:var(--ash);margin-top:8px;overflow-wrap:anywhere">sha256 <span class="mono" data-dl-sha="miner-hive" style="word-break:break-all">cc2fcbcaeab799364f41d613de52960337f703070c742d0df35961484002f927</span></p>
</div>
<p class="pt reveal" style="margin-top:20px">Testnet coin for testing: <a href="/faucet" style="color:var(--ember)">the faucet</a> sends 10 IGN per address per day. Any 4 GB card. The dataset starts at 2 GB and grows by half a gigabyte a year, approximate. Updates arrive signed; the app installs them itself. The <a href="/wallet" style="color:var(--ember)">wallet</a> reads this machine's node when the miner is installed.</p>
<a href="/litepaper#miners" class="lp reveal">Read more in the litepaper</a>
</div>
</section>
@ -500,6 +507,7 @@ pre b{color:var(--molten);font-weight:500}
<a href="/wallet">The wallet</a>
<a href="/miners">GPU bench table</a>
<a href="/miner#fee">The dev fee</a>
<a href="/faucet">Testnet faucet</a>
</nav>
<nav class="foot-col" aria-label="Follow">
<div class="eyebrow">Follow</div>

View file

@ -211,6 +211,7 @@ th{font-family:var(--f-mono);font-size:12px;letter-spacing:.12em;text-transform:
<a href="/wallet">The wallet</a>
<a href="/miners">GPU bench table</a>
<a href="/miner#fee">The dev fee</a>
<a href="/faucet">Testnet faucet</a>
</nav>
<nav class="foot-col" aria-label="Follow">
<div class="eyebrow">Follow</div>

View file

@ -20,6 +20,7 @@
<a href="/wallet">The wallet</a>
<a href="/miners">GPU bench table</a>
<a href="/miner#fee">The dev fee</a>
<a href="/faucet">Testnet faucet</a>
</nav>
<nav class="foot-col" aria-label="Follow">
<div class="eyebrow">Follow</div>

View file

@ -362,11 +362,12 @@ td.num{font-variant-numeric:tabular-nums;white-space:nowrap}
<div class="wrap">
<div class="sec-head reveal">
<h2>Get the wallet</h2>
<p>Public testnet first. Download only from this domain. Nobody from Igneum will ask for your seed.</p>
<p>Download only from this domain. Nobody from Igneum will ask for your seed. The file below is the one the wallet's signed manifest names, with its version and size.</p>
<p data-testnet-notice style="margin-top:10px;font-weight:600;color:var(--molten)">Public testnet: not yet open; the devnet build is here for people who want to look.</p>
</div>
<div class="cta reveal" style="align-items:center">
<a href="/#journey" class="btn"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M16.4 12.6c0-2.3 1.9-3.4 2-3.5-1.1-1.6-2.8-1.8-3.4-1.8-1.4-.1-2.8.8-3.5.8-.7 0-1.8-.8-3-.8-1.5 0-3 .9-3.8 2.3-1.6 2.8-.4 7 1.2 9.3.8 1.1 1.7 2.4 2.9 2.3 1.2 0 1.6-.7 3-.7s1.8.7 3 .7c1.3 0 2-1.1 2.8-2.3.9-1.3 1.2-2.6 1.3-2.6-.1 0-2.5-.9-2.5-3.7zM14.1 5.8c.6-.8 1.1-1.9.9-3-.9 0-2 .6-2.7 1.4-.6.7-1.1 1.8-1 2.9 1.1.1 2.1-.5 2.8-1.3z"/></svg>macOS</a>
<a href="/#journey" class="btn"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M3 5.5l7.5-1v7H3zM11.5 4.3L21 3v8.5h-9.5zM3 12.5h7.5v7L3 18.5zM11.5 12.5H21V21l-9.5-1.3z"/></svg>Windows</a>
<a data-dl="wallet-mac" href="https://dl.igneum.network/public/igneum-wallet-mac.dmg" class="btn primary"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M16.4 12.6c0-2.3 1.9-3.4 2-3.5-1.1-1.6-2.8-1.8-3.4-1.8-1.4-.1-2.8.8-3.5.8-.7 0-1.8-.8-3-.8-1.5 0-3 .9-3.8 2.3-1.6 2.8-.4 7 1.2 9.3.8 1.1 1.7 2.4 2.9 2.3 1.2 0 1.6-.7 3-.7s1.8.7 3 .7c1.3 0 2-1.1 2.8-2.3.9-1.3 1.2-2.6 1.3-2.6-.1 0-2.5-.9-2.5-3.7zM14.1 5.8c.6-.8 1.1-1.9.9-3-.9 0-2 .6-2.7 1.4-.6.7-1.1 1.8-1 2.9 1.1.1 2.1-.5 2.8-1.3z"/></svg>macOS <span data-dl-meta="wallet-mac" style="font-weight:400;opacity:.85">v0.1.3 · 19.6 MB</span></a>
<span class="btn" aria-disabled="true" style="opacity:.7;cursor:default"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M3 5.5l7.5-1v7H3zM11.5 4.3L21 3v8.5h-9.5zM3 12.5h7.5v7L3 18.5zM11.5 12.5H21V21l-9.5-1.3z"/></svg>Windows <span style="font-weight:400;opacity:.85">next</span></span>
<a href="#metamask" class="btn"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><path d="M4 4l5 3h6l5-3-1.5 7 1.5 5-3 4H7l-3-4 1.5-5z"/><path d="M9 13h2M13 13h2"/></svg>MetaMask</a>
</div>
<p class="pt reveal" style="margin-top:20px">The wallet reads this machine's node when the <a href="/miner" style="color:var(--ember)">miner</a> is installed. Without it, a public RPC for balances and sending, or the bundled node for everything.</p>
@ -408,6 +409,7 @@ td.num{font-variant-numeric:tabular-nums;white-space:nowrap}
<a href="/wallet">The wallet</a>
<a href="/miners">GPU bench table</a>
<a href="/miner#fee">The dev fee</a>
<a href="/faucet">Testnet faucet</a>
</nav>
<nav class="foot-col" aria-label="Follow">
<div class="eyebrow">Follow</div>

View file

@ -4,7 +4,7 @@
//
// node tools/ship-app.mjs 0.3.4 --node <fork worktree> [--notes "..."] [--dry-run] [--from <step>]
// [--skip-windows | --skip-mac] [--node-commit <sha>] [--win-release <dir>] [--mac-release <dir>]
// [--min-supported 0.3.0] [--activation-height N --deadline-note "..."] [--dl-both]
// [--min-supported 0.3.0] [--activation-height N --deadline-note "..."] [--dl-both] [--public]
// node tools/ship-app.mjs --check the six version files agree (exit 1 when they do not)
// node tools/ship-app.mjs --self-test the bump, on a scratch copy of the version files
//
@ -25,7 +25,7 @@
// min_supported, checked field by field against the first
// deploy the downloads folder with the Vercel CLI (one deploy carries the files and the manifest together)
// verify HEAD and GET of the three files (size and sha256 against the local copies), the live manifest and its signature;
// with --dl-both the same for the NEXT folder
// with --dl-both the same for the NEXT folder; with --public every file and alias of dl/public/
// console one console item (tools/console.mjs post --kind build) with version, sizes and hashes, then sync-dl
//
// --dl-both (rotation phase 2, 5 October 2026, docs/plans/rotation-phase-2.md): the downloads token is being rotated.
@ -33,6 +33,10 @@
// packaging/mac/packaged-config.sh packages by default while that file exists). The version is published in BOTH
// folders so the old apps find the update and the new ones find their folder; one deploy, both verified.
//
// --public (testnet launch, 5 October 2026, packaging/ota/publish-public.sh): the manifest step also publishes the version
// into dl/public/ (no token in any URL: the site's download buttons and the per-platform aliases under /public/), the
// same deploy carries it, and verify checks every public file and alias. The token folders are never touched by it.
//
// Secrets: ~/.config/igneum/dl-token, dlsite-dir, relay-token, relay-key, ota-signing-key, vercel/ are read by this
// tool or by the scripts it calls and never printed; every output line is scrubbed of the tokens. State that is not a
// secret (commit, run id, bump time) lives in ~/.cache/igneum/ship/<version>.json. gh auth switch --user igneum-josh runs
@ -191,6 +195,7 @@ const DEST = DLSITE && TOKEN ? join(DLSITE, 'dl', TOKEN) : '';
const BASE = `https://dl.igneum.network/dl/${TOKEN}`;
// --dl-both: the NEXT folder, from ~/.config/igneum/dl-token.next
const BOTH = !!flags['dl-both'];
const PUBLIC = !!flags.public;
const TOKEN_NEXT = BOTH ? cfg('dl-token.next') : '';
const DEST_NEXT = BOTH && DLSITE && TOKEN_NEXT ? join(DLSITE, 'dl', TOKEN_NEXT) : '';
const BASE_NEXT = `https://dl.igneum.network/dl/${TOKEN_NEXT}`;
@ -211,9 +216,12 @@ const WIN_RELEASE = flags['win-release'] ? resolve(flags['win-release']) : first
const MAC_RELEASE = flags['mac-release'] ? resolve(flags['mac-release']) : firstDir([join(NODE_DIR, 'target-integration', 'release'), join(NODE_DIR, 'target', 'release')], 'igneumd');
const WIN_INPUTS = ['igneumd.exe', 'igneum-miner.exe'].map(n => join(WIN_RELEASE, n));
const WORKERS = [join(ROOT, 'proto-cuda', 'nvrtc', 'igneum-worker-cuda.exe'), join(ROOT, 'proto-opencl', 'igneum-worker-opencl.exe')];
const retryCmd = step => `node tools/ship-app.mjs ${VERSION} --node ${flags.node}${flags.notes ? ` --notes ${JSON.stringify(flags.notes)}` : ''}${WIN ? '' : ' --skip-windows'}${MAC ? '' : ' --skip-mac'}${flags['node-commit'] ? ` --node-commit ${flags['node-commit']}` : ''}${BOTH ? ' --dl-both' : ''} --from ${step}`;
const retryCmd = step => `node tools/ship-app.mjs ${VERSION} --node ${flags.node}${flags.notes ? ` --notes ${JSON.stringify(flags.notes)}` : ''}${WIN ? '' : ' --skip-windows'}${MAC ? '' : ' --skip-mac'}${PUBLIC ? ' --public' : ''}${flags['node-commit'] ? ` --node-commit ${flags['node-commit']}` : ''}${BOTH ? ' --dl-both' : ''} --from ${step}`;
// ---- --dl-both helpers (pure; the self-test runs them on scratch folders) -------------------------------------------
// the extra arguments the FIRST publish-manifest.sh call takes for the public folder (the second, --dl-both, call never
// does: dl/public/ derives from the current token folder once)
function publicArgs(isPublic) { return isPublic ? ['--public'] : []; }
// which of `names` must be copied from src to dst: 'copy' (missing or different bytes), 'same', or 'absent' (not in src)
function mirrorPlan(src, dst, names) {
return names.map(name => {
@ -481,14 +489,22 @@ async function manifest() {
if (MAC) args.push('--mac', join(DEST, DMG_NAME));
if (WIN) args.push('--win', join(DEST, SETUP_NAME));
for (const k of ['min-supported', 'activation-height', 'deadline-note', 'channel']) if (flags[k]) args.push(`--${k}`, String(flags[k]));
args.push(...publicArgs(PUBLIC));
const cmd = `packaging/ota/publish-manifest.sh ${args.map(a => a.includes(' ') ? JSON.stringify(a) : a).join(' ')}`;
if (DRY) return done('manifest', 'would', `run ${cmd.replace(TOKEN, '<token>')} (signs, verifies the signature; ${WIN && MAC ? 'both platforms' : WIN ? 'windows entry, mac carried over when the live manifest is this version' : 'mac entry, windows carried over when the live manifest is this version'})${BOTH ? '; then the same for dl/<token.next>/ with --dest and --base-url, carrying this manifest\'s override, tuning and min_supported; the two compared field by field' : ''}`);
if (DRY) return done('manifest', 'would', `run ${cmd.replace(TOKEN, '<token>')} (signs, verifies the signature; ${WIN && MAC ? 'both platforms' : WIN ? 'windows entry, mac carried over when the live manifest is this version' : 'mac entry, windows carried over when the live manifest is this version'})${BOTH ? '; then the same for dl/<token.next>/ with --dest and --base-url, carrying this manifest\'s override, tuning and min_supported; the two compared field by field' : ''}${PUBLIC ? '; and into dl/public/ with public URLs, its own signed manifest, the /public/ aliases rewritten (publish-public.sh --app [--wallet])' : ''}`);
for (const p of [MAC && join(DEST, DMG_NAME), WIN && join(DEST, SETUP_NAME)].filter(Boolean)) if (!existsSync(p)) throw new Error(`missing ${p.replace(TOKEN, '<token>')}; ${retryCmd(p.endsWith('.dmg') ? 'copy' : 'fetch')}`);
const r = await run('bash', [join(ROOT, 'packaging', 'ota', 'publish-manifest.sh'), ...args]);
if (r.code !== 0) throw new Error(`publish-manifest.sh exited ${r.code}; retry: ${cmd.replace(TOKEN, '<token>')}`);
const m = JSON.parse(readFileSync(join(DEST, 'igneum-app-latest.json'), 'utf8'));
if (m.version !== VERSION) throw new Error(`the written manifest says ${m.version}`);
if (!BOTH) return done('manifest', 'ok', `${VERSION} ${Object.keys(m.platforms).join('+')}, signed and verified locally`);
if (PUBLIC) {
const pm = JSON.parse(readFileSync(join(DLSITE, 'dl', 'public', 'igneum-app-latest.json'), 'utf8'));
if (pm.version !== VERSION) throw new Error(`dl/public/igneum-app-latest.json says ${pm.version}, not ${VERSION}`);
const diffs = manifestDifferences(m, pm, BASE, 'https://dl.igneum.network/dl/public');
if (diffs.length) throw new Error(`the public manifest differs beyond the folder and the publish time:\n ${diffs.join('\n ')}`);
say(` dl/public/: ${VERSION} ${Object.keys(pm.platforms).join('+')}, same fields, URLs under /dl/public/`);
}
if (!BOTH) return done('manifest', 'ok', `${VERSION} ${Object.keys(m.platforms).join('+')}, signed and verified locally${PUBLIC ? ', and in dl/public/' : ''}`);
// the NEXT folder: the same entries from its own copies, the same override, tuning and min_supported
const tmp = mkdtempSync(join(tmpdir(), 'igneum-ship-m-'));
try {
@ -562,12 +578,16 @@ async function verifyFolder(dest, base, files, label) {
async function verify() {
const files = [MAC && DMG_NAME, WIN && SETUP_NAME, WIN && ZIP_NAME].filter(Boolean);
if (DRY) return done('verify', 'would', `HEAD and GET ${files.join(', ')} (size and sha256 against the local files), GET the manifest and its .sig, igneum-ota-sign verify${BOTH ? '; the same for dl/<token.next>/' : ''}`);
if (DRY) return done('verify', 'would', `HEAD and GET ${files.join(', ')} (size and sha256 against the local files), GET the manifest and its .sig, igneum-ota-sign verify${BOTH ? '; the same for dl/<token.next>/' : ''}${PUBLIC ? '; every file and alias of dl/public/ (publish-public.sh --verify)' : ''}`);
const failures = await verifyFolder(DEST, BASE, files, 'current');
if (BOTH) failures.push(...await verifyFolder(DEST_NEXT, BASE_NEXT, files, 'next'));
if (PUBLIC) {
const r = await run('bash', [join(ROOT, 'packaging', 'ota', 'publish-public.sh'), '--verify', '--no-prune']);
if (r.code !== 0) failures.push('public:folder');
}
saveState();
if (failures.length) throw new Error(`not served as expected: ${failures.join(', ')}; the deploy may still be propagating. Retry: ${retryCmd('deploy')}`);
done('verify', 'ok', `${files.length} files and the manifest match the local copies${BOTH ? ' in both folders' : ''}`);
done('verify', 'ok', `${files.length} files and the manifest match the local copies${BOTH ? ' in both folders' : ''}${PUBLIC ? '; dl/public/ and the /public/ aliases serve the local bytes' : ''}`);
}
async function consoleStep() {
@ -672,6 +692,10 @@ function selfTest() {
second.consensus.override.difficulty_v2_activation_daa = 1; delete second.tuning;
const d = manifestDifferences(first, second, 'https://dl.igneum.network/dl/OLD', 'https://dl.igneum.network/dl/NEW');
check('a changed override and a dropped tuning are reported', d.length === 2 && d.some(x => x.startsWith('consensus')) && d.some(x => x.startsWith('tuning')), d.join(' | '));
// 6. --public: the first manifest call carries --public and nothing else changes; the public manifest is compared like the next folder's
check('public args', publicArgs(true).join(' ') === '--public' && publicArgs(false).length === 0);
const pub = JSON.parse(JSON.stringify(first)); pub.published_at = '2026-10-05T12:02:00Z'; pub.platforms.mac.url = 'https://dl.igneum.network/dl/public/Igneum-Miner-0.3.6.dmg';
check('a public manifest that differs only by folder and time agrees', manifestDifferences(first, pub, 'https://dl.igneum.network/dl/OLD', 'https://dl.igneum.network/dl/public').length === 0);
} finally { rmSync(dir, { recursive: true, force: true }); }
say(fails ? `${fails} check(s) failed` : 'all checks passed');
return fails ? 1 : 0;