Compare commits

..

3 commits

Author SHA1 Message Date
igneum-labs
81e14b291f finality-leave plan: the once-set digest rule of the 0.3.17 rebase, and the testnet genesis table (leave on from genesis, the project lead's decision)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:19:06 +00:00
igneum-labs
c42af11e87 Bench-log: the finality pause ended as computed, lock 7082 at 20:42:57.8Z at the frozen table's expiry (DAA 216,402), 7083 refreezes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:44:09 +00:00
igneum-labs
446ecb92d2 Finality pause of 6 October 2026: incident entry, ledger F27 and the F19 correction, spec 03 W7, the leave plan and harness case
- docs/bench-log.md: "6 October 2026, 18:40Z to 20:41Z, the live devnet's finality pause": the timeline from the hub's own
  log and the hands' logs (local time +01:00: both hands stopped 18:30:28Z, returned 18:41:54Z and 18:42:07Z, relaunched
  under launchd 18:45:57Z and 18:46:39Z), the cause in the code (rule v3's frozen table at lock 6842, 42.7 percent of it
  held by 20 keys that stopped at 17:20 to 18:30Z, expiring at DAA 216,402), what was ruled out with the line that rules
  it out (the hands, the aggregators, the "level is 0" rejects = the Igneum Wallet app's bundled 5 October igneumd on this
  Mac), and what "paused" meant per tier
- docs/fud-ledger.md: F27 (the incident as a critic will post it, with the fix and the operational rule) and the F19
  correction: a silent 40 percent under rule v3 stalls the full window, 30 days on mainnet, not "day 19 to 20" (the v2
  arithmetic)
- docs/spec/03-finality.md: W7, the departure announcement, and the Q5 note
- docs/plans/finality-leave.md: the 0.3.16 plan (what changes, why this candidate, the Devnet 2 gate, what it does not do,
  per tier)
- tools/finality-attacks/leave.mjs: the harness case on the v3 runner's shape (45 percent of weight stops with leaves;
  --silent is the known-failed case), designed and not yet run (it needs W7 binaries on the harness host); lib/net.mjs
  gains the noLeave option
- infra/fast-time/override-60x.json: leave_delay 10 and the leave switch at never, explicit

Fork: branch finality-pause-node on 4c6b129d (vendor/igneum-node-finpause).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 20:33:27 +00:00
3071 changed files with 27260 additions and 633270 deletions

View file

@ -5,7 +5,7 @@ tools: Read, Grep, Glob, Bash, Edit, Write, WebSearch, WebFetch, Agent
model: fable
---
You are the consensus engineer on a GPU-mined layer 1 built on a fork of rusty-kaspa. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the founder changes them.
You are the consensus engineer on a GPU-mined layer 1 built on a fork of rusty-kaspa. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the project lead changes them.
## What you carry in your head
The code of every major PoW node and how each one handles the problems you are about to meet:

View file

@ -5,7 +5,7 @@ tools: Read, Grep, Glob, Bash, WebSearch, WebFetch, Agent
model: fable
---
You are the cryptographer and proof-systems engineer on a GPU-mined layer 1 whose miners are also its ZK provers. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the founder changes them.
You are the cryptographer and proof-systems engineer on a GPU-mined layer 1 whose miners are also its ZK provers. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the project lead changes them.
## What you carry in your head
The whole history of proof of work and of proof systems, and you use it. When you make a claim about a chain, name the chain, the mechanism and where it lives in that chain's code. Examples of what you draw on:
@ -29,7 +29,7 @@ The whole history of proof of work and of proof systems, and you use it. When yo
- Numbers are measured or cited. A number from memory is labelled approximate. Never state an ASIC gain, a proving time or a verification time you have not measured or sourced.
- Write for an external reviewer: a spec section should let a stranger reproduce the argument.
- Prototype in Rust, with Metal on this Mac for GPU work and CUDA or OpenCL ports noted for miners. Benchmarks go in bench/ with the exact command and hardware.
- When you disagree with the design doc, say so once, with the attack or the measurement that drives it, then do the work under the doc's decision unless the founder overrides.
- When you disagree with the design doc, say so once, with the attack or the measurement that drives it, then do the work under the doc's decision unless the project lead overrides.
## Writing rules
No em dashes. Short sentences. Numbers in tables. The project is called Igneum. Approximate figures say so.

View file

@ -5,7 +5,7 @@ tools: Read, Grep, Glob, Bash, Edit, Write, WebSearch, WebFetch, Agent
model: fable
---
You are the execution engineer on a GPU-mined layer 1 whose every block is ZK-proven by its miners. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the founder changes them.
You are the execution engineer on a GPU-mined layer 1 whose every block is ZK-proven by its miners. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the project lead changes them.
## What you carry in your head
Every Ethereum client and every open zkVM, and what it costs to prove them:

View file

@ -5,7 +5,7 @@ tools: Read, Grep, Glob, Bash, WebSearch, WebFetch, Agent
model: fable
---
You are the miner-community lead on a GPU-mined layer 1 whose miners are also its ZK provers. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the founder changes them.
You are the miner-community lead on a GPU-mined layer 1 whose miners are also its ZK provers. Read CLAUDE.md in the project root first, then the design doc it links to. The doc's decisions are fixed unless the project lead changes them.
## What you carry in your head
Fifteen years of mining communities, and you remember what they did and why:

View file

@ -1,26 +0,0 @@
---
name: Grant application
about: Apply for an Igneum grant: tooling, a reference app, infrastructure or research, paid in IGN on delivery
title: "Grant: "
labels: grant
---
<!-- Read igneum.network/grants first. A short application is fine; the definition of done is the part that matters. -->
## What you will build
<!-- One paragraph. Name the tier: tooling, reference app, infrastructure, research. -->
## What done looks like
<!-- What runs where on the public testnet, and what a reviewer can click or call to see it working. -->
## Your public work
<!-- A repository, a package, a paper, a deployed thing. Links. -->
## How to reach you
<!-- An email, a Discord handle, or this issue. -->
<!-- Devnet and testnet coins have no value. No amount, price or date is promised. Not legal advice. -->

View file

@ -1,48 +0,0 @@
# The red watcher as its own workflow, on workflow_run, so the copy on master watches EVERY branch's ci run whatever
# ci.yml that branch carries: GitHub runs a workflow_run workflow from the default branch only, and the branch's own
# ci.yml never enters it (7 October 2026: the inline `red` job of ci.yml was conditioned on master and release-*, and
# a feature branch would have waited for a merge of master before its reds were posted at all).
#
# One line per failed, cancelled or timed-out run (tools/ci/red-watch.mjs record, idempotent per run attempt) to /srv/ci-red/red.jsonl on the
# box; the box's igneum-ci-red.timer posts each new line once to the hidden updates channel, naming the branch, the
# commit, the red check and the pushing author. Runs on the box's own runner (not a GitHub-hosted machine: the billing
# block of 6 October 2026, 18:37Z to 20:10Z, failed every hosted job at start and nobody was told). Never blocks a
# release: it reads the run, writes one line, and ends.
name: ci-red
on:
workflow_run:
workflows: [ci]
types: [completed]
jobs:
red:
name: red watcher (every branch; one line per failed run, with the branch, commit, red check and pushing author, to the updates channel and the box file)
# failure, and since 7 October 2026 (17:2x UK) cancelled and timed_out too: a job that hangs into its timeout-minutes or a run
# someone cancels is a run that never answered, and a lane reads it like a red (tools/ci/red-watch.mjs names the kind)
if: ${{ github.event.workflow_run.conclusion == 'failure' || github.event.workflow_run.conclusion == 'cancelled' || github.event.workflow_run.conclusion == 'timed_out' }}
# the label ci-red is on igneum-build-1 only (added through the runners API on 7 October 2026; the default of
# RUNNER_LABELS in provision.sh carries it): the record file and the poster (igneum-ci-red.timer, the webhook file)
# live on that box, and the pool label igneum-build-1 is shared with igneum-build-2 since the same day
runs-on: [self-hosted, linux, x64, ci-red]
timeout-minutes: 5
permissions:
actions: read # the failed run's jobs API (the first real red run, 21:19Z on 6 October: the default token answered 403 and the line carried no step)
contents: read
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: tools/ci
- name: record the failed run (one line, the branch, the commit, the failed jobs and their first failed step from the run's own API, the pushing author)
env:
GITHUB_TOKEN: ${{ github.token }}
RED_WATCH_RUN_ID: ${{ github.event.workflow_run.id }}
RED_WATCH_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
RED_WATCH_WORKFLOW: ${{ github.event.workflow_run.name }}
RED_WATCH_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
RED_WATCH_BRANCH: ${{ github.event.workflow_run.head_branch }}
RED_WATCH_SHA: ${{ github.event.workflow_run.head_sha }}
RED_WATCH_EVENT: ${{ github.event.workflow_run.event }}
RED_WATCH_URL: ${{ github.event.workflow_run.html_url }}
RED_WATCH_ACTOR: ${{ github.event.workflow_run.actor.login }}
RED_WATCH_TITLE: ${{ github.event.workflow_run.head_commit.message }}
RED_WATCH_AUTHOR: ${{ github.event.workflow_run.head_commit.author.name }}
run: node tools/ci/red-watch.mjs record --file /srv/ci-red/red.jsonl

View file

@ -1,23 +1,10 @@
# CI on every push and pull request (private repository, free runner minutes).
#
# What runs: the lottery-hash crate's tests (igneum-pow, release profile), the census tool's build, the two Python
# simulators' --quick modes (each under two minutes), and the tree gate: every fast check in ONE script,
# tools/ci/pre-push.sh (site build and link check, the ledger sentences, the identity grep of the public export list
# and the served site, Windows-valid paths, workflow shell syntax, the copied-sources and playbook classes, the unit
# tests, the no-secrets check). The pre-push hook runs the SAME script before a push to master or release-*, so the
# local gate and CI cannot drift (6 October 2026: 131 red `ci` runs in three days, 92 of them on master, every one a
# tree check that would have failed on the pushing machine in under 25 s; docs/analysis/ci-failures-2026-10-06.md).
#
# Where it runs: `pow` and `sims` go to the self-hosted pool (label igneum-build-1: the runners on igneum-build-1 and, since
# 7 October 2026, igneum-build-2, which carries that label too; rustc pinned, sccache read-only) and only when the push
# touched code (the `changes` job; a docs-only push skips them)
# when the repository variable IGNEUM_CI_RUNNER is `box`, else to ubuntu-latest (docs/plans/ci-self-hosted.md; GitHub
# has no fallback in runs-on, the variable is the switch). The `site` job stays on GitHub's machines. The red watcher
# is its own workflow, .github/workflows/ci-red.yml (workflow_run, so the copy on master watches every branch's run
# whatever ci.yml that branch carries): one line per failed run, naming the branch, the commit, the red check and the
# pushing author, to the hidden updates channel and to /srv/ci-red/red.jsonl (tools/ci/red-watch.mjs;
# infra/build-server/ci-red), so nobody opens the Actions page to learn a branch is red (the inline `red` job here
# watched master and release-* only until 7 October 2026, when eight red runs on ca3-v4-node went unseen).
# simulators' --quick modes (each under two minutes), the site build with an internal link check, the gh-free
# identity grep of the public export list (tools/ci/forbidden-strings.txt), and the no-secrets check of the tree
# (tools/ci/no-secrets-check.sh: no file named like a key of ~/.config/igneum, no 64-hex value assigned to a
# token/key/secret name outside tests and the allowlist; docs/security/keys.md).
#
# What does not run, on purpose: the node fork (vendor/igneum-node*, a rusty-kaspa fork of about 500 crates with
# rocksdb, blst and the execution layer) is gitignored here and too big for the free runners today (a cold build is
@ -28,42 +15,9 @@ on:
push:
pull_request:
jobs:
changes:
# What the push touched (tools/ci/docs-only-check.sh): a push of documents only (docs/, site/, *.md) skips the two
# compile-or-compute jobs below, which read none of those paths, so the self-hosted queue carries only runs that can
# change their result (7 October 2026: 31 runs queued on one runner, most of them status-document pushes). The tree
# gate (the `site` job) runs on ubuntu-latest for every push. A pull request, a new branch or a force push answers
# code=true (no `before` to compare from), as does any error reading the compare API: when in doubt, run.
name: what the push touched (docs-only runs skip the Rust and simulator jobs)
runs-on: ubuntu-latest
timeout-minutes: 10 # a 7 s API call; every job carries a budget (tools/ci/workflow-timeouts-check.sh)
outputs:
code: ${{ steps.classify.outputs.code }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: tools/ci
- id: classify
env:
GH_TOKEN: ${{ github.token }}
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.sha }}
REPO: ${{ github.repository }}
EVENT: ${{ github.event_name }}
run: |
if [ "$EVENT" != push ] || [ -z "$BEFORE" ] || [ "$BEFORE" = 0000000000000000000000000000000000000000 ]; then
echo "code=true" >> "$GITHUB_OUTPUT"; echo "no base to compare from ($EVENT): the compile jobs run"; exit 0
fi
files="$(gh api "repos/$REPO/compare/$BEFORE...$AFTER" --paginate --jq '.files[].filename' 2>/dev/null || true)"
line="$(printf '%s\n' "$files" | bash tools/ci/docs-only-check.sh)"
echo "$line" >> "$GITHUB_OUTPUT"
echo "$line: $(printf '%s\n' "$files" | grep -c .) changed path(s) between ${BEFORE:0:8} and ${AFTER:0:8}"
pow:
name: igneum-pow tests, igneum-census build
needs: changes
if: ${{ needs.changes.outputs.code == 'true' }}
runs-on: ${{ vars.IGNEUM_CI_RUNNER == 'box' && fromJSON('["self-hosted", "linux", "x64", "igneum-build-1"]') || 'ubuntu-latest' }}
timeout-minutes: 60 # the box's suite ran 45 s to 2 min 40 s on 7 October 2026; a hosted fallback compiles cold
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: toolchain
@ -78,20 +32,13 @@ jobs:
run: cargo build --release
sims:
name: simulators, quick modes
needs: changes
# master and release-* pushes, and pull requests into them, only (main, 7 October 2026: every code push cost two box jobs and the
# queue read 22); a feature-branch code push runs the igneum-pow tests alone. tools/ci/sims-branch-check.sh holds this rule.
if: ${{ needs.changes.outputs.code == 'true' && ((github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-'))) || (github.event_name == 'pull_request' && (github.base_ref == 'master' || startsWith(github.base_ref, 'release-')))) }}
runs-on: ${{ vars.IGNEUM_CI_RUNNER == 'box' && fromJSON('["self-hosted", "linux", "x64", "igneum-build-1"]') || 'ubuntu-latest' }}
timeout-minutes: 45 # two simulators under 120 s each by their own timeout, plus a hosted fallback's pip install
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
if: vars.IGNEUM_CI_RUNNER != 'box' # the box has python3 and numpy from provision.sh
with:
python-version: '3.12'
- run: python3 -m pip install --quiet numpy
if: vars.IGNEUM_CI_RUNNER != 'box'
- name: finality_v2.py --quick (under two minutes)
working-directory: sim
run: time timeout 120 python3 finality_v2.py --quick > finality_quick.md
@ -107,22 +54,59 @@ jobs:
site:
name: site build, link check, identity grep
runs-on: ubuntu-latest
# 15: the gate took 229 s on a hosted runner on 7 October 2026 plus a 40 s Playwright install; the same day three
# hosted site jobs on master hung in the gate for over two hours each with no budget, and GitHub's six-hour default
# would have ended each as a failure email. A hung job is a red the watcher posts (ci-red.yml fires on timed_out).
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: a headless Chromium for the text-overlap sweep (Playwright outside the tree; the gate finds it through IGNEUM_PLAYWRIGHT_DIR)
run: |
mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.56 | tail -1
npx playwright install --with-deps chromium | tail -1
echo "IGNEUM_PLAYWRIGHT_DIR=/tmp/pw" >> "$GITHUB_ENV"
- name: the tree gate, tools/ci/pre-push.sh --ci (the same script the pre-push hook runs; one line per check, a red check prints its output)
run: bash tools/ci/pre-push.sh --ci
- name: site build
run: node site/build.mjs
- name: internal link check of site/*.html
run: node tools/ci/link-check.mjs
- name: identity grep of the public export list
run: bash tools/ci/identity-check.sh
- name: no conflict markers in tracked files
run: bash tools/ci/no-conflict-markers.sh
- name: PowerShell drive-reference check (a `$name:` inside a double-quoted string is a 5.1 parse error)
run: bash tools/ci/ps-drive-ref-check.sh
- name: copied sources are re-stamped before a build
run: bash tools/ci/copied-sources-check.sh
- name: override params files parse with no duplicate key (the duplicate-field class, 6 October 2026)
run: bash tools/ci/override-json-check.sh
- name: second-engine playbooks log to a file and end their tree (C35)
run: bash tools/ci/second-engine-check.sh
- name: no playbook quits, pauses or resumes the installed app (self-test first, then the tree)
run: bash tools/ci/playbook-quit-check.sh --self-test && bash tools/ci/playbook-quit-check.sh
- name: no script writes into another worktree or walks Projects (tools/ci/no-foreign-tree-writes.sh)
run: bash tools/ci/no-foreign-tree-writes.sh --self-test && bash tools/ci/no-foreign-tree-writes.sh
- name: the signer is never piped into head
run: bash tools/ci/signer-pipe-check.sh
- name: bash bodies in PowerShell job scripts pass bash -n, the lost-quote class (self-test first, then the tree)
run: bash tools/ci/bash-body-check.sh --self-test && bash tools/ci/bash-body-check.sh
- name: run jobs test their fetched kit before use, the wiped-jobs-folder class (self-test first, then the tree)
run: bash tools/ci/kit-path-check.sh --self-test && bash tools/ci/kit-path-check.sh
- name: every Windows spawn of the app runs with a hidden console (self-test first, then the tree)
run: node tools/ci/windows-spawn-check.mjs --self-test && node tools/ci/windows-spawn-check.mjs
- name: pinned guest programs match their manifest and are built only by pin-guests.sh
run: bash tools/ci/pinned-guests-check.sh
- name: root prover playbooks kill the GPU server and unlink its socket (the root-socket class, 5 October 2026)
run: bash tools/ci/prover-socket-check.sh
- name: commit-string gate self-test (the empty-commit class of 6 October 2026; the gate itself runs in build-remote.sh, cross-remote.sh and cross-build.sh on every node binary)
run: bash tools/ci/commit-string-check.sh --self-test
- name: build server remote checkout self-test (the stale-overlay class of 6 October 2026)
run: bash infra/build-server/remote-run.sh --self-test
- name: no secret file names and no 64-hex secrets in the tree (self-test first, then the tree)
run: bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-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: explorer and public stats unit tests (search router, formatters, emission rule against the node's own test values, the documented API fields from a fixture)
run: node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs
- name: public stats API answers with the documented fields (the live site; master only, the endpoints exist there after the merge)
if: github.ref == 'refs/heads/master'
run: bash tools/ci/retry-once.sh public-api node tools/ci/public-api-check.mjs https://igneum.network # a live host: one retry before red
run: node tools/ci/public-api-check.mjs https://igneum.network
- 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 relay/test/ember.test.mjs
- name: miner app notice strip and update card (ordering, keys, wording, timers, when the card shows)
run: node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs

View file

@ -216,14 +216,8 @@ jobs:
run: |
$payload = Resolve-Path 'packaging\windows\igneum-windows-app'
function Run-Capture([string]$exe, [string]$flag) {
# Start-Process -Wait on an exe that exits in milliseconds can throw "Cannot process request because the process has
# exited" before it attaches (release-0.3.21 run 37653903394, 7 October 2026, 17:40 UK): one retry before the verdict.
$out = Join-Path $env:RUNNER_TEMP ('smoke-' + [IO.Path]::GetRandomFileName() + '.txt')
$p = $null
foreach ($try in 1, 2) {
try { $p = Start-Process -FilePath $exe -ArgumentList $flag -Wait -NoNewWindow -PassThru -RedirectStandardOutput $out; break }
catch { if ($try -eq 2) { throw }; Write-Host ("Start-Process on {0} {1} failed once ({2}); second try" -f (Split-Path -Leaf $exe), $flag, $_.Exception.Message); Start-Sleep -Milliseconds 500 }
}
$p = Start-Process -FilePath $exe -ArgumentList $flag -Wait -NoNewWindow -PassThru -RedirectStandardOutput $out
$text = if (Test-Path $out) { (Get-Content $out -Raw) } else { '' }
Write-Host ("{0} {1} -> exit {2}: {3}" -f (Split-Path -Leaf $exe), $flag, $p.ExitCode, $text.Trim())
if ($p.ExitCode -ne 0) { throw "$exe $flag exited $($p.ExitCode)" }
@ -299,24 +293,3 @@ jobs:
path: build/inputs-artifact/
retention-days: 90
if-no-files-found: error
red:
# The red watcher for the Windows pipeline (see ci.yml `red`): one line per failed master or release-* run to the
# hidden updates channel and /srv/ci-red/red.jsonl on the box, recorded by the box's own runner.
name: red watcher (master and release-* only; one line per failed run to the updates channel and the box file)
needs: [parse, build]
if: ${{ failure() && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-')) }}
runs-on: [self-hosted, linux, x64, igneum-build-1]
timeout-minutes: 5
permissions:
actions: read # the run's jobs API (the first real red run, 21:19Z: the default token answered 403 and the line carried no step)
contents: read
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: tools/ci
- name: record this run (one line, the failed jobs and their first failed step, from the run's own API)
env:
GITHUB_TOKEN: ${{ github.token }}
RED_WATCH_TITLE: ${{ github.event.head_commit.message }}
run: node tools/ci/red-watch.mjs record --file /srv/ci-red/red.jsonl

View file

@ -1,6 +1,6 @@
// Build script for igneum-app. On a Windows target it compiles resources/igneum-app.rc (the coin icon Explorer shows
// and the version block under Properties > Details) with windres and links the object into igneum-app.exe. Other
// targets: nothing. The founder's rule (4 October 2026): every shipped exe carries the coin icon and a version block, like the
// targets: nothing. the project lead's rule (4 October 2026): every shipped exe carries the coin icon and a version block, like the
// Mac app and DMG. No crate dependency: windres is called directly (x86_64-w64-mingw32-windres from Homebrew mingw-w64
// on the Mac, windres from MSYS2 on a PC; IGNEUM_WINDRES names another one).
use std::env;

View file

@ -1,6 +1,6 @@
// Windows resources for igneum-app.exe: the coin icon Explorer shows and the version block under Properties > Details.
// Compiled with x86_64-w64-mingw32-windres (the icon path is relative to brand/icons, passed with -I).
// the founder's rule (4 October 2026): every shipped exe carries the coin icon and a version block, like the Mac app and DMG.
// the project lead's rule (4 October 2026): every shipped exe carries the coin icon and a version block, like the Mac app and DMG.
#include <winver.h>
1 ICON "igneum.ico"

View file

@ -87,7 +87,7 @@ pub struct Settings {
/// (on when that is switched on, never effective while it is off). A pinned card is skipped.
#[serde(default)]
pub sweep: bool,
/// Power control (the founder, 5 October 2026: "if we don't have to ask then don't ask"): the NVIDIA power cap and the
/// Power control (the project lead, 5 October 2026: "if we don't have to ask then don't ask"): the NVIDIA power cap and the
/// efficiency sweep need administrator rights (one UAC prompt on Windows). Default OFF on every machine; the app
/// never raises the prompt on its own. Switching it on asks once, at that moment; a refused, cancelled or
/// unanswered prompt switches it back off with a notice, no retries.
@ -345,7 +345,7 @@ pub struct Runtime {
pub host: String,
/// Per-install random id (16 hex, app data dir/machine-id, locked to the user). Identity labels, vote keys and
/// the upload fields come from this, so two PCs cloned with the same COMPUTERNAME never share a key
/// (found 4 October 2026 on the founder's two DESKTOP-KMCV30N machines).
/// (found 4 October 2026 on the project lead's two DESKTOP-KMCV30N machines).
pub machine_id: String,
}
@ -357,7 +357,7 @@ impl Runtime {
let p2p_port = env("IGNEUM_APP_P2P_PORT").and_then(|v| v.parse().ok()).unwrap_or(26611);
let peers = match std::env::var("IGNEUM_APP_PEERS") {
Ok(v) => v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
// the public seed node first, then the founder's Mac on the house LAN (devnet only)
// the public seed node first, then the project lead's Mac on the house LAN (devnet only)
Err(_) if network == "devnet" => vec!["188.245.5.161:26611".to_string(), "192.168.68.64:26611".to_string()],
Err(_) => vec![],
};

View file

@ -23,15 +23,8 @@ pub const POWER_STEPS_PCT: [u32; 6] = [100, 90, 80, 70, 60, 50];
/// 6 October 2026, run 6: the 5090's best MH/W sat on the 60% floor (1,854 MHz: 0.563 MH/W, the rate within 0.15%),
/// so the ladder and the floor go to 45% of the maximum; the 1% rate tolerance is the guard below that
pub const CLOCK_STEPS_PCT: [u32; 7] = [100, 90, 80, 70, 60, 50, 45];
/// A card's clock floor when the vendor reports none: this share of its maximum core clock. 7 October 2026, the PC 1
/// efficiency passes (docs/bench-log.md): the 5090's best MH per watt sat at 1,200 to 1,300 MHz (39 to 42 percent of
/// 3,090) and the rate fell past 5 percent only at 1,200 (class v3) and 1,100 (class v4), under the old 45 percent
/// floor; so the ladder continues below 45 percent in [`CLOCK_FINE_STEP_MHZ`] steps down to 20 percent of the maximum
/// (618 MHz on the 5090) and the stop rule, not the floor, ends the search.
pub const CLOCK_FLOOR_PCT: u32 = 20;
/// Below the percent ladder's last rung (45 percent) the clock ladder descends in steps of this many MHz until the
/// rate falls more than the tolerance under the cap point's rate (the knee), a step faults, or the floor is reached.
pub const CLOCK_FINE_STEP_MHZ: u32 = 100;
/// A card's clock floor when the vendor reports none: this share of its maximum core clock.
pub const CLOCK_FLOOR_PCT: u32 = 45;
/// A point may lose this much rate against the fastest point and still win on MH per watt (the manifest can change it).
pub const RATE_TOLERANCE_PCT: f64 = 1.0;
/// A step whose hottest GPU reading reaches this is marked hot and cannot win (the engine aborts at 90).
@ -200,9 +193,6 @@ pub struct Plan {
pub tolerance_pct: f64,
power: Vec<Step>,
clock_pcts: Vec<u32>,
/// the clock ladder below the percent rungs: MHz values from the last rung minus one fine step down to the floor
/// (the core-clock knob of 7 October 2026; empty when the card has no readable maximum clock)
clock_fine: Vec<u32>,
fixed: Vec<Step>,
/// Ember 2 (Climb): the start point, the step sizes and the step budget
climb: Option<Climb>,
@ -228,7 +218,7 @@ impl Plan {
let mem_step = if limits.mem_max_mhz > limits.mem_default_mhz { ((limits.mem_max_mhz - limits.mem_default_mhz) / 20).max(25) } else { 0 };
let core_step = if limits.clock_max_mhz > 0 { (limits.clock_max_mhz / 20).max(25) } else { 0 };
let start = Point { clock_mhz: limits.clamp_clock(start.clock_mhz), power_pct: start.power_pct.clamp(50, 100), mem_mhz: limits.clamp_mem(start.mem_mhz) };
Plan { kind: PlanKind::Climb, limits: limits.clone(), before: start, tolerance_pct: goal.tolerance_pct(tolerance_pct), power: Vec::new(), clock_pcts: Vec::new(), clock_fine: Vec::new(), fixed: Vec::new(), climb: Some(Climb { start, mem_step, core_step, budget: 5, goal }) }
Plan { kind: PlanKind::Climb, limits: limits.clone(), before: start, tolerance_pct: goal.tolerance_pct(tolerance_pct), power: Vec::new(), clock_pcts: Vec::new(), fixed: Vec::new(), climb: Some(Climb { start, mem_step, core_step, budget: 5, goal }) }
}
/// The goal's score of a row: MH per watt for efficiency and balanced, the rate for maximum rate.
@ -295,22 +285,7 @@ impl Plan {
}
}
let clock_pcts = if limits.clock_max_mhz > 0 { CLOCK_STEPS_PCT[1..].to_vec() } else { Vec::new() };
// the fine ladder: from the last percent rung down to the floor in CLOCK_FINE_STEP_MHZ steps (the knob of
// 7 October 2026; the stop rule in `next` ends it at the knee)
let mut clock_fine = Vec::new();
if limits.clock_max_mhz > 0 {
let last_pct = limits.clamp_clock(limits.clock_max_mhz * CLOCK_STEPS_PCT[CLOCK_STEPS_PCT.len() - 1] / 100);
let floor = limits.clock_floor();
let mut m = (last_pct / CLOCK_FINE_STEP_MHZ) * CLOCK_FINE_STEP_MHZ;
if m >= last_pct {
m = m.saturating_sub(CLOCK_FINE_STEP_MHZ);
}
while m >= floor && m > 0 {
clock_fine.push(m);
m = m.saturating_sub(CLOCK_FINE_STEP_MHZ);
}
}
Plan { kind: PlanKind::Full, limits: limits.clone(), before, tolerance_pct, power, clock_pcts, clock_fine, fixed: Vec::new(), climb: None }
Plan { kind: PlanKind::Full, limits: limits.clone(), before, tolerance_pct, power, clock_pcts, fixed: Vec::new(), climb: None }
}
/// The prior's point, then one neighbour: the next clock step up when the prior caps the clock (is the cap
@ -329,14 +304,14 @@ impl Plan {
if neighbour != p {
fixed.push(Step { point: neighbour, watts: limits.watts_for(neighbour.power_pct), kind: Kind::Confirm });
}
Plan { kind: PlanKind::Confirm, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), clock_fine: Vec::new(), fixed, climb: None }
Plan { kind: PlanKind::Confirm, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), fixed, climb: None }
}
/// One step at the card's current point: the before number, and all a measure-only card (Apple, or NVIDIA
/// with Power control off) reports.
pub fn baseline(limits: &Limits, before: Point, tolerance_pct: f64) -> Plan {
let fixed = vec![Step { point: before, watts: limits.watts_for(before.power_pct), kind: Kind::Baseline }];
Plan { kind: PlanKind::Baseline, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), clock_fine: Vec::new(), fixed, climb: None }
Plan { kind: PlanKind::Baseline, limits: limits.clone(), before, tolerance_pct, power: Vec::new(), clock_pcts: Vec::new(), fixed, climb: None }
}
/// How many steps the plan has at most (the clock ladder counts whether or not it runs).
@ -344,42 +319,7 @@ impl Plan {
if let Some(c) = &self.climb {
return c.budget;
}
self.fixed.len() + self.power.len() + self.clock_pcts.len() + self.clock_fine.len()
}
/// The cap point's row: the power ladder's choice (the row the clock search is read against), else the first row.
pub fn cap_row(&self, rows: &[Row]) -> Option<Row> {
if self.power.is_empty() {
rows.first().cloned()
} else {
choose(&rows[..self.power.len().min(rows.len())], self.tolerance_pct).or_else(|| rows.first().cloned())
}
}
/// Why the clock search ended after `rows`, in words, or None while it runs: a faulted clock row (a rejected or
/// mismatched hash during the hold: the fingerprint check), the knee (the rate under the cap point's by more than
/// the tolerance), or the floor.
pub fn clock_stop_reason(&self, rows: &[Row]) -> Option<String> {
let last = rows.last()?;
if last.point.clock_mhz == 0 || !matches!(self.kind, PlanKind::Full) {
return None;
}
let clock_rows = rows.len().saturating_sub(self.power.len());
if clock_rows == 0 {
return None;
}
if last.mark == Some(Mark::Faulted) {
return Some(format!("fingerprint mismatch at {} MHz, clocks reset", last.point.clock_mhz));
}
if let Some(cap) = self.cap_row(rows) {
if last.usable() && cap.usable() && cap.mhs > 0.0 && last.mhs < cap.mhs * (1.0 - self.tolerance_pct.max(0.0) / 100.0) {
return Some(format!("rate fell {:.1} percent at {} MHz", 100.0 * (cap.mhs - last.mhs) / cap.mhs, last.point.clock_mhz));
}
}
if clock_rows >= self.clock_pcts.len() + self.clock_fine.len() {
return Some(format!("the floor at {} MHz", last.point.clock_mhz));
}
None
self.fixed.len() + self.power.len() + self.clock_pcts.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
@ -398,22 +338,10 @@ impl Plan {
return Some(self.power[i].clone());
}
let k = i - self.power.len();
// the stop rule (7 October 2026): a faulted clock row or the knee ends the search; the choice is made among
// the rows so far
if k > 0 {
if let Some(reason) = self.clock_stop_reason(rows) {
if !reason.starts_with("the floor") {
return None;
}
}
}
let clock = if k < self.clock_pcts.len() {
self.limits.clamp_clock(self.limits.clock_max_mhz * self.clock_pcts[k] / 100)
} else {
*self.clock_fine.get(k - self.clock_pcts.len())?
};
let pct = *self.clock_pcts.get(k)?;
// the clock ladder rides the power point the power ladder chose (the before point when nothing won)
let power_pct = if self.power.is_empty() { self.before.power_pct } else { choose(&rows[..self.power.len()], self.tolerance_pct).map(|r| r.point.power_pct).unwrap_or(self.before.power_pct) };
let clock = self.limits.clamp_clock(self.limits.clock_max_mhz * pct / 100);
// a step whose clamp lands on the previous step's clock is dropped (the floor was reached)
if rows.last().map(|r| r.point.clock_mhz == clock).unwrap_or(false) {
return None;
@ -993,38 +921,6 @@ pub fn result_line(kind: PlanKind, mhs: f64, watts: f64, eff: f64) -> String {
}
}
/// The core-clock knob's result on a card (7 October 2026; the UI lane's field shape): the chosen lock against the cap
/// point's unlocked row, and the stop reason in words.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct LockResult {
/// the chosen core clock cap (0 = unlocked)
pub lock_mhz: u32,
pub lock_mhs: f64,
pub lock_w: f64,
pub lock_mhw: f64,
/// the cap point's row (clock 0): the rate and draw the lock is read against
pub unlocked_mhs: f64,
pub unlocked_w: f64,
/// "rate fell 5.1 percent at 1,200 MHz", "fingerprint mismatch at 1,400 MHz, clocks reset", "the floor at 618 MHz",
/// "no lever" (a card without a clock cap), "" while nothing ran
pub lock_note: String,
}
/// The knob's result from a finished plan's rows and its chosen row.
pub fn lock_result(plan: &Plan, rows: &[Row], chosen: &Row) -> LockResult {
let cap = plan.cap_row(rows);
let (unlocked_mhs, unlocked_w) = cap.as_ref().map(|c| (c.mhs, c.watts)).unwrap_or((0.0, 0.0));
let ran_clocks = rows.iter().any(|r| r.point.clock_mhz > 0);
let note = if plan.limits.clock_max_mhz == 0 {
"no lever".to_string()
} else if !ran_clocks {
String::new()
} else {
plan.clock_stop_reason(rows).unwrap_or_else(|| format!("stopped at {} MHz", rows.last().map(|r| r.point.clock_mhz).unwrap_or(0)))
};
LockResult { lock_mhz: chosen.point.clock_mhz, lock_mhs: chosen.mhs, lock_w: chosen.watts, lock_mhw: chosen.eff, unlocked_mhs, unlocked_w, lock_note: note }
}
/// Why a card cannot be tuned beyond measuring, or None when both knobs are available.
pub fn control_reason(vendor: &str, limits: &Limits, device: &str, power_control: bool, amd_helper: bool) -> Option<String> {
match vendor {
@ -1056,9 +952,7 @@ mod tests {
#[test]
fn the_full_plan_is_the_power_ladder_then_the_clock_ladder_at_the_chosen_power() {
let plan = Plan::full(&l5090(), Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 }, 1.0);
// five power steps (60% and 50% clamp to 400 W; one kept), six percent rungs (90% down to 45% = 1,390) and the
// fine ladder 1,300 down to the 20% floor (618): 1,300, 1,200, ..., 700 = 7 steps
assert_eq!(plan.len(), 5 + 6 + 7);
assert_eq!(plan.len(), 5 + 6, "five power steps (60% and 50% clamp to 400 W; one kept) and six clock steps (90% down to 45%)");
let first = plan.next(&[]).unwrap();
assert_eq!((first.point, first.watts, first.kind), (Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, 575.0, Kind::Power));
// the power ladder: 575, 518, 460, 403, 400
@ -1080,21 +974,24 @@ mod tests {
let s = plan.next(&rows).unwrap();
assert_eq!(s.point.clock_mhz, 2472);
rows.push(row_at(s.point, 220.0, 123.5));
rows.push(row_at(plan.next(&rows).unwrap().point, 200.0, 118.0));
let s = plan.next(&rows).unwrap();
assert_eq!(s.point.clock_mhz, 2163, "70%");
rows.push(row_at(s.point, 200.0, 118.0));
// the stop rule (7 October 2026): 118 is 4.8% under the cap point's 124, past the 1% tolerance, so the search
// ends here (the 6 October ladder went on to 1,854, 1,545 and 1,390)
assert_eq!(s.point.clock_mhz, 1854, "60% of 3,090");
rows.push(row_at(s.point, 180.0, 100.0));
let s = plan.next(&rows).unwrap();
assert_eq!(s.point.clock_mhz, 1545, "50%");
rows.push(row_at(s.point, 170.0, 90.0));
let s = plan.next(&rows).unwrap();
assert_eq!(s.point.clock_mhz, 1390, "45% of 3,090 is the floor (6 October 2026)");
rows.push(row_at(s.point, 160.0, 80.0));
assert_eq!(plan.next(&rows), None);
assert_eq!(plan.clock_stop_reason(&rows).as_deref(), Some("rate fell 4.8 percent at 2163 MHz"));
// the choice: 2,472 MHz keeps 99.6% of the top rate at 220 W = 0.561 MH/W; 2,163 MHz (118 MH/s) is outside the 1% tolerance
let best = choose(&rows, 1.0).unwrap();
assert_eq!(best.point, Point { clock_mhz: 2472, power_pct: 100, mem_mhz: 0 });
// a wider tolerance lets the 2,163 MHz step (0.590 MH/W, 4.8% slower) win
assert_eq!(choose(&rows, 5.0).unwrap().point.clock_mhz, 2163);
// no power limits, clocks only; no clocks, power only; nothing, empty
// clocks only: six percent rungs (1,800 .. 900) then the fine ladder 800 .. 400 (the 20% floor) = 5 more
assert_eq!(Plan::full(&Limits { clock_max_mhz: 2000, ..Default::default() }, Point::default(), 1.0).len(), 6 + 5);
assert_eq!(Plan::full(&Limits { clock_max_mhz: 2000, ..Default::default() }, Point::default(), 1.0).len(), 6);
assert_eq!(Plan::full(&Limits { power_default_w: 300.0, ..Default::default() }, Point::default(), 1.0).len(), 6);
assert!(Plan::full(&Limits::default(), Point::default(), 1.0).is_empty());
}
@ -1120,12 +1017,11 @@ mod tests {
#[test]
fn limits_never_exceed_the_vendor_or_undercut_the_floor() {
let l = l5090();
assert_eq!(l.clock_floor(), 618, "20% of 3,090 (7 October 2026; the 45% floor of 6 October sat on the 5090's knee)");
assert_eq!(l.clamp_clock(1000), 1000);
assert_eq!(l.clamp_clock(500), 618);
assert_eq!(l.clock_floor(), 1390);
assert_eq!(l.clamp_clock(1000), 1390);
assert_eq!(l.clamp_clock(5000), 3090);
assert_eq!(l.clamp_clock(0), 0, "unlocked stays unlocked");
assert_eq!(Limits { clock_max_mhz: 3000, clock_min_mhz: 2100, ..Default::default() }.clamp_clock(1500), 2100, "the vendor's floor wins over the 20% rule");
assert_eq!(Limits { clock_max_mhz: 3000, clock_min_mhz: 2100, ..Default::default() }.clamp_clock(1500), 2100, "the vendor's floor wins over the 45% rule");
assert_eq!(l.watts_for(100), 575.0);
assert_eq!(l.watts_for(50), 400.0);
assert_eq!(Limits { power_default_w: 300.0, power_max_w: 250.0, ..Default::default() }.watts_for(100), 250.0);
@ -1425,138 +1321,4 @@ mod tests {
assert!(control_reason("amd", &Limits::default(), "1", false, true).is_none());
}
}
/// The core-clock knob (7 October 2026, the PC 1 efficiency passes): a flat ladder walks below the old 45 percent
/// floor in 100 MHz steps to the 20 percent floor, and the result names the floor.
#[test]
fn the_clock_ladder_continues_below_45_percent_in_100_mhz_steps_to_the_floor() {
let plan = Plan::full(&l5090(), Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, 1.0);
let mut rows = Vec::new();
for _ in 0..5 {
let s = plan.next(&rows).unwrap();
rows.push(row_at(s.point, 300.0, 136.8));
}
let mut clocks = Vec::new();
while let Some(s) = plan.next(&rows) {
assert_eq!(s.kind, Kind::Clock);
clocks.push(s.point.clock_mhz);
// the rate holds (memory-bound): the draw falls with the clock
rows.push(row_at(s.point, 300.0 - clocks.len() as f64 * 10.0, 136.0));
}
assert_eq!(clocks, vec![2781, 2472, 2163, 1854, 1545, 1390, 1300, 1200, 1100, 1000, 900, 800, 700]);
assert_eq!(plan.clock_stop_reason(&rows).as_deref(), Some("the floor at 700 MHz"));
let chosen = choose(&rows, 1.0).unwrap();
assert_eq!(chosen.point.clock_mhz, 700, "flat rate: the lowest draw wins");
let r = lock_result(&plan, &rows, &chosen);
assert_eq!((r.lock_mhz, r.lock_w, r.unlocked_mhs, r.unlocked_w), (700, 170.0, 136.8, 300.0));
assert_eq!(r.lock_note, "the floor at 700 MHz");
}
/// The stop rule on PC 1's RTX 5090 rows of 7 October 2026 (class v3, the card alone): the first clock row more
/// than the tolerance under the cap point's rate ends the search and the best MH per watt among the rows within
/// tolerance is chosen. At the 1 percent tolerance the 5090's rate (136.6 at 2,781) is 1.24 percent down at
/// 1,545 MHz, so the search ends there and 1,854 MHz (135.6 MH/s at 239.6 W) is the point; at 1.5 percent it runs
/// on to 1,200 (5.2 percent down) and 1,300 MHz is the point. The tolerance is the manifest's.
#[test]
fn the_clock_search_stops_at_the_knee_and_names_it() {
let measured: Vec<(u32, f64, f64)> = vec![(2781, 317.9, 136.6), (2472, 276.0, 136.4), (2163, 252.4, 136.1), (1854, 239.6, 135.6), (1545, 232.1, 134.9), (1390, 229.0, 134.85), (1300, 223.3, 134.6), (1200, 215.7, 129.5)];
let walk = |tolerance: f64| -> (Plan, Vec<Row>) {
let plan = Plan::full(&Limits { clock_max_mhz: 3090, ..Default::default() }, Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, tolerance);
let mut rows = vec![];
for (mhz, w, mhs) in &measured {
let Some(s) = plan.next(&rows) else { break };
assert_eq!(s.point.clock_mhz, *mhz);
rows.push(row_at(s.point, *w, *mhs));
}
(plan, rows)
};
let (plan, rows) = walk(1.0);
assert_eq!(rows.last().unwrap().point.clock_mhz, 1545, "the search ends on the first row over 1 percent under the cap row");
assert_eq!(plan.next(&rows), None);
let reason = plan.clock_stop_reason(&rows).unwrap();
assert!(reason.starts_with("rate fell 1.2 percent at 1545 MHz"), "{reason}");
let chosen = choose(&rows, 1.0).unwrap();
assert_eq!(chosen.point.clock_mhz, 1854, "the best MH per watt within 1 percent of the fastest row");
let r = lock_result(&plan, &rows, &chosen);
assert_eq!((r.lock_mhz, r.unlocked_mhs), (1854, 136.6));
assert!((r.lock_mhw - 135.6 / 239.6).abs() < 1e-6);
let (plan, rows) = walk(1.5);
assert_eq!(rows.last().unwrap().point.clock_mhz, 1200);
assert_eq!(plan.next(&rows), None);
assert!(plan.clock_stop_reason(&rows).unwrap().starts_with("rate fell 5.2 percent at 1200 MHz"));
assert_eq!(choose(&rows, 1.5).unwrap().point.clock_mhz, 1300);
}
/// The fingerprint rule: a clock row marked Faulted (a rejected or mismatched hash during the hold) ends the search
/// at once; the choice is made among the usable rows and the note says why.
#[test]
fn a_faulted_clock_row_ends_the_search_and_the_note_says_so() {
let plan = Plan::full(&Limits { clock_max_mhz: 3090, ..Default::default() }, Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, 1.0);
let mut rows = vec![];
for (mhz, w, mhs) in [(2781, 317.9, 136.6), (2472, 276.0, 136.4)] {
let s = plan.next(&rows).unwrap();
assert_eq!(s.point.clock_mhz, mhz);
rows.push(row_at(s.point, w, mhs));
}
let s = plan.next(&rows).unwrap();
assert_eq!(s.point.clock_mhz, 2163);
let mut bad = row_at(s.point, 252.4, 136.1);
bad.faults = 1;
bad.mark = Some(Mark::Faulted);
rows.push(bad);
assert_eq!(plan.next(&rows), None, "the search ends on the faulted row");
assert_eq!(plan.clock_stop_reason(&rows).as_deref(), Some("fingerprint mismatch at 2163 MHz, clocks reset"));
let chosen = choose(&rows, 1.0).unwrap();
assert_eq!(chosen.point.clock_mhz, 2472, "the faulted row never wins");
assert_eq!(lock_result(&plan, &rows, &chosen).lock_note, "fingerprint mismatch at 2163 MHz, clocks reset");
}
/// The same through the state machine with a fake helper (the known-failed case first: a mismatch mid-search must
/// reset and abort): the run applies 2,781 and 2,472, a fault lands during 2,163's hold, the row comes out Faulted,
/// the next step is none, and the run's final Apply is the chosen 2,472 point (the reset), then Finished.
#[test]
fn a_mismatch_mid_search_resets_to_the_chosen_point_and_finishes() {
let plan = Plan::full(&Limits { clock_max_mhz: 3090, ..Default::default() }, Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 }, 1.0);
let timing = Timing { settle: Duration::from_secs(1), hold: Duration::from_secs(2), apply: Duration::from_secs(3) };
let t0 = Instant::now();
let mut run = Run::new(0, "0", "card-0", plan, 300.0, false, timing, t0);
let mut t = t0;
let mut applied: Vec<Step> = Vec::new();
let mut finished: Option<Row> = None;
let watts_for = |mhz: u32| -> f64 { match mhz { 2781 => 317.9, 2472 => 276.0, _ => 252.4 } };
for _ in 0..200 {
t += Duration::from_millis(500);
let acked = true;
let limit = run.current.as_ref().map(|s| s.watts).unwrap_or(0.0);
// the fake helper: every setting takes; during 2,163's hold the worker reports a mismatched hash
if let Some(cur) = run.current.clone() {
if matches!(run.phase, Phase::Holding { .. }) {
run.sample_rate(136.4);
run.sample_telemetry(watts_for(cur.point.clock_mhz), cur.point.clock_mhz as f64, 13801.0, 60.0);
if cur.point.clock_mhz == 2163 {
run.sample_fault();
}
}
}
for o in run.tick(t, Readback { limit_w: limit, acked }) {
match o {
Out::Apply(s) => applied.push(s),
Out::Finished(r) => finished = Some(r),
Out::Failed(e) => panic!("the run failed: {e}"),
Out::Row(_) => {}
}
}
if finished.is_some() {
break;
}
}
let clocks: Vec<u32> = applied.iter().map(|s| s.point.clock_mhz).collect();
assert_eq!(clocks, vec![2781, 2472, 2163, 2472], "2,781, 2,472, the faulted 2,163, then the reset to the chosen 2,472");
assert_eq!(applied.last().unwrap().kind, Kind::Confirm);
let f = finished.expect("finished");
assert_eq!(f.point.clock_mhz, 2472);
assert_eq!(run.rows.len(), 3);
assert_eq!(run.rows[2].mark, Some(Mark::Faulted));
assert_eq!(run.plan.clock_stop_reason(&run.rows).as_deref(), Some("fingerprint mismatch at 2163 MHz, clocks reset"));
}
}

View file

@ -2727,13 +2727,6 @@ impl Engine {
cc.tune_steps = of;
cc.tune_eta_s = eta;
cc.tune_plan = plan.into();
// the clock search's own step count while a clock step runs (the UI's "locking clocks: step 4 of 9")
if let Some(r) = self.sweep.as_ref() {
let power_steps = r.rows.iter().filter(|x| x.point.clock_mhz == 0 && x.mark.is_some()).count() as u32;
let on_clock = r.current.as_ref().map(|s| s.kind == crate::ember::Kind::Clock).unwrap_or(false);
cc.lock_step = if on_clock { step.saturating_sub(power_steps) } else { 0 };
cc.lock_steps = if on_clock { of.saturating_sub(power_steps) } else { 0 };
}
}
if self.shared.runtime.sweep_only {
// the job playbook forwards this to the installed app's /api/tune-progress
@ -2772,22 +2765,6 @@ impl Engine {
c.tune_source = kind.name().into();
c.tune_line = crate::ember::result_line(kind, row.mhs, row.watts, row.eff);
c.tune_curve = run.rows.iter().map(|r| r.json()).collect();
// the core-clock knob's result (7 October 2026): the chosen lock against the cap point, and why it stopped
if kind == crate::ember::PlanKind::Baseline {
c.lock_note = if c.vendor == "apple" || run.plan.limits.clock_max_mhz == 0 { "no lever".into() } else { c.sweep_note.clone() };
} else {
let lr = crate::ember::lock_result(&run.plan, &run.rows, &row);
c.lock_mhz = lr.lock_mhz;
c.lock_mhs = lr.lock_mhs;
c.lock_w = lr.lock_w;
c.lock_mhw = lr.lock_mhw;
c.unlocked_mhs = lr.unlocked_mhs;
c.unlocked_w = lr.unlocked_w;
c.lock_at = unix as f64;
c.lock_note = lr.lock_note;
}
c.lock_step = 0;
c.lock_steps = 0;
let control = c.tune_control;
if kind == crate::ember::PlanKind::Baseline {
c.sweep_note = if control { String::new() } else { c.sweep_note.clone() };
@ -4102,7 +4079,7 @@ impl Engine {
}
/// The watts a card's cap asks for: power_pct of the default limit, inside the card's min and max.
/// the founder, 5 October 2026: "if we don't have to ask then don't ask". The NVIDIA power cap and the efficiency sweep need
/// the project lead, 5 October 2026: "if we don't have to ask then don't ask". The NVIDIA power cap and the efficiency sweep need
/// administrator rights (one UAC prompt on Windows, pkexec on Linux); the engine builds an elevated command only when
/// Power control is on in Settings, or when it is itself the elevated PC sweep job (--sweep).
fn elevation_allowed(power_control: bool, sweep_only: bool) -> bool {
@ -4374,7 +4351,7 @@ mod resume_tests {
mod tests {
#[test]
fn power_control_off_builds_no_elevated_command() {
// the decision (the founder, 5 October 2026): off = the app never asks; the elevated PC sweep job is the exception
// the decision (the project lead, 5 October 2026): off = the app never asks; the elevated PC sweep job is the exception
assert!(!super::elevation_allowed(false, false));
assert!(super::elevation_allowed(true, false));
assert!(!super::elevation_allowed(false, true), "the --sweep job alone never asks (C35)");

View file

@ -1,5 +1,5 @@
//! The `build` job (the model is src/jobs.rs, the runner src/jobrun.rs): a Windows PC builds the node and the app
//! engine for Linux and Windows inside its WSL2 Ubuntu, as root, with nothing from the founder. The founder's ask, 4 October 2026
//! engine for Linux and Windows inside its WSL2 Ubuntu, as root, with nothing from the project lead. the project lead's ask, 4 October 2026
//! evening ("efficiency"): every Windows build went through a GitHub runner at 15 to 25 minutes a round and every
//! Linux binary was cross-compiled on the Mac under the build lock; the two RTX 5090 PCs sit idle on the CPU side.
//!
@ -135,9 +135,6 @@ pub struct Manifest {
pub node_commit: String,
/// the 40-hex commit (manifest node.commit_full, packer of 6 October 2026); empty in older manifests
pub node_commit_full: String,
/// the commit's author time (manifest node.commit_time, unix seconds): SOURCE_DATE_EPOCH of every build stage, so mimalloc's
/// __DATE__/__TIME__ and anything else that reads the clock give one answer per commit (6 October 2026, the 0.3.14 repro)
pub node_commit_time: u64,
pub node_dirty: bool,
pub app_version: String,
pub builds: Vec<Unit>,
@ -164,7 +161,7 @@ pub fn parse_manifest(text: &str) -> Result<Manifest, String> {
let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
let node = v.get("node").cloned().unwrap_or(Value::Null);
let ns = |k: &str| node.get(k).and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
let mut m = Manifest { created_at: s("created_at"), node_branch: ns("branch"), node_commit: ns("commit"), node_commit_full: ns("commit_full"), node_commit_time: node.get("commit_time").and_then(|x| x.as_u64()).unwrap_or(0), node_dirty: node.get("dirty").and_then(|x| x.as_bool()).unwrap_or(false), app_version: s("app_version"), ..Default::default() };
let mut m = Manifest { created_at: s("created_at"), node_branch: ns("branch"), node_commit: ns("commit"), node_commit_full: ns("commit_full"), node_dirty: node.get("dirty").and_then(|x| x.as_bool()).unwrap_or(false), app_version: s("app_version"), ..Default::default() };
let builds = v.get("builds").and_then(|b| b.as_array()).ok_or("manifest.json has no \"builds\" list")?;
for (i, b) in builds.iter().enumerate() {
let dir = b.get("dir").and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
@ -332,11 +329,6 @@ pub fn build_script(p: &BuildParams, job_id: &str, m: &Manifest, target: &str) -
s.push_str(&windows_env());
}
s.push_str(&format!("echo \"STAGE {target} start $(now)\"\n"));
if m.node_commit_time > 0 {
// reproducible builds: the commit's author time and UTC for every compiler of this stage; the target dir is the one
// persistent $CARGO_TARGET_DIR (never a per-run name: prost's generated code embeds OUT_DIR)
s.push_str(&format!("export SOURCE_DATE_EPOCH={} TZ=UTC; echo \"SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH TZ=UTC\"\n", m.node_commit_time));
}
s.push_str(&format!("mkdir -p \"$OUT/{target}\"\n"));
s.push_str("rc_all=0\n");
// the node's full commit from the manifest (each stage is its own script; the extract stage read it too)
@ -506,7 +498,7 @@ mod tests {
use super::*;
use serde_json::json;
const MANIFEST: &str = r#"{"created_at":"2026-10-04T20:00:00Z","node":{"branch":"devnet-v4","commit":"3bfe346f","commit_time":1791300000,"dirty":false,"source":"vendor/igneum-node-v4"},"repo":{"commit":"0f44edd","branch":"build-job","dirty":true},"app_version":"0.3.4",
const MANIFEST: &str = r#"{"created_at":"2026-10-04T20:00:00Z","node":{"branch":"devnet-v4","commit":"3bfe346f","dirty":false,"source":"vendor/igneum-node-v4"},"repo":{"commit":"0f44edd","branch":"build-job","dirty":true},"app_version":"0.3.4",
"builds":[{"dir":"node","packages":["kaspad","igneum-miner"],"features":["kaspad/igneum-pow"],"bins":["igneumd","igneum-miner"],"targets":["linux","windows"]},
{"dir":"app/igneum-app","packages":["igneum-app"],"bins":["igneum-app"],"optional_on":["linux"]}],
"tests":[{"dir":"app/igneum-app","packages":["igneum-app"]},{"dir":"node","packages":["igneum-miner"]},{"dir":"node","packages":[]}]}"#;
@ -585,8 +577,6 @@ mod tests {
let lin = build_script(&p, id, &m, "linux");
assert!(lin.contains("cargo build --release $JOBS -p kaspad -p igneum-miner --features kaspad/igneum-pow 2>&1"));
assert!(lin.contains("cd \"$SRC/app/igneum-app\""));
// reproducible builds (6 October 2026): the commit's author time and UTC exported before any cargo build of the stage
assert!(lin.contains("export SOURCE_DATE_EPOCH=1791300000 TZ=UTC") && lin.find("SOURCE_DATE_EPOCH=1791300000").unwrap() < lin.find("cargo build").unwrap(), "{lin}");
assert!(lin.contains("optional on linux: not fatal"));
assert!(!lin.contains("--target x86_64-pc-windows-gnu"));
// the windows stage ships the runtime DLLs of its own toolchain next to the exes; the linux stage does not

View file

@ -32,7 +32,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// The safety-net poll. Before 0.3.6 this was 600 s and a published job waited up to 10 minutes on every PC (the founder,
/// The safety-net poll. Before 0.3.6 this was 600 s and a published job waited up to 10 minutes on every PC (the project lead,
/// 5 October 2026: "why is it taking so long for pc2 and pc1s tasks to spin up?"); the wake below makes it seconds.
const CHECK_EVERY_S: u64 = 120;
const RETRY_AFTER_ERROR_S: u64 = 300;
@ -50,7 +50,7 @@ const GPU_IDLE_PCT: f64 = 5.0;
const GPU_IDLE_WAIT_S: u64 = 180;
const DEFAULT_DISTRO: &str = "Ubuntu-24.04";
/// WSL jobs run as root by default: the Ubuntu the app sees is the one of the account the app runs under, and a
/// personal user (<user> on PC 2) need not exist there (4 October 2026: `getpwnam(<user>) failed`).
/// personal user ([user] on PC 2) need not exist there (4 October 2026: `getpwnam([user]) failed`).
const DEFAULT_WSL_USER: &str = "root";
const DEFAULT_FIXTURES: &[&str] = &["block-338-shard1", "block-341-shards2", "block-344-shards4"];
const HISTORY_SHOWN: usize = 20;
@ -1218,9 +1218,7 @@ fn run_script(shared: &Arc<Shared>, job: &Job, sink: &Sink, jobs_dir: &Path, dat
let shell = shell_for(job);
let body = job.str_param("script").replace("\r\n", "\n");
let script = dir.join(if shell == "powershell" { "script.ps1" } else { "script.sh" });
// every PowerShell job gets the runner's prelude first (7 October 2026, main's rule after 0.3.22 take 2: a helper that must
// outlive the job is started through ONE helper here, never the plain start cmdlet from the job's own PowerShell)
let text = if shell == "powershell" { format!("{}{}", ps_prelude(), body.replace('\n', "\r\n")) } else { body };
let text = if shell == "powershell" { body.replace('\n', "\r\n") } else { body };
std::fs::write(&script, if shell == "powershell" { [b"\xEF\xBB\xBF".as_slice(), text.as_bytes()].concat() } else { text.into_bytes() }).map_err(|e| format!("cannot write the script: {e}"))?;
let elevated = cfg!(windows) && job.bool_param("elevated");
let limit = Duration::from_secs(job.timeout_minutes() * 60);
@ -1306,25 +1304,6 @@ fn follow_file(sink: &Sink, path: PathBuf) -> Follow {
/// `-WindowStyle Hidden` on the launch keeps it hidden, and this line is the running measurement of that on every
/// elevated job: "elevated console: hwnd N visible False"), then the script, everything into `out_file` for the engine
/// to read back. The console-window class, PC 1, 5 October 2026 (tools/windows/console-watch-elevated.ps1).
/// The PowerShell prelude written ahead of every job script (0.3.24; main's rule, 7 October 2026 21:3x UK, from the 0.3.22 take 2
/// fault: a smoke helper started with Start-Process from the job's PowerShell died with the job's process tree when the job ended,
/// because the runner ends the tree (taskkill /T) and Start-Process keeps the child inside it). `Start-IgneumDetached -File <ps1>
/// [-Arguments <text>]` starts a hidden PowerShell through Win32_Process.Create (Invoke-CimMethod), which puts the child outside
/// the job's tree and outside taskkill /T, so it outlives the job; it falls back to a one-shot scheduled task (schtasks /SC ONCE,
/// run now, delete after) when CIM is refused. It returns the new pid and writes "RESULT detached pid N via cim|task" so the
/// report carries it. A script that needs to outlive itself (a smoke helper, an installer that stops the app) calls this and
/// nothing else; the plain start cmdlet for that purpose is the known-failed shape (it keeps the child in the job's tree).
fn ps_prelude() -> String {
"function Start-IgneumDetached { param([Parameter(Mandatory=$true)][string]$File, [string]$Arguments = '')\r\n\
$cmd = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File \"' + $File + '\"' + $(if ($Arguments) { ' ' + $Arguments } else { '' })\r\n\
$how = 'cim'; $newPid = 0\r\n\
try { $si = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = 0 }; $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = $cmd; ProcessStartupInformation = $si }; if ($r.ReturnValue -eq 0) { $newPid = [int]$r.ProcessId } } catch { }\r\n\
if ($newPid -eq 0) { $how = 'task'; $tn = 'IgneumDetached-' + [guid]::NewGuid().ToString('N').Substring(0, 8); $at = (Get-Date).AddMinutes(1).ToString('HH:mm'); & schtasks.exe /Create /TN $tn /TR $cmd /SC ONCE /ST $at /F | Out-Null; & schtasks.exe /Run /TN $tn | Out-Null; Start-Sleep -Seconds 2; & schtasks.exe /Delete /TN $tn /F | Out-Null; $newPid = -1 }\r\n\
Write-Output ('RESULT detached pid ' + $newPid + ' via ' + $how + ': ' + $File)\r\n\
return $newPid }\r\n\
# (runner prelude end)\r\n".to_string()
}
fn elevated_wrapper(env_lines: &str, script: &str, out_file: &str) -> String {
let (script, out) = (crate::platform::ps_quote(script), crate::platform::ps_quote(out_file));
format!(
@ -1510,23 +1489,6 @@ mod tests {
assert!(l.contains("if ($null -eq $p) { Write-Error 'elevated launch failed: no process'; exit 251 }"), "{l}");
}
#[test]
fn powershell_jobs_get_the_detached_helper_and_it_never_uses_start_process() {
// the known-failed shape (0.3.22 take 2, 7 October 2026): a helper started with Start-Process from the job's PowerShell
// dies with the job's tree; the prelude's helper goes through Win32_Process.Create (or a one-shot task) instead
let p = ps_prelude();
assert!(p.starts_with("function Start-IgneumDetached"));
assert!(p.contains("Invoke-CimMethod -ClassName Win32_Process -MethodName Create"));
assert!(p.contains("schtasks.exe /Create") && p.contains("/SC ONCE"));
assert!(p.contains("RESULT detached pid"));
// the banned cmdlet's name is assembled here so the windows-spawn gate does not read this test as a spawn
let banned = ["Start", "Process"].join("-");
assert!(!p.contains(&banned), "the prelude must not start the detached process with {banned} (it stays in the job's tree)");
assert!(p.ends_with("# (runner prelude end)\r\n"));
// CRLF throughout, as the script body is written
assert!(!p.replace("\r\n", "").contains('\n'));
}
#[test]
fn elevated_wrapper_reports_its_console_then_runs_the_script() {
let w = elevated_wrapper("$env:IGNEUM_JOB_ID = 'j1'\r\n", r"C:\jobs\j1\script.ps1", r"C:\jobs\it's\elevated-output.log");

View file

@ -2,7 +2,7 @@
//! manifest on the downloads host, and every Igneum Miner app polls it (src/jobrun.rs, every 10 minutes). A job
//! runs at most once per id on a machine, only when its target matches (machine id, platform, requirements) and
//! it has not expired. Same key, same canonical JSON (sorted keys, no whitespace) and the same `.sig` scheme as the
//! update manifest (src/manifest.rs). The founder's rule, 4 October 2026: one app on both PCs that the Mac can send
//! update manifest (src/manifest.rs). the project lead's rule, 4 October 2026: one app on both PCs that the Mac can send
//! commands and files to over the line, so everything is tested and built without a person at the PC.
//!
//! This module is self-contained (serde_json and manifest.rs only), so the signer (src/bin/ota-sign.rs) includes it

View file

@ -1,4 +1,4 @@
//! Over-the-air updates of the app (and the node, miner and workers inside it). The founder's rule: every app updates
//! Over-the-air updates of the app (and the node, miner and workers inside it). the project lead's rule: every app updates
//! itself and downloads the update without being asked. This is also how a consensus upgrade (a height-activated
//! rule such as difficulty v2) reaches every node before its activation height.
//!
@ -120,7 +120,7 @@ pub struct Updater {
/// this machine's minute of the hour for applying (manifest::slot_minute of the machine id)
slot: u64,
/// When this engine started (unix seconds): an update published more than an hour before it is a catch-up, not a
/// rollout, and skips the hourly slot (the founder's morning of 6 October 2026: PC 1 came up after the 0.3.11 publish and
/// rollout, and skips the hourly slot (the project lead's morning of 6 October 2026: PC 1 came up after the 0.3.11 publish and
/// sat on "installs at the next safe moment" until he pressed Install now).
started_unix: u64,
catch_up_logged: bool,

View file

@ -1,4 +1,4 @@
//! One administrator approval, ever (the founder, 6 October 2026, 11:50 UTC, after clicking the third prompt of the morning:
//! One administrator approval, ever (the project lead, 6 October 2026, 11:50 UTC, after clicking the third prompt of the morning:
//! "can we make sure all these popups are not needed in future?").
//!
//! What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; but every later cap (an app

View file

@ -1,4 +1,4 @@
//! Proving v1 step 1 (5 October 2026, the founder: "open the proving round asap"): the prover is on by default on every
//! Proving v1 step 1 (5 October 2026, the project lead: "open the proving round asap"): the prover is on by default on every
//! mining machine that can prove, decided once per install after the cards are detected (src/engine.rs
//! `apply_prove_default`). The rule, one line each:
//!
@ -6,13 +6,13 @@
//! |---|---|---|
//! | NVIDIA card with 24 GB or more, mining or not, Windows with WSL2 (Ubuntu-24.04) answering or Linux | on | a full shard at the adopted v1 budget (30,000 pgas, 4.7 M cycles) peaks at 20,434 MiB alone and 22,210 beside the miner (measured on the 5090; approximate for a 24 GB card's own allocation); the prototype shard the devnet proves until its fee switch (6.75 M pgas) peaks at 28,307 MiB alone and 30,039 beside the miner, so until the switch only a 32 GB card proves it and a 24 GB card's prover waits for shards it can hold (the host refuses nothing; a proof that runs out of memory fails and the shard is left) |
//! | NVIDIA card of 16 to 24 GB | off, with the line saying why | the GPU prover's floor is 13,874 MiB for an EMPTY shard, 15,670 beside the miner; a 16 GB card holds no full shard |
//! | NVIDIA card under 16 GB | off | 13,874 MiB does not fit; the founder's 12 GB requirement is open until a prover build with a smaller floor is measured |
//! | NVIDIA card under 16 GB | off | 13,874 MiB does not fit; the project lead's 12 GB requirement is open until a prover build with a smaller floor is measured |
//! | Windows under 32 GB of RAM | off, with the line saying why | the WSL2 prover held 7.9 GB on a 63 GB PC; a 16 GB PC would swap |
//! | Windows with a qualifying card but WSL2 silent | off, with the Set up hint | nothing can prove until the distribution exists |
//! | Apple silicon | off | the M5 Max CPU took 41 to 55 s for an EMPTY shard's compressed proof under load and 272 s for a 200-pgas shard; a full shard was never under 60 s (bench-log 4 and 5 October 2026) |
//! | AMD-only (no NVIDIA card) | off, "mines and does not prove" | no zkVM proves on an AMD GPU today (docs/analysis/amd-proving.md); the SP1 CPU prover on PC 1 cost 82 to 87 s core plus 199 to 202 s compressed a shard at a 30 GB RSS whatever the shard size (bench-log, "the SP1 CPU prover on PC 1") |
//!
//! Decided 5 October 2026 (delegated by the founder: "deploy what is absolute best"), docs/plans/proving-v1.md. The default
//! Decided 5 October 2026 (delegated by the project lead: "deploy what is absolute best"), docs/plans/proving-v1.md. The default
//! never switches an explicit on back off, and Settings always wins afterwards.
use crate::state::CardState;

View file

@ -127,7 +127,7 @@ pub struct CardState {
pub tune_source: String, // full | confirm | baseline
pub tune_line: String, // "Tuned: 122.3 MH/s at 290 W (0.422 MH/W)" once tuned
// a tune in progress on this card, by this engine or by a measurement engine posting /api/tune-progress
// (the founder, 6 October 2026: "don't we need to show in the app that tuning is in progress?")
// (the project lead, 6 October 2026: "don't we need to show in the app that tuning is in progress?")
pub tune_step: u32,
pub tune_steps: u32,
pub tune_eta_s: i64,
@ -135,18 +135,6 @@ pub struct CardState {
// Ember 2: the memory clock the last tune chose and the measured curve (every row of the last plan)
pub tune_mem_mhz: u32,
pub tune_curve: Vec<serde_json::Value>,
// the core-clock knob (7 October 2026, src/ember.rs lock_result; the UI lane's field shape): the chosen lock
// against the cap point's unlocked row, the step while the clock search runs, the moment and the stop reason
pub lock_mhz: u32, // the chosen core clock cap (0 = unlocked)
pub lock_mhs: f64,
pub lock_w: f64,
pub lock_mhw: f64,
pub unlocked_mhs: f64, // the cap point's row the lock is read against
pub unlocked_w: f64,
pub lock_step: u32, // the clock search's step while it runs (0 otherwise)
pub lock_steps: u32,
pub lock_at: f64, // unix s the lock point was taken (0 = never)
pub lock_note: String, // "rate fell 5.1 percent at 1200 MHz", "fingerprint mismatch at 1400 MHz, clocks reset", "the floor at 700 MHz", "no lever"
// the kernel variant race (docs/design/miner-tuning.md): what the worker's last race chose
pub variant: String,
pub race_mhs: f64,

View file

@ -221,11 +221,11 @@ mod tests {
#[test]
fn the_command_line_after_the_dashes_never_carries_a_double_quote_or_a_newline() {
let file = Path::new("C:\\Users\\the founder\\AppData\\Local\\igneum\\wsl\\probe-12-3.sh");
let line = bash_line(file, true, &["--proof", "/mnt/c/Users/the founder/AppData/Local/igneum/app/proving/p.bin", "--statement", "0xab", "it's", "two\nlines"]);
let file = Path::new("C:\\Users\\the project lead\\AppData\\Local\\igneum\\wsl\\probe-12-3.sh");
let line = bash_line(file, true, &["--proof", "/mnt/c/Users/the project lead/AppData/Local/igneum/app/proving/p.bin", "--statement", "0xab", "it's", "two\nlines"]);
assert_eq!(
line,
"bash -l '/mnt/c/Users/the founder/AppData/Local/igneum/wsl/probe-12-3.sh' '--proof' '/mnt/c/Users/the founder/AppData/Local/igneum/app/proving/p.bin' '--statement' '0xab' 'it'\\''s' 'two lines'"
"bash -l '/mnt/c/Users/the project lead/AppData/Local/igneum/wsl/probe-12-3.sh' '--proof' '/mnt/c/Users/the project lead/AppData/Local/igneum/app/proving/p.bin' '--statement' '0xab' 'it'\\''s' 'two lines'"
);
assert!(!line.contains('"') && !line.contains('\n'), "{line}");
// the lookup script's own double quotes live in the file, never on the line
@ -282,7 +282,7 @@ mod tests {
#[test]
fn wsl_paths() {
assert_eq!(wsl_path(Path::new("C:\\Users\\<user>\\AppData\\Local\\igneum\\app\\proving\\seq.json")), "/mnt/c/Users/<user>/AppData/Local/igneum/app/proving/seq.json");
assert_eq!(wsl_path(Path::new("C:\\Users\\[user]\\AppData\\Local\\igneum\\app\\proving\\seq.json")), "/mnt/c/Users/[user]/AppData/Local/igneum/app/proving/seq.json");
assert_eq!(wsl_path(Path::new("\\\\?\\D:\\x")), "/mnt/d/x");
assert_eq!(wsl_path(Path::new("/tmp/x")), "/tmp/x");
}

File diff suppressed because it is too large Load diff

View file

@ -1,100 +0,0 @@
// The class v5 tiers table (app/igneum-app/tiers/class-v5-tiers.json): the loader and the validator the test and the
// app's packaging read it through. The rows are in the shape src/ember.rs tier_from_json reads back from a card's state
// (id, clock_mhz, power_pct, mem_mhz, limit_w, mhs, w), so a card whose own search has not run can be set to a tier
// from this table by /api/tune/tier, and the search replaces the row when it runs. node --test class-v5-tiers.test.mjs
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
export const TIER_IDS = ['efficiency', 'balanced', 'max'];
export const LABELS = ['measured', 'estimated'];
export const SOURCES = ['measured', 'stock'];
export const VENDORS = ['nvidia', 'amd', 'apple', 'intel'];
// the fields tier_from_json reads, every one a number
export const ROW_FIELDS = ['clock_mhz', 'power_pct', 'mem_mhz', 'limit_w', 'mhs', 'w'];
// the brief's card classes (floor lane 4, 8 October 2026): every one must have an entry
export const REQUIRED_CARDS = ['5090', '5080', '5070 Ti', '5070', '5060 Ti', '5060', '4090', '4080', '4070', '3090', '3080', '3070', '3060',
'9070 XT', '7900 XTX', '7800 XT', '7600 XT', '9060 XT', 'H100', 'L40S', 'A100', 'B580', 'A750', 'M5 Max', 'M4 Max', 'M4 Pro', 'M3 Max'];
export function loadTable(path) {
const p = path || join(dirname(fileURLToPath(import.meta.url)), 'class-v5-tiers.json');
return JSON.parse(readFileSync(p, 'utf8'));
}
/** Every fault in the table as a list of strings; an empty list is a valid table. */
export function validate(t) {
const faults = [];
const f = (s) => faults.push(s);
if (t.class !== 'v5') f(`class is ${t.class}, not v5`);
if (!Array.isArray(t.tier_ids) || t.tier_ids.join() !== TIER_IDS.join()) f('tier_ids must be efficiency, balanced, max');
for (const k of ['stale_when', 'on_flip', 'measured_flip', 'period']) if (!t.remeasure_rule || typeof t.remeasure_rule[k] !== 'string' || !t.remeasure_rule[k]) f(`remeasure_rule.${k} missing`);
if (!t.v5_over_v4 || typeof t.v5_over_v4.watts_pct !== 'number') f('v5_over_v4.watts_pct missing');
if (!Array.isArray(t.cards) || t.cards.length === 0) { f('cards missing'); return faults; }
const seen = new Set();
for (const c of t.cards) {
const name = c.card || '(unnamed)';
if (seen.has(name)) f(`${name}: listed twice`);
seen.add(name);
if (!VENDORS.includes(c.vendor)) f(`${name}: vendor ${c.vendor}`);
if (!LABELS.includes(c.label)) f(`${name}: label ${c.label}`);
if (!Array.isArray(c.match) || c.match.length === 0) f(`${name}: no match list`);
if (typeof c.src !== 'string' || !c.src) f(`${name}: no src`);
if (!c.stock || typeof c.stock.uj !== 'number' || c.stock.uj <= 0) f(`${name}: stock.uj missing`);
if (!Array.isArray(c.tiers) || c.tiers.length === 0) { f(`${name}: no tiers`); continue; }
const ids = c.tiers.map((r) => r.id);
for (const id of ids) if (!TIER_IDS.includes(id)) f(`${name}: tier id ${id}`);
if (new Set(ids).size !== ids.length) f(`${name}: a tier id repeats`);
const lever = c.vendor === 'nvidia' || c.vendor === 'amd';
if (lever && ids.join() !== TIER_IDS.join()) f(`${name}: a card with a lever carries all three tiers in order`);
if (!lever && ids.join() !== 'max') f(`${name}: a card with no lever carries the max tier only`);
for (const r of c.tiers) {
for (const k of ROW_FIELDS) if (typeof r[k] !== 'number' || !Number.isFinite(r[k])) f(`${name}/${r.id}: ${k} is not a number`);
if (typeof r.power_pct === 'number' && (r.power_pct < 50 || r.power_pct > 100)) f(`${name}/${r.id}: power_pct ${r.power_pct} outside 50 to 100`);
if (typeof r.clock_mhz === 'number' && (r.clock_mhz < 0 || r.clock_mhz > 4000)) f(`${name}/${r.id}: clock_mhz ${r.clock_mhz}`);
if (!LABELS.includes(r.label)) f(`${name}/${r.id}: label ${r.label}`);
if (!SOURCES.includes(r.source)) f(`${name}/${r.id}: source ${r.source}`);
if (typeof r.uj !== 'number' || r.uj <= 0) f(`${name}/${r.id}: uj missing`);
if (typeof r.note !== 'string' || !r.note) f(`${name}/${r.id}: no note`);
if (r.mhs > 0 && r.w > 0 && r.uj > 0 && Math.abs(r.w / r.mhs - r.uj) / r.uj > 0.025) f(`${name}/${r.id}: uj ${r.uj} is not w over mhs (${(r.w / r.mhs).toFixed(2)})`);
if (r.mhs > 0 && r.w > 0 && typeof r.mhw === 'number' && Math.abs(r.mhs / r.w - r.mhw) / r.mhw > 0.025) f(`${name}/${r.id}: mhw ${r.mhw} is not mhs over w`);
if (r.id === 'max' && r.source !== 'stock') f(`${name}/max: the max tier is the stock row`);
if (r.id === 'max' && (r.clock_mhz !== 0 || r.power_pct !== 100)) f(`${name}/max: stock is unlocked at 100 percent`);
if (c.vendor === 'amd' && (typeof r.core_offset_mhz !== 'number' || typeof r.power_offset_pct !== 'number')) f(`${name}/${r.id}: an AMD row carries core_offset_mhz and power_offset_pct`);
if (c.vendor === 'amd' && r.core_offset_mhz > 0) f(`${name}/${r.id}: an AMD core offset never raises the clock`);
}
const by = Object.fromEntries(c.tiers.map((r) => [r.id, r]));
if (by.efficiency && by.max && by.efficiency.uj > by.max.uj) f(`${name}: efficiency costs more per hash than stock`);
if (by.efficiency && by.balanced && by.balanced.uj + 1e-9 < by.efficiency.uj) f(`${name}: balanced is cheaper per hash than efficiency`);
if (by.balanced && by.max && by.balanced.mhs > 0 && by.max.mhs > 0 && by.balanced.mhs < by.max.mhs * 0.98) f(`${name}: balanced gives up over 2 percent of the top rate`);
if (c.label === 'measured' && !c.tiers.some((r) => r.label === 'measured' && r.source === 'measured') && lever) f(`${name}: a measured card has no measured tuned row`);
}
for (const want of REQUIRED_CARDS) if (!t.cards.some((c) => c.card.includes(want))) f(`no entry for ${want}`);
return faults;
}
/** The entry a card name matches (the first whose match list hits; the longer match wins, so "5070 Ti" beats "5070"). */
export function entryFor(t, cardName) {
const n = cardName.toUpperCase();
let best = null, bestLen = 0;
for (const c of t.cards) for (const m of c.match) if (n.includes(m.toUpperCase()) && m.length > bestLen) { best = c; bestLen = m.length; }
return best;
}
/** The tier row a card starts from, in the shape tier_from_json reads; null when the table has none. */
export function tierRow(t, cardName, id) {
const c = entryFor(t, cardName);
if (!c) return null;
return c.tiers.find((r) => r.id === id) || null;
}
/** The re-measure verdict the app applies on a class flip (src/ember.rs tiers_stale): stale when both classes are known and differ. */
export function stale(tiersClass, programClass) {
return Boolean(tiersClass) && Boolean(programClass) && tiersClass !== programClass;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const t = loadTable();
const faults = validate(t);
if (faults.length) { console.error(faults.join('\n')); process.exit(1); }
console.log(`class ${t.class}: ${t.cards.length} card classes, ${t.cards.filter((c) => c.label === 'measured').length} measured`);
}

View file

@ -1,109 +0,0 @@
// node --test app/igneum-app/tiers/class-v5-tiers.test.mjs
// The class v5 tiers table: the shipped file validates; the measured rows carry the record's numbers; the match rule
// picks the longer name; a class flip reads stale; and the known-failed cases (a bad power rung, a missing field, a
// tuned row dearer than stock, a card class left out) are refused, so a stale or broken table cannot pass.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadTable, validate, entryFor, tierRow, stale, REQUIRED_CARDS, ROW_FIELDS } from './class-v5-tiers.mjs';
const clone = (t) => JSON.parse(JSON.stringify(t));
test('the shipped table validates with no faults', () => {
const t = loadTable();
assert.deepEqual(validate(t), []);
assert.equal(t.class, 'v5');
assert.ok(t.cards.length >= REQUIRED_CARDS.length);
});
test('the measured rows are the record\'s (the 5090 at its 1,300 MHz knee, the 5080 at 1,100, the 4070 at its tune, the 9070 XT grid, the M5 Max meter)', () => {
const t = loadTable();
const r5090 = tierRow(t, 'NVIDIA GeForce RTX 5090', 'balanced');
assert.equal(r5090.clock_mhz, 1300);
assert.equal(r5090.label, 'measured');
assert.ok(Math.abs(r5090.uj - 2.38) < 0.01, `the 5090 balanced row reads 2.38 microjoules under class v5: ${r5090.uj}`);
const e5090 = tierRow(t, 'NVIDIA GeForce RTX 5090', 'efficiency');
assert.equal(e5090.clock_mhz, 1200);
const r5080 = tierRow(t, 'NVIDIA GeForce RTX 5080', 'efficiency');
assert.equal(r5080.clock_mhz, 1100);
assert.ok(Math.abs(r5080.uj - 2.06) < 0.01);
const r4070 = tierRow(t, 'NVIDIA GeForce RTX 4070', 'efficiency');
assert.equal(r4070.clock_mhz, 1860);
assert.equal(r4070.power_pct, 50);
const amd = tierRow(t, 'AMD Radeon RX 9070 XT', 'efficiency');
assert.equal(amd.core_offset_mhz, -500);
assert.equal(amd.power_offset_pct, -30);
assert.ok(Math.abs(amd.w - 149.3) < 0.01);
const apple = entryFor(t, 'Apple M5 Max');
assert.equal(apple.tiers.length, 1);
assert.equal(apple.tiers[0].id, 'max');
assert.ok(Math.abs(apple.tiers[0].uj - 1.40) < 0.01);
});
test('every entry carries the three tiers where the card has a lever, and only max where it has none', () => {
const t = loadTable();
for (const c of t.cards) {
const ids = c.tiers.map((r) => r.id).join();
if (c.vendor === 'nvidia' || c.vendor === 'amd') assert.equal(ids, 'efficiency,balanced,max', c.card);
else assert.equal(ids, 'max', c.card);
for (const r of c.tiers) for (const k of ROW_FIELDS) assert.equal(typeof r[k], 'number', `${c.card}/${r.id}.${k}`);
}
});
test('the match rule takes the longer name: a 5070 Ti is not a 5070, a 4060 Ti is not a 4060, a 9060 XT is not a 9070 XT', () => {
const t = loadTable();
assert.equal(entryFor(t, 'NVIDIA GeForce RTX 5070 Ti').card, 'NVIDIA GeForce RTX 5070 Ti');
assert.equal(entryFor(t, 'NVIDIA GeForce RTX 5070').card, 'NVIDIA GeForce RTX 5070');
assert.equal(entryFor(t, 'NVIDIA GeForce RTX 4060 Ti').card, 'NVIDIA GeForce RTX 4060 Ti');
assert.equal(entryFor(t, 'NVIDIA GeForce RTX 4060').card, 'NVIDIA GeForce RTX 4060');
assert.equal(entryFor(t, 'AMD Radeon RX 9060 XT').card, 'AMD Radeon RX 9060 XT');
assert.equal(entryFor(t, 'amd:gfx1201').card, 'AMD Radeon RX 9070 XT');
assert.equal(entryFor(t, 'NVIDIA GeForce GTX 1080 Ti'), null);
assert.equal(tierRow(t, 'NVIDIA GeForce GTX 1080 Ti', 'max'), null);
});
test('a class flip reads stale exactly as src/ember.rs tiers_stale does', () => {
assert.equal(stale('v4', 'v5'), true);
assert.equal(stale('v5', 'v5'), false);
assert.equal(stale('', 'v5'), false);
assert.equal(stale('v4', ''), false);
const t = loadTable();
assert.ok(t.remeasure_rule.measured_flip.includes('0.0 percent of rate'));
assert.equal(t.v5_over_v4.watts_pct, 2.0);
});
test('known-failed: a power rung under 50 is refused', () => {
const t = clone(loadTable());
t.cards[0].tiers[0].power_pct = 40;
assert.ok(validate(t).some((s) => s.includes('power_pct 40')));
});
test('known-failed: a row missing a field tier_from_json reads is refused', () => {
const t = clone(loadTable());
delete t.cards[1].tiers[1].limit_w;
assert.ok(validate(t).some((s) => s.includes('limit_w is not a number')));
});
test('known-failed: a tuned row dearer per hash than stock is refused, and a max tier that is not stock', () => {
const t = clone(loadTable());
const c = t.cards.find((x) => x.card.includes('4080'));
c.tiers[0].uj = c.tiers[2].uj + 1; c.tiers[0].w = c.tiers[0].uj * c.tiers[0].mhs; c.tiers[0].mhw = c.tiers[0].mhs / c.tiers[0].w;
assert.ok(validate(t).some((s) => s.includes('efficiency costs more per hash than stock')));
const u = clone(loadTable());
u.cards[0].tiers[2].clock_mhz = 1500;
assert.ok(validate(u).some((s) => s.includes('stock is unlocked at 100 percent')));
});
test('known-failed: a card class of the brief left out is refused, and a stale uj (not w over mhs) is refused', () => {
const t = clone(loadTable());
t.cards = t.cards.filter((c) => !c.card.includes('3060'));
assert.ok(validate(t).some((s) => s === 'no entry for 3060'));
const u = clone(loadTable());
u.cards[0].tiers[1].uj = 9.99;
assert.ok(validate(u).some((s) => s.includes('is not w over mhs')));
});
test('known-failed: a table under another class is refused', () => {
const t = clone(loadTable());
t.class = 'v4';
assert.ok(validate(t).some((s) => s.includes('not v5')));
});

View file

@ -267,7 +267,7 @@ var View = (function () {
function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; }
var kindWord = Notices.kindWord;
// hot-plug (src/hotplug.rs): a removed card's row hides after five minutes (gone); a faulty one has no switch
// The list is ordered by performance (the founder, 6 October 2026): usable cards first, then by the measured rate since the
// The list is ordered by performance (the project lead, 6 October 2026): usable cards first, then by the measured rate since the
// start (5 MH/s buckets so the order does not flicker), then discrete, external and Apple before integrated, then
// memory; removed and unusable cards last. Ties keep the detection order.
function perfRank(c) {

View file

@ -1,490 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
"rand_core",
"subtle",
"zeroize",
]
[[package]]
name = "crypto-common"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"elliptic-curve",
"signature",
"spki",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"ff",
"generic-array",
"group",
"pkcs8",
"rand_core",
"sec1",
"subtle",
"zeroize",
]
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core",
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "generic-array"
version = "0.14.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core",
"subtle",
]
[[package]]
name = "igneum-common"
version = "0.1.0"
dependencies = [
"ed25519-dalek",
"getrandom",
"k256",
"libc",
"serde",
"serde_json",
"sha2",
"sha3",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "k256"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
dependencies = [
"cfg-if",
"ecdsa",
"elliptic-curve",
"once_cell",
]
[[package]]
name = "keccak"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
dependencies = [
"cpufeatures",
]
[[package]]
name = "libc"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array",
"pkcs8",
"subtle",
"zeroize",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha3"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
dependencies = [
"digest",
"keccak",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"

View file

@ -1,19 +0,0 @@
[package]
name = "igneum-common"
version = "0.1.0"
edition = "2021"
description = "What Igneum Miner and Igneum Wallet share: platform helpers, the payout key code, the signed update manifest, the local dashboard server primitives and the packaged configuration"
license = "MIT"
publish = false
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
k256 = { version = "0.13", default-features = false, features = ["arithmetic", "std"] }
sha3 = { version = "0.10", default-features = false }
getrandom = "0.2"
ed25519-dalek = { version = "2", default-features = false, features = ["std"] }
sha2 = { version = "0.10", default-features = false }
[target.'cfg(unix)'.dependencies]
libc = "0.2"

View file

@ -1,352 +0,0 @@
//! The biometric gate, the part that needs no Touch ID and no Windows Hello: challenges the engine issues, the host
//! confirms after the prompt, and the action consumes once. The window host (macOS: app/mac/Biometric.swift;
//! Windows: the WebView2 host) holds the secret; this module only decides whether a confirmation is fresh.
//!
//! The flow for a gated action:
//! 1. the window asks the engine for a challenge: `Gate::issue(purpose, bound, reason)` gives a nonce; the reason
//! (at most 80 characters) is what the prompt shows, built by the engine so the window cannot word it;
//! 2. the window hands the nonce to the host; the host reads the reason from the engine (with the host token, which
//! only the host knows: the engine printed it on its stdout), shows the prompt, and on success posts
//! `Gate::confirm(nonce)`;
//! 3. the window calls the action with the nonce; the engine runs `Gate::take(nonce, purpose, bound)`: confirmed
//! within CHALLENGE_TTL_S, the same purpose and the same binding (the quote for a send, the new address for an
//! address change), never used before. One nonce, one action.
//!
//! Enrolment (the wallet): the window posts the password to the engine, which checks it and keeps it under a one-time
//! token for ENROL_TTL_S; the host takes it with the token after the prompt, seals it and writes the file. The
//! password reaches the host over 127.0.0.1 only, never through the page.
use serde::Serialize;
use std::time::{Duration, Instant};
/// A confirmation is fresh for this long after the prompt succeeded.
pub const CHALLENGE_TTL_S: u64 = 30;
/// An unconfirmed challenge waits for the prompt this long (the confirm screen can sit open).
pub const CHALLENGE_WAIT_S: u64 = 180;
/// The enrolment token's life.
pub const ENROL_TTL_S: u64 = 120;
/// The prompt's reason string: at most this many characters (the hard clip); the strings the engines build stay
/// under 60, in our voice, no trailing full stop ("Unlock your wallet", "Send 1.5 IGN to 0x7F45…C126").
pub const REASON_MAX: usize = 80;
/// The idle lock (the wallet): minutes without the window reporting activity before the key is zeroed.
pub const IDLE_LOCK_MIN: u64 = 5;
/// What `/api/state` carries under `biometric`.
#[derive(Clone, Serialize, Default)]
pub struct BiometricState {
/// the host said the prompt can be shown (Touch ID set up, Windows Hello configured)
pub available: bool,
/// touchid | hello | "" (no host yet)
pub kind: String,
/// the sealed file exists: the gated actions need the prompt
pub enrolled: bool,
/// the host's word for why it is unavailable, in the window's wording
pub message: String,
/// the last prompt's outcome: ok | cancelled | failed | locked | invalidated | unavailable | ""
pub last_result: String,
pub last_op: String,
pub last_at: f64,
/// the wallet: lock after IDLE_LOCK_MIN minutes idle (setting, on by default once enrolled)
pub idle_lock: bool,
pub idle_lock_min: u64,
/// set when the password changed under an enrolment: the sealed password is stale and was removed
pub needs_enrol: bool,
}
pub struct Challenge {
pub nonce: String,
pub purpose: String,
pub bound: String,
pub reason: String,
issued: Instant,
confirmed: Option<Instant>,
used: bool,
}
#[derive(Default)]
pub struct Gate {
challenges: Vec<Challenge>,
enrol: Option<(String, String, Instant)>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum GateError {
Unknown,
Expired,
NotConfirmed,
Stale,
Used,
Mismatch,
}
impl GateError {
/// The window's wording. `what` is "Touch ID" or "Windows Hello".
pub fn text(&self, what: &str) -> String {
match self {
GateError::Unknown => format!("confirm with {what} first"),
GateError::Expired => format!("the {what} request timed out; try again"),
GateError::NotConfirmed => format!("{what} did not confirm this; try again"),
GateError::Stale => format!("the {what} confirmation is older than {CHALLENGE_TTL_S} s; confirm again"),
GateError::Used => format!("that {what} confirmation was already used; confirm again"),
GateError::Mismatch => format!("the {what} confirmation was for something else; confirm again"),
}
}
}
fn random_hex(n: usize) -> String {
let mut raw = vec![0u8; n];
getrandom::getrandom(&mut raw).expect("os randomness");
crate::keys::hex(&raw)
}
/// Cuts a reason to REASON_MAX characters (not bytes), with a trailing ellipsis when it was longer.
pub fn clip_reason(s: &str) -> String {
let count = s.chars().count();
if count <= REASON_MAX {
return s.to_string();
}
let mut out: String = s.chars().take(REASON_MAX - 1).collect();
out.push('…');
out
}
/// The prompt's line for a send: the amount (the caller gives it to 4 decimals) and the checksum address as its
/// first 6 and last 4 characters: "Send 1.5 IGN to 0x7F45…C126".
pub fn send_reason(amount_ign: &str, display_to: &str) -> String {
let short = if display_to.len() > 10 { format!("{}…{}", &display_to[..6], &display_to[display_to.len() - 4..]) } else { display_to.to_string() };
clip_reason(&format!("Send {amount_ign} IGN to {short}"))
}
impl Gate {
pub fn new() -> Gate {
Gate::default()
}
fn prune(&mut self, now: Instant) {
// used nonces stay until their window ends, so a replay is answered "used", not "unknown"
self.challenges.retain(|c| now.duration_since(c.issued) < Duration::from_secs(CHALLENGE_WAIT_S + CHALLENGE_TTL_S));
if let Some((_, _, t)) = &self.enrol {
if now.duration_since(*t) >= Duration::from_secs(ENROL_TTL_S) {
self.enrol = None;
}
}
}
pub fn issue(&mut self, purpose: &str, bound: &str, reason: &str, now: Instant) -> String {
self.prune(now);
let nonce = random_hex(16);
self.challenges.push(Challenge { nonce: nonce.clone(), purpose: purpose.into(), bound: bound.into(), reason: clip_reason(reason), issued: now, confirmed: None, used: false });
nonce
}
/// For the host: what the prompt says for this nonce.
pub fn reason_of(&self, nonce: &str) -> Option<(String, String)> {
self.challenges.iter().find(|c| c.nonce == nonce && !c.used).map(|c| (c.purpose.clone(), c.reason.clone()))
}
/// The host says the prompt succeeded for this nonce.
pub fn confirm(&mut self, nonce: &str, now: Instant) -> Result<(), GateError> {
self.prune(now);
let c = self.challenges.iter_mut().find(|c| c.nonce == nonce).ok_or(GateError::Unknown)?;
if c.used {
return Err(GateError::Used);
}
if now.duration_since(c.issued) >= Duration::from_secs(CHALLENGE_WAIT_S) {
return Err(GateError::Expired);
}
c.confirmed = Some(now);
Ok(())
}
/// The action runs: fresh, the same purpose and binding, once.
pub fn take(&mut self, nonce: &str, purpose: &str, bound: &str, now: Instant) -> Result<(), GateError> {
let c = self.challenges.iter_mut().find(|c| c.nonce == nonce).ok_or(GateError::Unknown)?;
if c.used {
return Err(GateError::Used);
}
let Some(t) = c.confirmed else {
return Err(if now.duration_since(c.issued) >= Duration::from_secs(CHALLENGE_WAIT_S) { GateError::Expired } else { GateError::NotConfirmed });
};
if c.purpose != purpose || c.bound != bound {
return Err(GateError::Mismatch);
}
if now.duration_since(t) >= Duration::from_secs(CHALLENGE_TTL_S) {
c.used = true;
return Err(GateError::Stale);
}
c.used = true;
Ok(())
}
/// Enrolment: the engine keeps the checked secret under a one-time token.
pub fn enrol_begin(&mut self, secret: &str, now: Instant) -> String {
let token = random_hex(16);
self.enrol = Some((token.clone(), secret.to_string(), now));
token
}
/// The host takes the secret with the token, once.
pub fn enrol_take(&mut self, token: &str, now: Instant) -> Option<String> {
self.prune(now);
match self.enrol.take() {
Some((t, s, _)) if constant_eq(&t, token) => Some(s),
Some(other) => {
// a wrong token does not burn the pending enrolment
self.enrol = Some(other);
None
}
None => None,
}
}
pub fn enrol_pending(&self) -> bool {
self.enrol.is_some()
}
pub fn enrol_cancel(&mut self) {
self.enrol = None;
}
#[cfg(test)]
fn len(&self) -> usize {
self.challenges.len()
}
}
/// Equal strings, compared in constant time over the longer length.
pub fn constant_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
let mut diff = (a.len() ^ b.len()) as u8;
for i in 0..a.len().max(b.len()) {
diff |= a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(0);
}
diff == 0
}
/// A fingerprint of a send quote, so the confirmation binds to what the window showed.
pub fn send_binding(to: &str, value: &str, tx_nonce: u64, chain_id: u64) -> String {
format!("send:{}:{}:{}:{}", to.to_ascii_lowercase(), value, tx_nonce, chain_id)
}
#[cfg(test)]
mod tests {
use super::*;
fn t(base: Instant, s: u64) -> Instant {
base + Duration::from_secs(s)
}
#[test]
fn confirm_then_take_once() {
let mut g = Gate::new();
let now = Instant::now();
let n = g.issue("send", "send:0xab:1:0:7", "Send 1 IGN to 0xab", now);
assert_eq!(g.reason_of(&n), Some(("send".into(), "Send 1 IGN to 0xab".into())));
// not confirmed yet
assert_eq!(g.take(&n, "send", "send:0xab:1:0:7", t(now, 1)), Err(GateError::NotConfirmed));
g.confirm(&n, t(now, 2)).unwrap();
// wrong binding, wrong purpose
assert_eq!(g.take(&n, "send", "send:0xcd:1:0:7", t(now, 3)), Err(GateError::Mismatch));
assert_eq!(g.take(&n, "reveal", "send:0xab:1:0:7", t(now, 3)), Err(GateError::Mismatch));
// the right one, once
assert_eq!(g.take(&n, "send", "send:0xab:1:0:7", t(now, 3)), Ok(()));
assert_eq!(g.take(&n, "send", "send:0xab:1:0:7", t(now, 4)), Err(GateError::Used));
assert_eq!(g.confirm(&n, t(now, 4)), Err(GateError::Used));
assert!(g.reason_of(&n).is_none());
}
#[test]
fn replay_and_expiry() {
let mut g = Gate::new();
let now = Instant::now();
assert_eq!(g.take("nope", "send", "", now), Err(GateError::Unknown));
assert_eq!(g.confirm("nope", now), Err(GateError::Unknown));
// a confirmation older than the TTL is stale and burns the nonce
let n = g.issue("reveal", "", "Show the words", now);
g.confirm(&n, t(now, 1)).unwrap();
assert_eq!(g.take(&n, "reveal", "", t(now, 1 + CHALLENGE_TTL_S)), Err(GateError::Stale));
assert_eq!(g.take(&n, "reveal", "", t(now, 2)), Err(GateError::Used));
// a challenge nobody confirmed within the wait expires
let n2 = g.issue("reveal", "", "Show the words", now);
assert_eq!(g.confirm(&n2, t(now, CHALLENGE_WAIT_S)), Err(GateError::Expired));
assert_eq!(g.take(&n2, "reveal", "", t(now, CHALLENGE_WAIT_S)), Err(GateError::Expired));
// pruned away after wait + ttl
let _ = g.issue("reveal", "", "x", t(now, CHALLENGE_WAIT_S + CHALLENGE_TTL_S + 1));
assert_eq!(g.len(), 1);
// and a used nonce still answers "used" inside its window
let n4 = g.issue("send", "b", "r", t(now, 1000));
g.confirm(&n4, t(now, 1001)).unwrap();
assert_eq!(g.take(&n4, "send", "b", t(now, 1002)), Ok(()));
assert_eq!(g.confirm(&n4, t(now, 1003)), Err(GateError::Used));
// a fresh confirmation at the edge still works
let n3 = g.issue("send", "b", "r", now);
g.confirm(&n3, t(now, CHALLENGE_WAIT_S - 1)).unwrap();
assert_eq!(g.take(&n3, "send", "b", t(now, CHALLENGE_WAIT_S - 1 + CHALLENGE_TTL_S - 1)), Ok(()));
}
#[test]
fn nonces_are_distinct_and_hex() {
let mut g = Gate::new();
let now = Instant::now();
let a = g.issue("send", "", "r", now);
let b = g.issue("send", "", "r", now);
assert_ne!(a, b);
assert_eq!(a.len(), 32);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn enrol_token_one_time_and_expiry() {
let mut g = Gate::new();
let now = Instant::now();
let tok = g.enrol_begin("correct horse", now);
assert!(g.enrol_pending());
// a wrong token leaves the pending enrolment in place
assert_eq!(g.enrol_take("wrong", t(now, 1)), None);
assert!(g.enrol_pending());
assert_eq!(g.enrol_take(&tok, t(now, 1)).as_deref(), Some("correct horse"));
assert_eq!(g.enrol_take(&tok, t(now, 1)), None);
assert!(!g.enrol_pending());
// expiry
let tok2 = g.enrol_begin("p", now);
assert_eq!(g.enrol_take(&tok2, t(now, ENROL_TTL_S)), None);
// cancel
let tok3 = g.enrol_begin("p", now);
g.enrol_cancel();
assert_eq!(g.enrol_take(&tok3, t(now, 1)), None);
}
#[test]
fn reasons_are_clipped_to_eighty() {
let long = "x".repeat(200);
assert_eq!(clip_reason(&long).chars().count(), REASON_MAX);
assert_eq!(clip_reason("short"), "short");
let r = send_reason("1.5", "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf");
assert_eq!(r, "Send 1.5 IGN to 0x7E5F…5Bdf");
assert!(r.chars().count() < 60);
// a long amount still fits under 60
let r2 = send_reason("123456789.1234", "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf");
assert_eq!(r2, "Send 123456789.1234 IGN to 0x7E5F…5Bdf");
assert!(r2.chars().count() < 60);
// absurd amount: still clipped at 80 characters
let r3 = send_reason(&"9".repeat(90), "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf");
assert_eq!(r3.chars().count(), REASON_MAX);
}
#[test]
fn constant_eq_and_binding() {
assert!(constant_eq("abc", "abc"));
assert!(!constant_eq("abc", "abd"));
assert!(!constant_eq("abc", "abcd"));
assert!(!constant_eq("", "a"));
assert_eq!(send_binding("0xAB", "5", 3, 7), "send:0xab:5:3:7");
}
#[test]
fn state_defaults() {
let s = BiometricState::default();
assert!(!s.available && !s.enrolled && s.kind.is_empty() && s.last_result.is_empty());
let v = serde_json::to_value(&s).unwrap();
assert_eq!(v["idle_lock_min"], 0);
}
}

View file

@ -1,89 +0,0 @@
//! The packager's configuration next to the binary (`igneum-app.json` for the miner, `igneum-wallet.json` for the
//! wallet; macOS: Contents/Resources) and the per-install machine id. From app/igneum-app/src/config.rs.
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// Written by the packager (build-dmg.sh, make-payload.sh). Missing fields disable the feature.
#[derive(Clone, Serialize, Deserialize, Default)]
pub struct Packaged {
#[serde(default)]
pub update_manifest: String,
#[serde(default)]
pub log_intake_url: String,
#[serde(default)]
pub log_intake_key: String,
#[serde(default)]
pub live_page: String,
#[serde(default)]
pub download_page: String,
/// Consensus parameters the packager pins for the bundled node (`--override-params-file`). Absent = none.
#[serde(default)]
pub node_override_params: Option<serde_json::Value>,
/// Wallet: the public Ethereum JSON-RPC of the seed, when one exists (`https://...`); empty = none known.
#[serde(default)]
pub public_rpc: String,
/// Wallet: the page on the site that adds the network to MetaMask (`https://igneum.network/wallet/add`).
#[serde(default)]
pub add_network_page: String,
}
impl Packaged {
pub fn load(candidates: &[PathBuf]) -> Packaged {
for c in candidates {
if let Some(p) = std::fs::read_to_string(c).ok().and_then(|t| serde_json::from_str::<Packaged>(&t).ok()) {
return p;
}
}
Packaged::default()
}
/// The places the packaged file can be: the binary's folder (Windows), Contents/Resources (macOS), IGNEUM_APP_BIN.
pub fn candidates(file_name: &str) -> Vec<PathBuf> {
let mut out = vec![];
if let Some(d) = std::env::var_os("IGNEUM_APP_BIN") {
out.push(PathBuf::from(d).join(file_name));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(d) = exe.parent() {
out.push(d.join(file_name));
if let Some(c) = d.parent() {
out.push(c.join("Resources").join(file_name));
}
}
}
out
}
}
/// Reads the machine id, or makes one on the first run (16 hex from the OS) and locks the file to the user.
pub fn machine_id(app_dir: &Path) -> String {
let path = app_dir.join("machine-id");
if let Some(id) = std::fs::read_to_string(&path).ok().map(|s| s.trim().to_ascii_lowercase()) {
if id.len() == 16 && id.chars().all(|c| c.is_ascii_hexdigit()) {
return id;
}
}
let mut raw = [0u8; 8];
getrandom::getrandom(&mut raw).expect("os randomness");
let id = crate::keys::hex(&raw);
let _ = std::fs::create_dir_all(app_dir);
crate::platform::lock_permissions(app_dir, true);
let _ = std::fs::write(&path, format!("{id}\n"));
crate::platform::lock_permissions(&path, false);
id
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn packaged_carries_the_wallet_fields() {
let p: Packaged = serde_json::from_str(r#"{"update_manifest":"","public_rpc":"https://rpc.igneum.network","node_override_params":{"difficulty_v2_activation_daa":123456}}"#).unwrap();
assert_eq!(p.public_rpc, "https://rpc.igneum.network");
assert_eq!(p.node_override_params.as_ref().unwrap()["difficulty_v2_activation_daa"], 123456);
let p: Packaged = serde_json::from_str(r#"{"update_manifest":""}"#).unwrap();
assert!(p.node_override_params.is_none());
assert!(p.public_rpc.is_empty());
}
}

View file

@ -1,107 +0,0 @@
//! The over-the-air update's network side, as the miner does it (app/igneum-app/src/ota.rs): the manifest and its
//! signature fetched with curl (macOS ships it; Windows 10 1803 and later ship curl.exe, so the engine carries no TLS
//! stack), verified before parsing; the installer or disk image downloaded next to the manifest with resume (a
//! dropped line continues the .part file) and checked against the manifest's sha256 and size.
use crate::manifest::{self, Manifest, PlatformEntry};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
pub fn curl(args: &[&str], limit: Duration) -> Result<(), String> {
let mut c = Command::new(crate::platform::tool("curl"));
c.args(args);
let out = crate::run::run_timeout(&mut c, None, limit).ok_or("curl is not available")?;
let code = out.lines().last().unwrap_or("").trim().to_string();
// 206: a resumed download (-C -) answers partial content
if code.starts_with("200") || code.starts_with("206") {
Ok(())
} else {
Err(format!("http {}", if code.is_empty() { "no answer".to_string() } else { code }))
}
}
/// GET `url` to `dest` with curl; `limit` bounds the whole transfer.
pub fn curl_get(url: &str, dest: &Path, limit: Duration) -> Result<(), String> {
curl(&["-fsSL", "--max-time", &limit.as_secs().to_string(), "-o", &dest.display().to_string(), "-w", "%{http_code}", url], limit + Duration::from_secs(5))
}
/// Fetches `<url>` and `<url>.sig` into `dir`, verifies the bytes with the embedded OTA public key, parses.
/// Writes `manifest.json` to `dir` only after the check.
pub fn fetch_manifest(url: &str, dir: &Path) -> Result<Manifest, String> {
let m = dir.join("manifest.json.new");
let s = dir.join("manifest.json.sig.new");
curl_get(url, &m, Duration::from_secs(30)).map_err(|e| format!("manifest: {e}"))?;
curl_get(&format!("{url}.sig"), &s, Duration::from_secs(30)).map_err(|e| format!("manifest signature: {e}"))?;
let bytes = std::fs::read(&m).map_err(|e| e.to_string())?;
let sig = std::fs::read_to_string(&s).map_err(|e| e.to_string())?;
let parsed = manifest::verify_and_parse(&bytes, sig.trim(), manifest::OTA_PUBLIC_KEY_HEX)?;
let _ = std::fs::rename(&m, dir.join("manifest.json"));
let _ = std::fs::rename(&s, dir.join("manifest.json.sig"));
Ok(parsed)
}
/// The file name a download keeps: the last path segment of the url, cleaned to [A-Za-z0-9.-_] (the miner's rule).
pub fn file_name(url: &str) -> String {
let name = url.rsplit('/').next().unwrap_or("download").split('?').next().unwrap_or("download");
let clean: String = name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-' || *c == '_').collect();
if clean.is_empty() { "download".into() } else { clean }
}
/// The .part file a download in progress writes next to the final name.
pub fn part_path(e: &PlatformEntry, dir: &Path) -> PathBuf {
dir.join(format!("{}.part", file_name(&e.url)))
}
/// How much of the platform entry is on disk right now, 0..1 (the dashboard's progress bar).
pub fn progress(e: &PlatformEntry, dir: &Path) -> f64 {
if e.size == 0 {
return 0.0;
}
let have = std::fs::metadata(part_path(e, dir)).map(|m| m.len()).unwrap_or(0);
(have as f64 / e.size as f64).min(1.0)
}
/// Downloads the platform entry into `dir` (skipped when a file with the right size and sha256 is there already),
/// resuming a .part file from an earlier try, and checks size and sha256. Returns the path.
pub fn download(e: &PlatformEntry, dir: &Path) -> Result<PathBuf, String> {
let dest = dir.join(file_name(&e.url));
let ok = |p: &Path| -> bool {
std::fs::metadata(p).map(|m| m.len() == e.size).unwrap_or(false) && manifest::sha256_file(p).map(|s| s == e.sha256).unwrap_or(false)
};
if ok(&dest) {
return Ok(dest);
}
let _ = std::fs::remove_file(&dest);
let part = part_path(e, dir);
let have = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if have > e.size {
let _ = std::fs::remove_file(&part);
}
if have != e.size {
// -C - resumes a partial file; --retry covers a dropped connection; 2 hours for a slow line
curl(&["-fsSL", "--retry", "3", "--retry-delay", "5", "-C", "-", "--max-time", "7200", "-o", &part.display().to_string(), "-w", "%{http_code}", &e.url], Duration::from_secs(7260))?;
}
let size = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if size != e.size {
let _ = std::fs::remove_file(&part);
return Err(format!("the download is {size} bytes, the manifest says {}", e.size));
}
let sum = manifest::sha256_file(&part).map_err(|e| e.to_string())?;
if sum != e.sha256 {
let _ = std::fs::remove_file(&part);
return Err("the download's sha256 does not match the manifest".into());
}
std::fs::rename(&part, &dest).map_err(|e| e.to_string())?;
Ok(dest)
}
#[cfg(test)]
mod tests {
#[test]
fn file_names_are_cleaned() {
assert_eq!(super::file_name("https://dl.igneum.network/dl/t/Igneum-Wallet-0.1.1.dmg"), "Igneum-Wallet-0.1.1.dmg");
assert_eq!(super::file_name("https://x/y/Setup%20.exe?x=1"), "Setup20.exe");
assert_eq!(super::file_name("https://x/"), "download");
}
}

View file

@ -1,258 +0,0 @@
//! The local dashboard server primitives (from app/igneum-app/src/server.rs): plain HTTP/1.1 on 127.0.0.1 with a
//! random port, every path under `/t/<token>/`, one thread per connection, Connection: close. And a small JSON client
//! for a node's Ethereum JSON-RPC on 127.0.0.1 (no TLS: the wallet reaches a public https RPC through curl instead).
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
pub struct Req {
pub method: String,
pub path: String,
pub query: String,
pub body: Vec<u8>,
pub origin: Option<String>,
pub sec_fetch_site: Option<String>,
pub host: Option<String>,
/// X-Igneum-Host: the window host's token (the engine printed it on stdout; the page never sees it)
pub host_token: Option<String>,
/// X-Igneum-Bridge: present on a page's call to the wallet's page bridge (a custom header, so the browser sends a
/// CORS preflight first and a plain form post from a stranger never reaches the handler)
pub bridge_header: bool,
/// Access-Control-Request-Private-Network: the browser asks (Chrome's private network access) before a page on the
/// public web reaches 127.0.0.1; the bridge answers the preflight with the allow header
pub private_network_ask: bool,
}
pub fn read_request(stream: &mut TcpStream) -> Option<Req> {
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(10)));
let mut reader = BufReader::new(stream.try_clone().ok()?);
let mut line = String::new();
reader.read_line(&mut line).ok()?;
let mut parts = line.split_whitespace();
let method = parts.next()?.to_string();
let target = parts.next()?.to_string();
let mut content_length = 0usize;
let (mut origin, mut sec_fetch_site, mut host, mut host_token) = (None, None, None, None);
let (mut bridge_header, mut private_network_ask) = (false, false);
loop {
let mut h = String::new();
reader.read_line(&mut h).ok()?;
let h = h.trim_end();
if h.is_empty() {
break;
}
if let Some((k, v)) = h.split_once(':') {
let v = v.trim();
if k.eq_ignore_ascii_case("content-length") {
content_length = v.parse().unwrap_or(0);
} else if k.eq_ignore_ascii_case("origin") {
origin = Some(v.to_string());
} else if k.eq_ignore_ascii_case("sec-fetch-site") {
sec_fetch_site = Some(v.to_ascii_lowercase());
} else if k.eq_ignore_ascii_case("host") {
host = Some(v.to_string());
} else if k.eq_ignore_ascii_case("x-igneum-host") {
host_token = Some(v.to_string());
} else if k.eq_ignore_ascii_case("x-igneum-bridge") {
bridge_header = true;
} else if k.eq_ignore_ascii_case("access-control-request-private-network") {
private_network_ask = v.eq_ignore_ascii_case("true");
}
}
}
if content_length > 1 << 20 {
return None;
}
let mut body = vec![0u8; content_length];
if content_length > 0 {
reader.read_exact(&mut body).ok()?;
}
let (path, query) = match target.split_once('?') {
Some((p, q)) => (p.to_string(), q.to_string()),
None => (target, String::new()),
};
Some(Req { method, path, query, body, origin, sec_fetch_site, host, host_token, bridge_header, private_network_ask })
}
/// R4.3.7: a mutating request must come from the dashboard itself. A browser sends Sec-Fetch-Site (same-origin for
/// the dashboard; cross-site, same-site or none otherwise) and, on POST, an Origin; a cross-site page can reach
/// 127.0.0.1 only through the browser, so both are checked. A request without either header (curl, the window host)
/// still needs the token in the path.
pub fn from_dashboard(req: &Req, port: u16) -> bool {
if let Some(s) = &req.sec_fetch_site {
if s != "same-origin" && s != "none" {
return false;
}
}
if let Some(o) = &req.origin {
let ok = o == &format!("http://127.0.0.1:{port}") || o == &format!("http://localhost:{port}");
if !ok {
return false;
}
}
if let Some(h) = &req.host {
if h != &format!("127.0.0.1:{port}") && h != &format!("localhost:{port}") {
return false;
}
}
true
}
pub fn respond(stream: &mut TcpStream, status: u16, ctype: &str, body: &[u8], cache: bool) {
let reason = match status {
200 => "OK",
204 => "No Content",
400 => "Bad Request",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
_ => "Error",
};
let head = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: {}\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\n\r\n",
body.len(),
if cache { "public, max-age=86400" } else { "no-store" }
);
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(body);
let _ = stream.flush();
}
pub fn json_resp(stream: &mut TcpStream, status: u16, v: serde_json::Value) {
respond(stream, status, "application/json; charset=utf-8", v.to_string().as_bytes(), false);
}
pub fn query_param(q: &str, key: &str) -> Option<String> {
q.split('&').find_map(|kv| {
let (k, v) = kv.split_once('=')?;
if k == key { Some(v.to_string()) } else { None }
})
}
/// Binds 127.0.0.1 on a random port and serves every connection on its own thread through `handle`.
pub fn serve<F>(handle: F) -> std::io::Result<u16>
where
F: Fn(TcpStream) + Send + Sync + 'static,
{
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let handle = std::sync::Arc::new(handle);
std::thread::spawn(move || {
for conn in listener.incoming() {
let Ok(stream) = conn else { continue };
let handle = handle.clone();
std::thread::spawn(move || handle(stream));
}
});
Ok(port)
}
/// Binds 127.0.0.1 on a FIXED port (the wallet's page bridge, 8 October 2026: a page on the web can only find the
/// wallet at a port it knows) and serves every connection on its own thread through `handle`. Err when the port is taken.
pub fn serve_on<F>(port: u16, handle: F) -> std::io::Result<()>
where
F: Fn(TcpStream) + Send + Sync + 'static,
{
let listener = TcpListener::bind(("127.0.0.1", port))?;
let handle = std::sync::Arc::new(handle);
std::thread::spawn(move || {
for conn in listener.incoming() {
let Ok(stream) = conn else { continue };
let handle = handle.clone();
std::thread::spawn(move || handle(stream));
}
});
Ok(())
}
/// A response with extra headers (the bridge's CORS and private-network answers), Connection: close, no cache.
pub fn respond_with(stream: &mut TcpStream, status: u16, ctype: &str, body: &[u8], extra: &[(&str, &str)]) {
let reason = match status {
200 => "OK",
204 => "No Content",
400 => "Bad Request",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
_ => "Error",
};
let mut head = format!("HTTP/1.1 {status} {reason}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\n", body.len());
for (k, v) in extra {
head.push_str(k);
head.push_str(": ");
head.push_str(v);
head.push_str("\r\n");
}
head.push_str("\r\n");
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(body);
let _ = stream.flush();
}
/// The per-launch dashboard token: 32 hex characters from the OS.
pub fn new_token() -> String {
let mut raw = [0u8; 16];
getrandom::getrandom(&mut raw).expect("os randomness");
crate::keys::hex(&raw)
}
// ---- a JSON POST to 127.0.0.1 (the node's Ethereum RPC) -------------------------------------------------------
/// POSTs `body` as JSON to `http://127.0.0.1:<port><path>` (or any plain-http host:port) and returns the body.
pub fn post_json(host_port: &str, path: &str, body: &str, timeout: std::time::Duration) -> Result<String, String> {
let mut s = TcpStream::connect(host_port).map_err(|e| format!("connect {host_port}: {e}"))?;
let _ = s.set_read_timeout(Some(timeout));
let _ = s.set_write_timeout(Some(timeout));
let req = format!(
"POST {path} HTTP/1.1\r\nHost: {host_port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
s.write_all(req.as_bytes()).map_err(|e| e.to_string())?;
let mut reader = BufReader::new(s);
let mut status = String::new();
reader.read_line(&mut status).map_err(|e| e.to_string())?;
let code: u16 = status.split_whitespace().nth(1).and_then(|c| c.parse().ok()).unwrap_or(0);
let mut len: Option<usize> = None;
let mut chunked = false;
loop {
let mut h = String::new();
reader.read_line(&mut h).map_err(|e| e.to_string())?;
let h = h.trim_end();
if h.is_empty() {
break;
}
if let Some((k, v)) = h.split_once(':') {
if k.eq_ignore_ascii_case("content-length") {
len = v.trim().parse().ok();
} else if k.eq_ignore_ascii_case("transfer-encoding") && v.trim().eq_ignore_ascii_case("chunked") {
chunked = true;
}
}
}
let mut out = Vec::new();
if chunked {
loop {
let mut l = String::new();
reader.read_line(&mut l).map_err(|e| e.to_string())?;
let n = usize::from_str_radix(l.trim().split(';').next().unwrap_or("0"), 16).unwrap_or(0);
if n == 0 {
break;
}
let mut buf = vec![0u8; n];
reader.read_exact(&mut buf).map_err(|e| e.to_string())?;
out.extend_from_slice(&buf);
let mut crlf = String::new();
let _ = reader.read_line(&mut crlf);
}
} else if let Some(n) = len {
out = vec![0u8; n];
reader.read_exact(&mut out).map_err(|e| e.to_string())?;
} else {
reader.read_to_end(&mut out).map_err(|e| e.to_string())?;
}
let text = String::from_utf8_lossy(&out).into_owned();
if code != 200 {
return Err(format!("http {code}: {}", text.chars().take(200).collect::<String>()));
}
Ok(text)
}

View file

@ -1,109 +0,0 @@
//! The payout key: a secp256k1 private key made on this machine, its EVM address, and the miner's wallet file.
//! The miner's file lives in its app data directory (`app/wallet.json`), plain JSON with the permissions locked to the
//! user: 0600 on macOS and Linux, an icacls grant to the signed-in user alone on Windows. Igneum Wallet imports that
//! file and keeps its own key encrypted (app/igneum-wallet/src/vault.rs). From app/igneum-app/src/keys.rs.
use k256::elliptic_curve::sec1::ToEncodedPoint;
use k256::SecretKey;
use serde::{Deserialize, Serialize};
use sha3::{Digest, Keccak256};
use std::path::Path;
#[derive(Clone, Serialize, Deserialize)]
pub struct Wallet {
pub address: String, // 0x + 40 lower-case hex
pub private_key: String, // 0x + 64 hex, secp256k1
pub created: u64, // unix seconds
}
pub fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Hex (with or without 0x) to bytes; None when not hex or odd length.
pub fn unhex(s: &str) -> Option<Vec<u8>> {
let s = s.trim().trim_start_matches("0x");
if s.len() % 2 != 0 {
return None;
}
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect()
}
/// The lower-case 0x address of a raw 32-byte private key, or None when the scalar is not a valid key.
pub fn address_of_raw(raw: &[u8; 32]) -> Option<String> {
SecretKey::from_slice(raw).ok().map(|sk| address_of(&sk))
}
/// The EVM address of a secp256k1 private key: keccak256 of the uncompressed public key (without the 0x04 prefix), last 20 bytes.
pub fn address_of(sk: &SecretKey) -> String {
let pk = sk.public_key();
let point = pk.to_encoded_point(false);
let bytes = point.as_bytes();
let h = Keccak256::digest(&bytes[1..]);
format!("0x{}", hex(&h[12..]))
}
/// A fresh key from the operating system's randomness.
pub fn generate() -> Wallet {
loop {
let mut raw = [0u8; 32];
getrandom::getrandom(&mut raw).expect("os randomness");
if let Ok(sk) = SecretKey::from_slice(&raw) {
let address = address_of(&sk);
return Wallet { address, private_key: format!("0x{}", hex(&raw)), created: crate::platform::unix_now() };
}
}
}
/// EIP-55 mixed-case form of a lower-case address, for display.
pub fn checksum(addr: &str) -> String {
let body = addr.trim_start_matches("0x").to_ascii_lowercase();
let h = Keccak256::digest(body.as_bytes());
let mut out = String::with_capacity(42);
out.push_str("0x");
for (i, c) in body.chars().enumerate() {
let nibble = (h[i / 2] >> (if i % 2 == 0 { 4 } else { 0 })) & 0xf;
if c.is_ascii_alphabetic() && nibble >= 8 {
out.push(c.to_ascii_uppercase());
} else {
out.push(c);
}
}
out
}
/// True for 0x followed by 40 hex characters.
pub fn valid_address(s: &str) -> bool {
let s = s.trim();
s.len() == 42 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit())
}
pub fn save(path: &Path, w: &Wallet) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
crate::platform::lock_permissions(dir, true);
}
let text = serde_json::to_string_pretty(w).expect("wallet json");
std::fs::write(path, text)?;
crate::platform::lock_permissions(path, false);
Ok(())
}
pub fn load(path: &Path) -> Option<Wallet> {
let text = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&text).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_vector() {
// The well-known test key 0x01: address 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf
let mut raw = [0u8; 32];
raw[31] = 1;
let sk = SecretKey::from_slice(&raw).unwrap();
assert_eq!(checksum(&address_of(&sk)), "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf");
}
}

View file

@ -1,46 +0,0 @@
//! igneum-common: the parts of the Igneum Miner engine (app/igneum-app) that Igneum Wallet (app/igneum-wallet) ships
//! on too. Extracted 4 October 2026 as a copy with the app identity made a parameter; the miner keeps its own copies
//! until its concurrent branches land and can switch to this crate behind a feature flag (nothing in app/igneum-app
//! was changed for the wallet).
//!
//! - `platform`: directories, file permissions, opening a URL, start at login, keep awake, terminate, quarantine
//! - `keys`: secp256k1 key, EVM address, EIP-55 checksum, the miner's plain `wallet.json` format
//! - `manifest`: the signed over-the-air update manifest, byte-identical to app/igneum-app/src/manifest.rs
//! - `fetch`: the manifest fetch, the download (resumed, as the miner's) and its sha256 check (through curl)
//! - `ota`: the apply side of an over-the-air update: the staged bundle, the detached helper that swaps and relaunches,
//! the pending/result files; from app/igneum-app/src/ota.rs with the app's names from `AppId`
//! - `http`: the 127.0.0.1 dashboard server primitives (request parsing, the token path, the same-origin guard) and a
//! small JSON-over-HTTP client for a node's Ethereum RPC on 127.0.0.1
//! - `run`: a command with a time limit
//! - `config`: the packager's `igneum-app.json` and the per-install machine id
//! - `biometric`: the Touch ID / Windows Hello gate logic (nonces, one-time enrolment token, state); the prompt and
//! the sealed secret live in the window hosts
pub mod biometric;
pub mod config;
pub mod fetch;
pub mod http;
pub mod keys;
pub mod manifest;
pub mod ota;
pub mod platform;
pub mod run;
/// What differs between the two apps when the platform code names them.
#[derive(Clone, Copy, Debug)]
pub struct AppId {
/// "Igneum Miner" or "Igneum Wallet": the window title, the login item name, the Windows Run key value.
pub name: &'static str,
/// "network.igneum.miner" or "network.igneum.wallet": the macOS bundle id and launch agent label.
pub bundle: &'static str,
/// The Windows window host next to the engine: "Igneum Miner.exe" or "Igneum Wallet.exe".
pub host_exe: &'static str,
/// The sub-folder of the shared data root this app writes under: "app" (the miner, as before) or "wallet".
pub data_sub: &'static str,
/// The engine binary next to the window host: "igneum-app" or "igneum-wallet" (".exe" on Windows). The update
/// helper names it when it checks that the new app started.
pub engine_exe: &'static str,
}
pub const MINER: AppId = AppId { name: "Igneum Miner", bundle: "network.igneum.miner", host_exe: "Igneum Miner.exe", data_sub: "app", engine_exe: "igneum-app" };
pub const WALLET: AppId = AppId { name: "Igneum Wallet", bundle: "network.igneum.wallet", host_exe: "Igneum Wallet.exe", data_sub: "wallet", engine_exe: "igneum-wallet" };

View file

@ -1,527 +0,0 @@
//! The over-the-air update manifest: what the downloads host publishes as `igneum-app-latest.json` with a detached
//! Ed25519 signature next to it (`igneum-app-latest.json.sig`, 64 bytes as 128 hex characters). The signature is over
//! the exact bytes of the manifest file; the publisher (packaging/ota/publish-manifest.sh) writes the file in canonical
//! form (sorted keys, no whitespace) and the app verifies the bytes before it parses them.
//!
//! This module is self-contained (serde_json, ed25519-dalek, sha2 only), so the signer binary
//! (src/bin/ota-sign.rs) includes it with `#[path]` and signs with the very code that verifies.
//!
//! Manifest shape:
//! {
//! "version": "0.3.1", "published_at": "2026-10-04T13:00:00Z", "channel": "devnet",
//! "platforms": { "mac": {"url","sha256","size","kind":"dmg"|"zip"}, "windows": {"url","sha256","size","kind":"inno-setup"} },
//! "min_supported_version": "0.3.0", "notes": "one line",
//! "consensus": { "activation_height": null|number, "deadline_note": "" }
//! }
//! A platform that is missing is not updated (the Windows build lands later than the Mac one).
#![allow(dead_code)]
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use sha2::{Digest, Sha256};
/// The public half of ~/.config/igneum/ota-signing-key (generated once on the Mac with `igneum-ota-sign keygen`;
/// the private key never enters the repo or CI). Fingerprint: SHA-256 of these 32 bytes, see `fingerprint()`.
pub const OTA_PUBLIC_KEY_HEX: &str = "b3c9c5bd144e9d246dc0edf897387d4f3f7ca494cd47457123d1c2f892cabddd";
/// How many DAA blocks before a consensus activation height the app stops waiting for a safe moment.
pub const FORK_URGENT_BLOCKS: u64 = 1_800;
/// No apply within this many seconds of an hourly program boundary.
pub const BOUNDARY_GUARD_S: i64 = 180;
/// A ready update that found no safe moment for this long is applied anyway (an unsynced node mines nothing).
pub const SAFE_MOMENT_PATIENCE_S: u64 = 6 * 3600;
#[derive(Clone, Debug, PartialEq, Default)]
pub struct PlatformEntry {
pub url: String,
pub sha256: String,
pub size: u64,
pub kind: String, // dmg | zip | inno-setup
}
#[derive(Clone, Debug, PartialEq, Default)]
pub struct Manifest {
pub version: String,
pub published_at: String,
pub channel: String,
pub mac: Option<PlatformEntry>,
pub windows: Option<PlatformEntry>,
pub min_supported_version: String,
pub notes: String,
pub activation_height: Option<u64>,
pub deadline_note: String,
/// consensus.override: the exact object the engine writes to <app data>/override.json for igneumd's
/// --override-params-file (for example {"difficulty_v2_activation_daa": N}); signed with the rest of the manifest.
pub override_params: Option<serde_json::Value>,
}
impl Manifest {
pub fn platform(&self, name: &str) -> Option<&PlatformEntry> {
match name {
"mac" => self.mac.as_ref(),
"windows" => self.windows.as_ref(),
_ => None,
}
}
pub fn this_platform(&self) -> Option<&PlatformEntry> {
self.platform(platform_name())
}
}
pub fn platform_name() -> &'static str {
if cfg!(target_os = "macos") {
"mac"
} else if cfg!(windows) {
"windows"
} else {
"linux"
}
}
pub fn hex_decode(s: &str) -> Option<Vec<u8>> {
let s = s.trim();
if s.len() % 2 != 0 {
return None;
}
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect()
}
pub fn hex_encode(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
pub fn public_key(hex: &str) -> Result<VerifyingKey, String> {
let bytes = hex_decode(hex).ok_or("public key is not hex")?;
let arr: [u8; 32] = bytes.try_into().map_err(|_| "public key is not 32 bytes")?;
VerifyingKey::from_bytes(&arr).map_err(|e| format!("public key invalid: {e}"))
}
/// SHA-256 of the raw public key bytes, as hex: what the report and the docs quote.
pub fn fingerprint(pub_hex: &str) -> String {
match hex_decode(pub_hex) {
Some(b) => hex_encode(&Sha256::digest(&b)),
None => String::new(),
}
}
/// Checks the detached signature (hex) over the manifest bytes with the given public key (hex).
pub fn verify_signature(manifest_bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result<(), String> {
let key = public_key(pub_hex)?;
let sig = hex_decode(sig_hex).ok_or("signature is not hex")?;
let sig: [u8; 64] = sig.try_into().map_err(|_| "signature is not 64 bytes")?;
let sig = Signature::from_bytes(&sig);
key.verify(manifest_bytes, &sig).map_err(|_| "manifest signature does not verify".to_string())
}
/// Parses the manifest JSON (after the signature was checked).
pub fn parse(text: &str) -> Result<Manifest, String> {
let v: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("manifest is not JSON: {e}"))?;
let s = |v: &serde_json::Value, k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
let version = s(&v, "version");
if parse_version(&version).is_none() {
return Err(format!("manifest version '{version}' is not a version"));
}
let entry = |name: &str| -> Result<Option<PlatformEntry>, String> {
let Some(p) = v.get("platforms").and_then(|p| p.get(name)) else { return Ok(None) };
if p.is_null() {
return Ok(None);
}
let e = PlatformEntry { url: s(p, "url"), sha256: s(p, "sha256").to_ascii_lowercase(), size: p.get("size").and_then(|x| x.as_u64()).unwrap_or(0), kind: s(p, "kind") };
if !e.url.starts_with("https://") && !e.url.starts_with("http://127.0.0.1:") {
return Err(format!("{name}: the url is not https"));
}
if e.sha256.len() != 64 || !e.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!("{name}: sha256 is not 64 hex characters"));
}
if e.size == 0 {
return Err(format!("{name}: size is missing"));
}
if !["dmg", "zip", "inno-setup"].contains(&e.kind.as_str()) {
return Err(format!("{name}: kind '{}' is unknown", e.kind));
}
Ok(Some(e))
};
let consensus = v.get("consensus").cloned().unwrap_or(serde_json::Value::Null);
Ok(Manifest {
version,
published_at: s(&v, "published_at"),
channel: s(&v, "channel"),
mac: entry("mac")?,
windows: entry("windows")?,
min_supported_version: s(&v, "min_supported_version"),
notes: s(&v, "notes"),
activation_height: consensus.get("activation_height").and_then(|x| x.as_u64()),
deadline_note: s(&consensus, "deadline_note"),
override_params: match consensus.get("override") {
Some(o) if o.is_object() && !o.as_object().unwrap().is_empty() => Some(o.clone()),
Some(o) if !o.is_null() => return Err("consensus.override must be an object".into()),
_ => None,
},
})
}
/// Verifies, then parses.
pub fn verify_and_parse(manifest_bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result<Manifest, String> {
verify_signature(manifest_bytes, sig_hex, pub_hex)?;
let text = std::str::from_utf8(manifest_bytes).map_err(|_| "manifest is not UTF-8")?;
parse(text)
}
/// A digest of a whole directory (the staged app bundle): sha256 over "relative path\nsha256 of the file\n" for every
/// regular file in byte-sorted path order (symlinks skipped). The macOS helper recomputes the same with find, sort
/// and shasum right before the swap (R4.3.5).
pub fn digest_dir(root: &std::path::Path) -> std::io::Result<String> {
fn walk(dir: &std::path::Path, root: &std::path::Path, out: &mut Vec<String>) -> std::io::Result<()> {
for e in std::fs::read_dir(dir)? {
let e = e?;
let ft = e.file_type()?;
let p = e.path();
if ft.is_symlink() {
continue;
}
if ft.is_dir() {
walk(&p, root, out)?;
} else if ft.is_file() {
out.push(p.strip_prefix(root).unwrap_or(&p).to_string_lossy().replace('\\', "/"));
}
}
Ok(())
}
let mut files = Vec::new();
walk(root, root, &mut files)?;
files.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
let mut h = Sha256::new();
for f in files {
let sum = sha256_file(&root.join(&f))?;
h.update(f.as_bytes());
h.update(b"\n");
h.update(sum.as_bytes());
h.update(b"\n");
}
Ok(hex_encode(&h.finalize()))
}
/// SHA-256 of a file, streamed, as hex.
pub fn sha256_file(path: &std::path::Path) -> std::io::Result<String> {
use std::io::Read;
let mut f = std::fs::File::open(path)?;
let mut h = Sha256::new();
let mut buf = vec![0u8; 1 << 20];
loop {
let n = f.read(&mut buf)?;
if n == 0 {
break;
}
h.update(&buf[..n]);
}
Ok(hex_encode(&h.finalize()))
}
// ---- versions -----------------------------------------------------------------------------------------------
/// major.minor.patch plus an optional pre-release tag ("0.4.0-rc1" sorts before "0.4.0"). A leading "v" is allowed.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Version {
pub parts: [u64; 3],
pub pre: Option<String>,
}
pub fn parse_version(s: &str) -> Option<Version> {
let s = s.trim().trim_start_matches('v');
if s.is_empty() {
return None;
}
let (num, pre) = match s.split_once('-') {
Some((n, p)) if !p.is_empty() => (n, Some(p.to_string())),
Some(_) => return None,
None => (s, None),
};
let mut parts = [0u64; 3];
let mut n = 0;
for p in num.split('.') {
if n >= 3 || p.is_empty() {
return None;
}
parts[n] = p.parse().ok()?;
n += 1;
}
if n == 0 {
return None;
}
Some(Version { parts, pre })
}
impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Version {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
match self.parts.cmp(&other.parts) {
std::cmp::Ordering::Equal => match (&self.pre, &other.pre) {
(None, None) => std::cmp::Ordering::Equal,
(None, Some(_)) => std::cmp::Ordering::Greater,
(Some(_), None) => std::cmp::Ordering::Less,
(Some(a), Some(b)) => a.cmp(b),
},
o => o,
}
}
}
/// True when `latest` is a newer version than `current`. Unparseable input is never newer.
pub fn newer(latest: &str, current: &str) -> bool {
match (parse_version(latest), parse_version(current)) {
(Some(a), Some(b)) => a > b,
_ => false,
}
}
// ---- when to apply --------------------------------------------------------------------------------------------
/// What the engine knows when it asks whether now is a safe moment.
#[derive(Clone, Debug, Default)]
pub struct Moment {
pub node_synced: bool,
/// DAA blocks (about seconds) to the next hourly program boundary; None when the node has not said.
pub boundary_eta_s: Option<i64>,
/// A worker is starting, exporting a pack or being built: let it finish.
pub miner_busy: bool,
/// How long the update has been ready and waiting.
pub ready_for_s: u64,
/// A consensus activation is close, or this version is below min_supported_version: now beats later.
pub urgent: bool,
/// This minute is the machine's own slot (slot_minute): machines take turns, two never restart together.
pub slot_ok: bool,
/// How much of the network's identity count (/api/live, last 10 minutes) is gone right now, in percent.
pub network_drop_pct: f64,
}
/// The percentage of identities the network may lose in 10 minutes before updates hold (we are the devnet).
pub const NETWORK_DROP_HOLD_PCT: f64 = 30.0;
/// The minute of the hour in which this machine applies updates: the first 8 hex of the machine id modulo 60.
pub fn slot_minute(id8: &str) -> u64 {
u64::from_str_radix(id8.trim(), 16).unwrap_or(0) % 60
}
/// Ok when the update may be applied now; Err carries the reason to wait, in the words the dashboard shows.
pub fn safe_to_apply(m: &Moment) -> Result<(), String> {
if m.urgent {
return Ok(());
}
if m.network_drop_pct > NETWORK_DROP_HOLD_PCT {
return Err(format!("the network lost {:.0}% of its identities in the last 10 minutes; holding the update", m.network_drop_pct));
}
if !m.slot_ok {
return Err("waiting for this machine's own minute of the hour (machines take turns)".into());
}
if m.ready_for_s >= SAFE_MOMENT_PATIENCE_S {
return Ok(());
}
if !m.node_synced {
return Err("waiting for the node to sync".into());
}
if let Some(eta) = m.boundary_eta_s {
if (0..=BOUNDARY_GUARD_S).contains(&eta) {
return Err(format!("hourly program boundary in {} s; installing after it", eta.max(1)));
}
}
if m.miner_busy {
return Err("a worker is starting; installing once it runs".into());
}
Ok(())
}
/// A consensus activation is within FORK_URGENT_BLOCKS of the node's DAA score (and the node has a score).
pub fn fork_is_close(activation_height: Option<u64>, daa: u64) -> bool {
match activation_height {
Some(h) if daa > 0 => daa.saturating_add(FORK_URGENT_BLOCKS) >= h,
_ => false,
}
}
/// `current` is older than the manifest's min_supported_version.
pub fn unsupported(m: &Manifest, current: &str) -> bool {
!m.min_supported_version.is_empty() && newer(&m.min_supported_version, current)
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
/// The helper's bash recipe (find | sort | shasum per file | shasum) must equal digest_dir.
#[test]
#[cfg(target_os = "macos")]
fn digest_dir_matches_the_helper() {
let root = std::env::temp_dir().join(format!("igneum-digest-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("Contents/MacOS")).unwrap();
std::fs::write(root.join("Contents/MacOS/igneum-app"), b"engine").unwrap();
std::fs::write(root.join("Contents/Info.plist"), b"<plist/>").unwrap();
std::fs::write(root.join("Contents/zed.txt"), b"z").unwrap();
let ours = digest_dir(&root).unwrap();
let recipe = r#"(cd "$1" && /usr/bin/find . -type f -print | LC_ALL=C /usr/bin/sort | while IFS= read -r f; do printf '%s\n%s\n' "${f#./}" "$(/usr/bin/shasum -a 256 "$f" | /usr/bin/cut -d' ' -f1)"; done) | /usr/bin/shasum -a 256 | /usr/bin/cut -d' ' -f1"#;
let out = std::process::Command::new("/bin/bash").args(["-c", recipe, "x", &root.display().to_string()]).output().unwrap();
let theirs = String::from_utf8_lossy(&out.stdout).trim().to_string();
let _ = std::fs::remove_dir_all(&root);
assert_eq!(ours, theirs);
}
#[test]
fn consensus_override_parses() {
let m = parse(r#"{"version":"0.3.2","platforms":{},"consensus":{"activation_height":5000,"override":{"difficulty_v2_activation_daa":5000}}}"#).unwrap();
assert_eq!(m.activation_height, Some(5000));
assert_eq!(m.override_params.unwrap()["difficulty_v2_activation_daa"], 5000);
assert!(parse(r#"{"version":"0.3.2","platforms":{},"consensus":{"override":"no"}}"#).is_err());
}
const SAMPLE: &str = r#"{"channel":"devnet","consensus":{"activation_height":120000,"deadline_note":"difficulty v2"},"min_supported_version":"0.3.0","notes":"difficulty v2 at height 120000","platforms":{"mac":{"kind":"dmg","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":20588331,"url":"https://dl.igneum.network/dl/t/Igneum-Miner-0.3.1.dmg"},"windows":{"kind":"inno-setup","sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","size":43978429,"url":"https://dl.igneum.network/dl/t/Igneum-Miner-Setup-0.3.1.exe"}},"published_at":"2026-10-04T13:00:00Z","version":"0.3.1"}"#;
fn key() -> (SigningKey, String) {
let sk = SigningKey::from_bytes(&[7u8; 32]);
let pk = hex_encode(sk.verifying_key().as_bytes());
(sk, pk)
}
#[test]
fn parses_manifest() {
let m = parse(SAMPLE).unwrap();
assert_eq!(m.version, "0.3.1");
assert_eq!(m.channel, "devnet");
assert_eq!(m.activation_height, Some(120000));
assert_eq!(m.deadline_note, "difficulty v2");
assert_eq!(m.min_supported_version, "0.3.0");
let mac = m.mac.as_ref().unwrap();
assert_eq!(mac.kind, "dmg");
assert_eq!(mac.size, 20588331);
assert_eq!(m.windows.as_ref().unwrap().kind, "inno-setup");
assert_eq!(m.platform("linux"), None);
}
#[test]
fn missing_platform_is_none_and_bad_entries_fail() {
let m = parse(r#"{"version":"0.3.1","platforms":{"mac":null},"consensus":{"activation_height":null}}"#).unwrap();
assert!(m.mac.is_none() && m.windows.is_none());
assert_eq!(m.activation_height, None);
assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"http://x","sha256":"aa","size":1,"kind":"dmg"}}}"#).unwrap_err().contains("https"));
assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"http://127.0.0.1:29790/x.dmg","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":1,"kind":"dmg"}}}"#).is_ok());
assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"https://x","sha256":"aa","size":1,"kind":"dmg"}}}"#).unwrap_err().contains("sha256"));
assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"https://x","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":1,"kind":"tar"}}}"#).unwrap_err().contains("kind"));
assert!(parse(r#"{"version":"latest"}"#).is_err());
assert!(parse("not json").is_err());
}
#[test]
fn signature_verifies_and_tampering_fails() {
let (sk, pk) = key();
let sig = hex_encode(&sk.sign(SAMPLE.as_bytes()).to_bytes());
assert!(verify_signature(SAMPLE.as_bytes(), &sig, &pk).is_ok());
let m = verify_and_parse(SAMPLE.as_bytes(), &sig, &pk).unwrap();
assert_eq!(m.version, "0.3.1");
// a tampered sha256 inside the manifest: the bytes changed, the signature no longer verifies
let tampered = SAMPLE.replace("aaaaaaaa", "aaaaaaab");
assert!(verify_and_parse(tampered.as_bytes(), &sig, &pk).is_err());
// a bad signature
let mut bad = sig.clone();
bad.replace_range(0..2, if &sig[0..2] == "00" { "01" } else { "00" });
assert!(verify_signature(SAMPLE.as_bytes(), &bad, &pk).is_err());
// another key
let other = hex_encode(SigningKey::from_bytes(&[9u8; 32]).verifying_key().as_bytes());
assert!(verify_signature(SAMPLE.as_bytes(), &sig, &other).is_err());
// garbage
assert!(verify_signature(SAMPLE.as_bytes(), "zz", &pk).is_err());
assert!(verify_signature(SAMPLE.as_bytes(), &sig, "abcd").is_err());
}
#[test]
fn sha256_of_file_is_checked_by_the_caller() {
let dir = std::env::temp_dir().join(format!("igneum-manifest-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("x.bin");
std::fs::write(&f, b"abc").unwrap();
assert_eq!(sha256_file(&f).unwrap(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
std::fs::write(&f, b"abd").unwrap();
assert_ne!(sha256_file(&f).unwrap(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn versions() {
assert!(newer("0.3.1", "0.3.0"));
assert!(newer("0.4.0", "0.3.9"));
assert!(newer("1.0.0", "0.99.99"));
assert!(newer("v0.3.1", "0.3.0"));
assert!(!newer("0.3.0", "0.3.0"));
assert!(!newer("0.2.9", "0.3.0"));
assert!(!newer("0.3", "0.3.0"));
assert!(newer("0.3.1", "0.3"));
assert!(newer("0.3.1", "0.3.1-rc1"));
assert!(!newer("0.3.1-rc1", "0.3.1"));
assert!(newer("0.3.1-rc2", "0.3.1-rc1"));
assert!(!newer("", "0.3.0"));
assert!(!newer("latest", "0.3.0"));
assert!(!newer("0.3.1", "garbage"));
assert!(!newer("0.3.1-", "0.3.0"));
assert!(!newer("0.3.1.2", "0.3.0"));
}
#[test]
fn safe_moments() {
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0 };
assert!(safe_to_apply(&base).is_ok());
assert_eq!(safe_to_apply(&Moment { node_synced: false, ..base.clone() }).unwrap_err(), "waiting for the node to sync");
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(120), ..base.clone() }).unwrap_err().contains("boundary in 120 s"));
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(0), ..base.clone() }).is_err());
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(181), ..base.clone() }).is_ok());
assert!(safe_to_apply(&Moment { boundary_eta_s: None, ..base.clone() }).is_ok());
assert!(safe_to_apply(&Moment { miner_busy: true, ..base.clone() }).unwrap_err().contains("worker"));
// urgent beats every wait
assert!(safe_to_apply(&Moment { node_synced: false, boundary_eta_s: Some(5), miner_busy: true, urgent: true, ..base.clone() }).is_ok());
// patience: an unsynced node for 6 h applies anyway
assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_ok());
assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S - 1, ..base.clone() }).is_err());
// the machine's slot: outside it nothing applies, not even with patience; urgent ignores it
assert!(safe_to_apply(&Moment { slot_ok: false, ..base.clone() }).unwrap_err().contains("own minute"));
assert!(safe_to_apply(&Moment { slot_ok: false, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_err());
assert!(safe_to_apply(&Moment { slot_ok: false, urgent: true, ..base.clone() }).is_ok());
// the network guard: over 30% of identities gone in 10 minutes holds the update (we are the devnet)
assert!(safe_to_apply(&Moment { network_drop_pct: 30.0, ..base.clone() }).is_ok());
assert!(safe_to_apply(&Moment { network_drop_pct: 30.1, ..base.clone() }).unwrap_err().contains("lost 30%"));
assert!(safe_to_apply(&Moment { network_drop_pct: 80.0, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_err());
assert!(safe_to_apply(&Moment { network_drop_pct: 80.0, urgent: true, ..base.clone() }).is_ok());
}
#[test]
fn slots() {
assert_eq!(slot_minute("1ccfe586"), 58); // PC 2
assert_eq!(slot_minute("00000000"), 0);
assert_eq!(slot_minute("0000003c"), 0);
assert_eq!(slot_minute("0000003b"), 59);
assert_eq!(slot_minute("zz"), 0);
}
#[test]
fn fork_closeness_and_support() {
assert!(!fork_is_close(None, 100_000));
assert!(!fork_is_close(Some(120_000), 0));
assert!(!fork_is_close(Some(120_000), 118_199));
assert!(fork_is_close(Some(120_000), 118_200));
assert!(fork_is_close(Some(120_000), 120_000));
assert!(fork_is_close(Some(120_000), 130_000));
let m = parse(SAMPLE).unwrap();
assert!(!unsupported(&m, "0.3.0"));
assert!(unsupported(&m, "0.2.9"));
assert!(!unsupported(&parse(r#"{"version":"0.3.1"}"#).unwrap(), "0.0.1"));
}
#[test]
fn fingerprint_is_sha256_of_key_bytes() {
let (_, pk) = key();
assert_eq!(fingerprint(&pk).len(), 64);
assert_eq!(fingerprint("zz"), "");
}
}

View file

@ -1,524 +0,0 @@
//! The apply side of an over-the-air update, shared by both apps. Taken from app/igneum-app/src/ota.rs (the miner's
//! updater, 4 October 2026; the miner keeps its own copy until it moves to this crate) with the app's names filled in
//! from `AppId`: the staged bundle, the detached helper that swaps it in and relaunches, and the pending/result files
//! the old engine, the helper and the new engine pass around. The manifest check and the download live in
//! `crate::fetch`; when to apply is each app's own business (the miner waits for a safe mining moment, the wallet for
//! no send in flight).
//!
//! macOS: `stage` mounts the disk image (or unpacks the zip), copies the bundle next to the running one as
//! ".<App>.app.new" (same volume, so the swap is two renames) and checks the new engine answers --version with the
//! manifest's version. `launch_apply` re-hashes the download and the staged bundle, writes update-pending.json and
//! ota-apply.sh, starts the helper detached and returns `Launch::QuitNow`: the engine leaves through its quit path.
//! The helper waits for the engine, asks the window to quit (by bundle id), moves the old bundle to
//! "<App>.app.previous", the staged one in, opens the new app, and puts the previous one back when the new app does
//! not start twice. The new engine counts its starts in update-pending.json; on the third start without
//! `HEALTHY_AFTER_S` healthy seconds it asks for `launch_rollback`.
//! Windows: the staged artefact is the Inno installer. `launch_apply` starts ota-apply.ps1 detached (CREATE_NO_WINDOW,
//! commit 0d123b3), which runs the installer /VERYSILENT first while the engine keeps running; the installer stops the
//! engine itself (api/quit) and relaunches the app with /IGNOTA=1. An unanswered administrator prompt comes back as
//! `deferred` in update-result.json. The wallet has never run this path (5 October 2026): untested there.
#![allow(dead_code)]
use crate::manifest::{self, PlatformEntry};
use crate::AppId;
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
/// A new version is healthy once it has run this long; the update is then complete and the leftovers go.
pub const HEALTHY_AFTER_S: u64 = 90;
/// What launch_apply started.
#[derive(Debug, PartialEq)]
pub enum Launch {
/// macOS: the helper waits for this engine to exit; the engine leaves through its quit path now.
QuitNow,
/// Windows: the installer runs first while the engine keeps running; the installer stops the engine itself
/// once it is allowed to run. The engine stays up and watches update-result.json for a deferral.
InstallerRunning,
}
/// What the old engine leaves for the new one (update-pending.json).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Pending {
pub from: String,
pub to: String,
pub at: f64,
pub starts: u32,
pub previous_installer: String,
}
/// The helper's verdict (update-result.json).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct HelperResult {
pub ok: bool,
pub version: String,
pub error: String,
pub rolled_back: bool,
pub deferred: bool,
}
pub fn pending_path(app_dir: &Path) -> PathBuf {
app_dir.join("update-pending.json")
}
pub fn result_path(app_dir: &Path) -> PathBuf {
app_dir.join("update-result.json")
}
pub fn read_pending(app_dir: &Path) -> Option<Pending> {
parse_pending(&std::fs::read_to_string(pending_path(app_dir)).ok()?)
}
pub fn parse_pending(text: &str) -> Option<Pending> {
let v: Value = serde_json::from_str(text).ok()?;
let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
Some(Pending { from: s("from"), to: s("to"), at: v.get("at").and_then(|x| x.as_f64()).unwrap_or(0.0), starts: v.get("starts").and_then(|x| x.as_u64()).unwrap_or(0) as u32, previous_installer: s("previous_installer") })
}
pub fn write_pending(app_dir: &Path, p: &Pending) {
let v = json!({ "from": p.from, "to": p.to, "at": p.at, "starts": p.starts, "previous_installer": p.previous_installer, "platform": manifest::platform_name() });
let _ = std::fs::write(pending_path(app_dir), v.to_string());
}
pub fn read_result(app_dir: &Path) -> Option<HelperResult> {
parse_result(&std::fs::read_to_string(result_path(app_dir)).ok()?)
}
pub fn parse_result(text: &str) -> Option<HelperResult> {
let v: Value = serde_json::from_str(text).ok()?;
let b = |k: &str| v.get(k).and_then(|x| x.as_bool()).unwrap_or(false);
let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
Some(HelperResult { ok: b("ok"), version: s("version"), error: s("error"), rolled_back: b("rolled_back"), deferred: b("deferred") })
}
/// "Igneum-Wallet-Setup-0.1.1.exe": the installer name the Windows packaging gives a version.
pub fn installer_name_for(app: AppId, version: &str) -> String {
format!("{}-Setup-{version}.exe", app.name.replace(' ', "-"))
}
/// The staged bundle's path next to the running one (macOS).
pub fn staged_path(app: AppId, bundle: &Path) -> Option<PathBuf> {
bundle.parent().map(|p| p.join(format!(".{}.app.new", app.name)))
}
/// Starts a process that outlives the engine (stdio closed, own session on unix, no window on Windows).
pub fn spawn_detached(c: &mut Command) -> Result<(), String> {
use std::process::Stdio;
c.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
c.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
c.creation_flags(0x0800_0000 | 0x0000_0008); // CREATE_NO_WINDOW | DETACHED_PROCESS
}
c.spawn().map(|_| ()).map_err(|e| format!("cannot start the helper: {e}"))
}
/// The engine's own environment for the helper's relaunch (a test run on a private devnet or a scratch data folder):
/// every variable whose name starts with one of `prefixes`, written to <app dir>/ota-relaunch.env. "" when there is
/// none, and the helper opens the bundle through LaunchServices.
pub fn write_env_file(app_dir: &Path, prefixes: &[&str]) -> String {
let vars: Vec<String> = std::env::vars().filter(|(k, _)| prefixes.iter().any(|p| k.starts_with(p))).map(|(k, v)| format!("{k}={v}")).collect();
if vars.is_empty() {
return String::new();
}
let p = app_dir.join("ota-relaunch.env");
if std::fs::write(&p, vars.join("\n") + "\n").is_ok() { p.display().to_string() } else { String::new() }
}
/// macOS: the new bundle next to the running one (same volume, so the swap is two renames); Windows: the installer
/// is the staged artefact. Err("manual: ...") when the engine cannot swap itself (not in a bundle, a read-only
/// Applications folder): the window then offers the download instead.
#[allow(unused_variables)]
pub fn stage(app: AppId, e: &PlatformEntry, file: &Path, dir: &Path, version: &str) -> Result<PathBuf, String> {
#[cfg(target_os = "macos")]
{
let bundle_name = format!("{}.app", app.name);
let bundle = crate::platform::bundle_path().ok_or(format!("manual: the engine is not running from {bundle_name}; open the downloaded disk image and drag the app to Applications"))?;
let parent = bundle.parent().ok_or("no parent folder")?;
let staged = staged_path(app, &bundle).ok_or("no parent folder")?;
let _ = std::fs::remove_dir_all(&staged);
// writable? a user-owned /Applications is; a managed Mac may not be
if std::fs::create_dir(&staged).is_err() {
return Err(format!("manual: {} is not writable; open the downloaded disk image and drag the app over the old one", parent.display()));
}
let _ = std::fs::remove_dir(&staged);
let work = dir.join("unpack");
let _ = std::fs::remove_dir_all(&work);
std::fs::create_dir_all(&work).map_err(|e| e.to_string())?;
let source: PathBuf;
let mut mounted: Option<PathBuf> = None;
if e.kind == "dmg" {
let mnt = work.join("mnt");
std::fs::create_dir_all(&mnt).map_err(|e| e.to_string())?;
let out = crate::run::run_timeout(Command::new(crate::platform::tool("hdiutil")).args(["attach", "-nobrowse", "-readonly", "-noautoopen", "-noverify", "-mountpoint", &mnt.display().to_string(), &file.display().to_string()]), None, Duration::from_secs(120)).unwrap_or_default();
if !mnt.join(&bundle_name).is_dir() {
return Err(format!("the disk image has no {bundle_name} ({})", out.lines().last().unwrap_or("hdiutil said nothing")));
}
mounted = Some(mnt.clone());
source = mnt.join(&bundle_name);
} else {
let out = crate::run::run_timeout(Command::new(crate::platform::tool("ditto")).args(["-x", "-k", &file.display().to_string(), &work.display().to_string()]), None, Duration::from_secs(300)).unwrap_or_default();
source = find_app(&work, &bundle_name).ok_or(format!("the zip has no {bundle_name} ({})", out.lines().last().unwrap_or("")))?;
}
let engine = staged.join("Contents/MacOS").join(app.engine_exe);
let r = (|| -> Result<(), String> {
let out = crate::run::run_timeout(Command::new(crate::platform::tool("ditto")).arg(&source).arg(&staged), None, Duration::from_secs(300)).unwrap_or_default();
if !engine.is_file() {
return Err(format!("copy failed: {}", out.lines().last().unwrap_or("")));
}
// the quarantine flag comes off only after the file this bundle came from verified again, now
let again = manifest::sha256_file(file).map_err(|e| e.to_string())?;
if again != e.sha256 {
return Err("the download changed while it was being unpacked; discarded".into());
}
let _ = Command::new(crate::platform::tool("xattr")).args(["-dr", "com.apple.quarantine"]).arg(&staged).output();
let v = crate::run::run_timeout(Command::new(&engine).arg("--version"), None, Duration::from_secs(20)).unwrap_or_default();
let want = format!("{} {version}", app.engine_exe);
if v.trim() != want {
return Err(format!("the new engine answers '{}' to --version, the manifest says {version}", v.trim()));
}
Ok(())
})();
if let Some(m) = mounted {
let _ = Command::new(crate::platform::tool("hdiutil")).args(["detach", "-force", &m.display().to_string()]).output();
}
let _ = std::fs::remove_dir_all(&work);
if let Err(err) = r {
let _ = std::fs::remove_dir_all(&staged);
return Err(err);
}
Ok(staged)
}
#[cfg(windows)]
{
if e.kind != "inno-setup" {
return Err(format!("kind '{}' is not an installer", e.kind));
}
Ok(file.to_path_buf())
}
#[cfg(not(any(target_os = "macos", windows)))]
{
Err("manual: no automatic install on this platform".into())
}
}
fn find_app(dir: &Path, bundle_name: &str) -> Option<PathBuf> {
let rd = std::fs::read_dir(dir).ok()?;
for e in rd.flatten() {
let p = e.path();
if p.file_name().map(|n| n == bundle_name).unwrap_or(false) && p.is_dir() {
return Some(p);
}
if p.is_dir() {
if let Some(f) = find_app(&p, bundle_name) {
return Some(f);
}
}
}
None
}
/// Windows: an install under Program Files was made by an administrator installer.
pub fn under_program_files(dir: &Path) -> bool {
let d = dir.to_string_lossy().to_ascii_lowercase();
["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"].iter().filter_map(|k| std::env::var(k).ok()).any(|pf| !pf.is_empty() && d.starts_with(&pf.to_ascii_lowercase()))
}
/// Everything launch_apply needs. `staged_digest` is manifest::digest_dir of the staged bundle at stage time (macOS);
/// `sha256` the manifest's for the installer (Windows); `env_file` from write_env_file or "".
pub struct Apply<'a> {
pub app: AppId,
pub app_dir: &'a Path,
pub current: &'a str,
pub version: &'a str,
pub staged: &'a Path,
pub staged_digest: &'a str,
pub sha256: &'a str,
pub host_pid: u32,
pub env_file: String,
/// Windows: the installer of the version now running, kept in updates/ as the rollback target ("" when none)
pub previous_installer: String,
}
/// Writes update-pending.json and the helper, starts the helper detached. The caller verified the download and the
/// staged bundle a moment ago (sha256 and digest_dir); the helper checks the digest once more before the swap.
pub fn launch_apply(a: &Apply) -> Result<Launch, String> {
write_pending(a.app_dir, &Pending { from: a.current.to_string(), to: a.version.to_string(), at: crate::platform::unix_now_f(), starts: 0, previous_installer: a.previous_installer.clone() });
let _ = std::fs::remove_file(result_path(a.app_dir));
let result = result_path(a.app_dir);
#[cfg(target_os = "macos")]
{
let bundle = crate::platform::bundle_path().ok_or(format!("not running from {}.app", a.app.name))?;
if a.staged_digest.is_empty() {
return Err("no digest for the staged app".into());
}
let script = a.app_dir.join("ota-apply.sh");
std::fs::write(&script, mac_helper(a.app)).map_err(|e| format!("cannot write the helper: {e}"))?;
let args = ["apply".to_string(), std::process::id().to_string(), a.host_pid.to_string(), bundle.display().to_string(), a.staged.display().to_string(), a.version.to_string(), result.display().to_string(), a.env_file.clone(), a.staged_digest.to_string()];
spawn_detached(Command::new(crate::platform::tool("nohup")).arg(crate::platform::tool("bash")).arg(&script).args(&args))?;
Ok(Launch::QuitNow)
}
#[cfg(windows)]
{
let install_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).ok_or("cannot find the install folder")?;
let script = a.app_dir.join("ota-apply.ps1");
std::fs::write(&script, win_helper(a.app)).map_err(|e| format!("cannot write the helper: {e}"))?;
let mut c = Command::new(crate::platform::tool("powershell"));
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File"]).arg(&script).args([
"-Mode", "apply", "-EnginePid", &std::process::id().to_string(), "-Installer", &a.staged.display().to_string(), "-Version", a.version, "-Result", &result.display().to_string(), "-InstallDir", &install_dir.display().to_string(), "-Sha256", a.sha256,
]);
spawn_detached(&mut c)?;
Ok(Launch::InstallerRunning)
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let _ = result;
Err("automatic apply is not supported on this platform".into())
}
}
/// The new version failed to start twice: the helper restores the previous one (macOS: the .previous bundle;
/// Windows: the previous installer kept in updates/). The caller exits afterwards.
pub fn launch_rollback(app: AppId, app_dir: &Path, p: &Pending, host_pid: u32, env_file: String) -> Result<(), String> {
let result = result_path(app_dir);
#[cfg(target_os = "macos")]
{
let bundle = crate::platform::bundle_path().ok_or(format!("not running from {}.app", app.name))?;
let script = app_dir.join("ota-apply.sh");
std::fs::write(&script, mac_helper(app)).map_err(|e| format!("cannot write the helper: {e}"))?;
let args = ["rollback".to_string(), std::process::id().to_string(), host_pid.to_string(), bundle.display().to_string(), String::new(), p.to.clone(), result.display().to_string(), env_file, String::new()];
spawn_detached(Command::new(crate::platform::tool("nohup")).arg(crate::platform::tool("bash")).arg(&script).args(&args))?;
Ok(())
}
#[cfg(windows)]
{
let _ = (host_pid, env_file);
if p.previous_installer.is_empty() || !Path::new(&p.previous_installer).is_file() {
return Err("no previous installer kept; reinstall from igneum.network".into());
}
let install_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).ok_or("cannot find the install folder")?;
let script = app_dir.join("ota-apply.ps1");
std::fs::write(&script, win_helper(app)).map_err(|e| format!("cannot write the helper: {e}"))?;
let sha = manifest::sha256_file(Path::new(&p.previous_installer)).unwrap_or_default();
let mut c = Command::new(crate::platform::tool("powershell"));
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File"]).arg(&script).args([
"-Sha256", &sha, "-Mode", "rollback", "-EnginePid", &std::process::id().to_string(), "-Installer", &p.previous_installer, "-Version", &p.to, "-Result", &result.display().to_string(), "-InstallDir", &install_dir.display().to_string(),
]);
spawn_detached(&mut c)?;
Ok(())
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let _ = (app, p, host_pid, env_file, result);
Err("rollback is not supported on this platform".into())
}
}
/// The macOS helper with this app's names filled in.
pub fn mac_helper(app: AppId) -> String {
fill(MAC_HELPER, app)
}
/// The Windows helper with this app's names filled in.
pub fn win_helper(app: AppId) -> String {
fill(WIN_HELPER, app)
}
fn fill(template: &str, app: AppId) -> String {
template.replace("@APP_NAME@", app.name).replace("@ENGINE@", app.engine_exe).replace("@BUNDLE@", app.bundle)
}
const MAC_HELPER: &str = r#"#!/bin/bash
# @APP_NAME@ update helper, written by the engine (igneum-common/src/ota.rs). Not for running by hand.
# bash ota-apply.sh apply|rollback <engine pid> <host pid|0> <app bundle> <staged bundle> <version> <result json> [env file] [digest]
# apply: waits for the engine (it exits right after starting this), asks the window to quit, moves the running
# bundle to "<app>.previous" and the staged one in, opens the new app; if the new app does not start twice, puts the
# previous one back. rollback: the previous bundle back, the failed one aside. Writes <result json> for the engine.
MODE="$1"; EPID="$2"; HPID="$3"; APP="$4"; NEW="$5"; VER="$6"; RESULT="$7"; ENVF="${8:-}"; DIGEST="${9:-}"
LOG="$(dirname "$RESULT")/ota-apply.log"
exec >>"$LOG" 2>&1
echo "$(date -u +%FT%TZ) $MODE: engine $EPID host $HPID app '$APP' new '$NEW' version $VER"
gone() { ! kill -0 "$1" 2>/dev/null; }
wait_gone() { local p="$1" n="$2"; while [ "$n" -gt 0 ] && ! gone "$p"; do sleep 0.5; n=$((n-1)); done; gone "$p"; }
result() { printf '{"ok":%s,"version":"%s","error":"%s","rolled_back":%s,"at":%s}\n' "$1" "$VER" "$2" "$3" "$(date +%s)" > "$RESULT.tmp" && mv "$RESULT.tmp" "$RESULT"; }
PREV="$APP.previous"
FAILED="$APP.failed"
ENGINE="$APP/Contents/MacOS/@ENGINE@"
# the same digest the engine computed when it staged the bundle (manifest.rs digest_dir): every regular file,
# byte-sorted relative path, "path\nsha256\n" per file, sha256 of the whole
digest_dir() { (cd "$1" && /usr/bin/find . -type f -print | LC_ALL=C /usr/bin/sort | while IFS= read -r f; do printf '%s\n%s\n' "${f#./}" "$(/usr/bin/shasum -a 256 "$f" | /usr/bin/cut -d' ' -f1)"; done) | /usr/bin/shasum -a 256 | /usr/bin/cut -d' ' -f1; }
started_ok() { local n=60; while [ "$n" -gt 0 ]; do pgrep -f "$ENGINE" >/dev/null 2>&1 && return 0; sleep 0.5; n=$((n-1)); done; return 1; }
# a test run carries its environment to the relaunch (open -n cannot); IGNEUM_OTA_RELAUNCH_ENGINE=1 in that file runs
# the engine alone (no window, a scratch test); a normal run goes through LaunchServices
launch() {
if [ -n "$ENVF" ] && [ -f "$ENVF" ]; then
(set -a; . "$ENVF"; set +a; if [ "${IGNEUM_OTA_RELAUNCH_ENGINE:-}" = 1 ]; then /usr/bin/nohup "$ENGINE" --no-open >/dev/null 2>&1 & else /usr/bin/nohup "$APP/Contents/MacOS/@APP_NAME@" >/dev/null 2>&1 & fi)
else
/usr/bin/open -n "$APP"
fi
}
wait_gone "$EPID" 240 || { echo "engine $EPID still running after 120 s; ending it"; kill -9 "$EPID" 2>/dev/null; sleep 1; }
if [ -n "$HPID" ] && [ "$HPID" != 0 ] && ! gone "$HPID"; then
/usr/bin/osascript -e 'tell application id "@BUNDLE@" to quit' >/dev/null 2>&1 || kill -TERM "$HPID" 2>/dev/null
wait_gone "$HPID" 80 || { echo "window $HPID still running after 40 s; ending it"; kill -9 "$HPID" 2>/dev/null; sleep 1; }
fi
# anything else from this bundle (a stray engine of an older run)
pkill -f "$APP/Contents/MacOS" 2>/dev/null; sleep 0.5
case "$MODE" in
apply)
[ -d "$NEW" ] || { result false "the staged app is missing" false; launch; exit 1; }
if [ -n "$DIGEST" ]; then
have="$(digest_dir "$NEW")"
if [ "$have" != "$DIGEST" ]; then echo "digest mismatch: staged $have, verified $DIGEST"; rm -rf "$NEW"; result false "the staged app changed since it was verified; not installed" false; launch; exit 1; fi
echo "staged bundle digest verified"
else
echo "no digest given; not installing an unverified bundle"; result false "no digest for the staged app" false; launch; exit 1
fi
rm -rf "$PREV"
mv "$APP" "$PREV" || { result false "could not move the old app aside" false; launch; exit 1; }
mv "$NEW" "$APP" || { mv "$PREV" "$APP"; result false "could not move the new app in" false; launch; exit 1; }
/usr/bin/xattr -dr com.apple.quarantine "$APP" 2>/dev/null # only a bundle whose digest just verified
echo "swapped; opening $APP"
launch || echo "open failed"
if started_ok; then result true "" false; echo "$VER is running"; exit 0; fi
echo "the new app did not start within 30 s; opening it once more"
launch || true
if started_ok; then result true "" false; echo "$VER is running (second try)"; exit 0; fi
echo "the new app did not start twice; restoring the previous version"
pkill -f "$APP/Contents/MacOS" 2>/dev/null; sleep 1
rm -rf "$FAILED"; mv "$APP" "$FAILED" && mv "$PREV" "$APP"
launch
result false "@APP_NAME@ $VER did not start twice; the previous version was restored" true
;;
rollback)
[ -d "$PREV" ] || { result false "no previous version kept to restore" false; launch; exit 1; }
rm -rf "$FAILED"; mv "$APP" "$FAILED" && mv "$PREV" "$APP"
launch
result false "@APP_NAME@ $VER did not stay up twice; the previous version was restored" true
;;
*) echo "unknown mode $MODE"; exit 2 ;;
esac
"#;
const WIN_HELPER: &str = r#"# @APP_NAME@ update helper, written by the engine (igneum-common/src/ota.rs). Not for running by hand.
# powershell -File ota-apply.ps1 -Mode apply|rollback -EnginePid <pid> -Installer <setup exe> -Version <v> -Result <json> -InstallDir <folder> -Sha256 <hex>
# The installer runs FIRST, while the engine keeps running (4 October 2026: two unattended PCs sat stopped at an
# administrator prompt nobody could click). A per-user installer (PrivilegesRequired=lowest) needs no prompt; an older
# administrator installer raises one through ShellExecute. Only when the installer actually runs does its
# PrepareToInstall step stop the engine (api/quit), replace the files and relaunch the app (/IGNOTA=1). A declined,
# timed-out or unanswered prompt leaves the engine running: the result says deferred:true. The old app is relaunched
# only when the engine is gone and the install did not happen.
param([string]$Mode, [int]$EnginePid, [string]$Installer, [string]$Version, [string]$Result, [string]$InstallDir, [string]$Sha256 = '')
$log = Join-Path (Split-Path -Parent $Result) 'ota-apply.log'
function Log([string]$t) { Add-Content -Path $log -Value ("{0} {1}" -f (Get-Date -Format s), $t) }
function Done([bool]$ok, [string]$err, [bool]$rb, [bool]$deferred) {
$o = @{ ok = $ok; version = $Version; error = $err; rolled_back = $rb; deferred = $deferred; at = [int][double](Get-Date -UFormat %s) }
($o | ConvertTo-Json -Compress) | Set-Content -Path $Result -Encoding ASCII
}
function EngineAlive() { return [bool](Get-Process -Id $EnginePid -ErrorAction SilentlyContinue) }
function Relaunch() {
if (EngineAlive) { return }
$exe = Join-Path $InstallDir '@ENGINE@.exe'
if (Test-Path $exe) { Log 'engine gone and nothing installed: starting the old app again'; Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $InstallDir | Out-Null }
}
Log "$Mode : engine $EnginePid installer '$Installer' version $Version (the engine keeps running until the installer runs)"
if (-not (Test-Path $Installer)) { Log 'installer missing'; Done $false 'the downloaded installer is missing' $false $false; exit 1 }
# the installer is hashed again right before it runs
if (-not $Sha256) { Log 'no sha256 given'; Done $false 'no sha256 for the installer; not run' $false $false; exit 1 }
$have = (Get-FileHash -Path $Installer -Algorithm SHA256).Hash.ToLower()
if ($have -ne $Sha256.ToLower()) { Log "sha256 mismatch: $have"; Remove-Item -Path $Installer -Force -ErrorAction SilentlyContinue; Done $false 'the installer changed since it was verified; not run' $false $false; exit 1 }
Log 'installer sha256 verified'
$setupLog = Join-Path (Split-Path -Parent $Result) 'ota-setup.log'
$setupArgs = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', '/IGNOTA=1', ('/LOG="' + $setupLog + '"'))
try {
# no -Verb RunAs: a per-user installer just runs; an administrator installer makes Windows ask, and a declined or
# timed-out prompt comes back here as an exception with the engine still running
$p = Start-Process -FilePath $Installer -ArgumentList $setupArgs -Wait -PassThru
if ($p.ExitCode -eq 0) {
if ($Mode -eq 'rollback') { Done $false "@APP_NAME@ $Version did not stay up twice; the previous version was reinstalled" $true $false }
else { Done $true '' $false $false }
Log 'installer exit 0'
exit 0
}
Log ("installer exit " + $p.ExitCode)
Done $false ("the installer exited with code " + $p.ExitCode + " (see ota-setup.log)") $false $false
Relaunch
exit 1
} catch {
$msg = $_.Exception.Message
Log ("installer did not run: " + $msg)
Log 'OTA: waiting for administrator approval; the engine keeps running; the update waits for the next time someone is at this PC'
Done $false ("waiting for administrator approval (" + $msg + ")") $false $true
Relaunch
exit 1
}
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn helpers_carry_the_apps_names_and_no_placeholder() {
for app in [crate::MINER, crate::WALLET] {
for text in [mac_helper(app), win_helper(app)] {
for ph in ["@APP_NAME@", "@ENGINE@", "@BUNDLE@"] {
assert!(!text.contains(ph), "{ph} was left in the helper for {}", app.name);
}
assert!(text.contains(app.name));
}
let m = mac_helper(app);
assert!(m.contains(&format!("ENGINE=\"$APP/Contents/MacOS/{}\"", app.engine_exe)));
assert!(m.contains(&format!("tell application id \"{}\" to quit", app.bundle)));
assert!(m.contains(&format!("\"$APP/Contents/MacOS/{}\"", app.name)));
assert!(win_helper(app).contains(&format!("'{}.exe'", app.engine_exe)));
}
assert!(mac_helper(crate::WALLET).contains("Igneum Wallet $VER did not start twice"));
assert!(!mac_helper(crate::WALLET).contains("Miner"));
}
#[test]
fn pending_and_result_round_trip() {
let dir = std::env::temp_dir().join(format!("igneum-ota-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let p = Pending { from: "0.1.0".into(), to: "0.1.1".into(), at: 1.5, starts: 2, previous_installer: String::new() };
write_pending(&dir, &p);
assert_eq!(read_pending(&dir), Some(p));
assert!(std::fs::read_to_string(pending_path(&dir)).unwrap().contains(&format!("\"platform\":\"{}\"", manifest::platform_name())));
assert_eq!(parse_pending("nope"), None);
let r = parse_result(r#"{"ok":false,"version":"0.1.1","error":"did not start","rolled_back":true,"at":1}"#).unwrap();
assert_eq!(r, HelperResult { ok: false, version: "0.1.1".into(), error: "did not start".into(), rolled_back: true, deferred: false });
assert!(parse_result(r#"{"ok":true,"version":"0.1.1","error":"","rolled_back":false,"deferred":true}"#).unwrap().deferred);
assert_eq!(read_result(&dir), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn names() {
assert_eq!(installer_name_for(crate::WALLET, "0.1.1"), "Igneum-Wallet-Setup-0.1.1.exe");
assert_eq!(installer_name_for(crate::MINER, "0.3.5"), "Igneum-Miner-Setup-0.3.5.exe");
assert_eq!(staged_path(crate::WALLET, Path::new("/Applications/Igneum Wallet.app")).unwrap(), PathBuf::from("/Applications/.Igneum Wallet.app.new"));
}
#[test]
fn env_file_takes_only_the_prefixes() {
let dir = std::env::temp_dir().join(format!("igneum-ota-env-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("IGNEUM_OTA_TEST_X", "1");
let p = write_env_file(&dir, &["IGNEUM_OTA_TEST_"]);
let text = std::fs::read_to_string(&p).unwrap();
assert!(text.contains("IGNEUM_OTA_TEST_X=1"));
assert!(!text.contains("PATH="));
assert_eq!(write_env_file(&dir, &["NO_SUCH_PREFIX_ZZ_"]), "");
std::env::remove_var("IGNEUM_OTA_TEST_X");
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -1,480 +0,0 @@
//! What differs per operating system: directories, file permissions, keeping the machine awake, opening a URL,
//! starting at login, and stopping a child process gracefully. From app/igneum-app/src/platform.rs; the login item
//! takes the app identity (`crate::AppId`) instead of naming Igneum Miner.
use crate::AppId;
use std::path::{Path, PathBuf};
use std::process::Command;
/// The absolute path of a system helper (R4.3.3: never a bare name on PATH). Windows: System32 (and NVIDIA's own
/// folder for nvidia-smi); macOS: /usr/bin, /usr/sbin, /bin. Unknown names fall back to the bare name.
pub fn tool(name: &str) -> PathBuf {
#[cfg(windows)]
{
let root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".into());
let sys = format!("{root}\\System32");
let p = match name {
"powershell" => format!("{sys}\\WindowsPowerShell\\v1.0\\powershell.exe"),
"cmd" | "curl" | "reg" | "icacls" | "taskkill" | "w32tm" | "net" | "tar" | "wsl" => format!("{sys}\\{name}.exe"),
"nvidia-smi" => {
let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".into());
let a = format!("{pf}\\NVIDIA Corporation\\NVSMI\\nvidia-smi.exe");
let b = format!("{sys}\\nvidia-smi.exe");
if Path::new(&a).is_file() { a } else { b }
}
_ => name.to_string(),
};
PathBuf::from(p)
}
#[cfg(target_os = "macos")]
{
let p = match name {
"curl" | "osascript" | "xattr" | "open" | "caffeinate" | "hdiutil" | "ditto" | "nohup" | "pgrep" | "pkill" | "shasum" | "xcrun" => format!("/usr/bin/{name}"),
"sntp" | "scutil" | "system_profiler" | "sysctl" => format!("/usr/sbin/{name}"),
"bash" | "sh" => format!("/bin/{name}"),
_ => name.to_string(),
};
PathBuf::from(p)
}
#[cfg(not(any(windows, target_os = "macos")))]
{
for dir in ["/usr/bin", "/bin", "/usr/sbin", "/usr/local/bin"] {
let p = Path::new(dir).join(name);
if p.is_file() {
return p;
}
}
PathBuf::from(name)
}
}
/// Strips the dashboard token from a line: "/t/<32 hex>/" becomes "/t/<token>/" (R4.3.8: the token is never logged
/// and the logs are uploaded).
pub fn redact(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find("/t/") {
out.push_str(&rest[..i + 3]);
let after = &rest[i + 3..];
let hex_len = after.chars().take_while(|c| c.is_ascii_hexdigit()).count();
if hex_len >= 16 {
out.push_str("<token>");
rest = &after[hex_len..];
} else {
rest = after;
}
}
out.push_str(rest);
out
}
/// True when a line still carries something that looks like the dashboard token (the upload guard).
pub fn carries_token(s: &str) -> bool {
let mut rest = s;
while let Some(i) = rest.find("/t/") {
let after = &rest[i + 3..];
if after.chars().take_while(|c| c.is_ascii_hexdigit()).count() >= 16 {
return true;
}
rest = after;
}
false
}
pub fn unix_now() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
pub fn unix_now_f() -> f64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0)
}
fn home() -> PathBuf {
#[cfg(windows)]
{
if let Some(p) = std::env::var_os("USERPROFILE") {
return PathBuf::from(p);
}
}
std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."))
}
/// The root both apps write under (the miner under `app/`, the wallet under `wallet/`, see `AppId::data_sub`).
/// macOS: ~/Library/Application Support/Igneum. Windows: %LOCALAPPDATA%\igneum (the same folder today's launchers
/// use, so an existing devnet-v4 database is reused).
pub fn data_root() -> PathBuf {
if let Some(p) = std::env::var_os("IGNEUM_APP_DATA") {
return PathBuf::from(p);
}
#[cfg(target_os = "macos")]
{
home().join("Library").join("Application Support").join("Igneum")
}
#[cfg(windows)]
{
std::env::var_os("LOCALAPPDATA").map(PathBuf::from).unwrap_or_else(|| home().join("AppData").join("Local")).join("igneum")
}
#[cfg(not(any(target_os = "macos", windows)))]
{
home().join(".igneum")
}
}
pub fn log_root() -> PathBuf {
if let Some(p) = std::env::var_os("IGNEUM_APP_LOGS") {
return PathBuf::from(p);
}
#[cfg(target_os = "macos")]
{
home().join("Library").join("Logs").join("Igneum")
}
#[cfg(not(target_os = "macos"))]
{
data_root().join("logs")
}
}
/// The machine's short name, cleaned to [A-Za-z0-9-], for the miner labels (mac-<host>, nvidia-<pc>).
pub fn host_label() -> String {
let raw = {
#[cfg(target_os = "macos")]
{
Command::new(tool("scutil")).args(["--get", "LocalHostName"]).output().ok().and_then(|o| String::from_utf8(o.stdout).ok())
}
#[cfg(windows)]
{
std::env::var("COMPUTERNAME").ok()
}
#[cfg(not(any(target_os = "macos", windows)))]
{
std::fs::read_to_string("/etc/hostname").ok()
}
};
let raw = raw.unwrap_or_default();
let cleaned: String = raw.trim().chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }).collect();
let cleaned = cleaned.trim_matches('-').to_string();
if cleaned.is_empty() {
if cfg!(windows) { "pc".into() } else { "mac".into() }
} else {
cleaned
}
}
/// Restricts a file (or directory) to the current user.
pub fn lock_permissions(path: &Path, dir: bool) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(if dir { 0o700 } else { 0o600 }));
}
#[cfg(windows)]
{
let _ = dir;
let user = std::env::var("USERNAME").unwrap_or_default();
if !user.is_empty() {
let _ = Command::new(tool("icacls"))
.arg(path)
.args(["/inheritance:r", "/grant:r", &format!("{user}:F")])
.output();
}
}
}
/// Opens a URL in the default browser (the fallback when no window host runs).
pub fn open_url(url: &str) {
#[cfg(target_os = "macos")]
let _ = Command::new(tool("open")).arg(url).spawn();
#[cfg(windows)]
let _ = quiet(&mut Command::new(tool("cmd"))).args(["/c", "start", "", url]).spawn();
#[cfg(not(any(target_os = "macos", windows)))]
let _ = Command::new("xdg-open").arg(url).spawn();
}
/// Keeps the machine awake while the engine runs. macOS: caffeinate tied to this process. Windows: the execution state,
/// which must be refreshed (call `keep_awake_tick` every minute).
pub struct KeepAwake {
#[cfg(target_os = "macos")]
child: Option<std::process::Child>,
}
impl KeepAwake {
pub fn start() -> KeepAwake {
#[cfg(target_os = "macos")]
{
let child = Command::new(tool("caffeinate")).args(["-dims", "-w", &std::process::id().to_string()]).spawn().ok();
KeepAwake { child }
}
#[cfg(not(target_os = "macos"))]
{
keep_awake_tick();
KeepAwake {}
}
}
pub fn stop(&mut self) {
#[cfg(target_os = "macos")]
if let Some(c) = self.child.as_mut() {
let _ = c.kill();
let _ = c.wait();
}
#[cfg(windows)]
unsafe {
SetThreadExecutionState(ES_CONTINUOUS);
}
}
}
#[cfg(windows)]
const ES_CONTINUOUS: u32 = 0x8000_0000;
#[cfg(windows)]
const ES_SYSTEM_REQUIRED: u32 = 0x0000_0001;
#[cfg(windows)]
#[link(name = "kernel32")]
extern "system" {
fn SetThreadExecutionState(flags: u32) -> u32;
}
pub fn keep_awake_tick() {
#[cfg(windows)]
unsafe {
SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED);
}
}
/// Asks a child to stop. Unix: SIGTERM (the node closes its database cleanly). Windows: TerminateProcess through
/// std (what today's launcher does with taskkill /F).
pub fn terminate(child: &mut std::process::Child) {
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGTERM);
}
#[cfg(not(unix))]
{
let _ = child.kill();
}
}
/// The app bundle on macOS (Igneum Miner.app) when the engine runs from inside one.
pub fn bundle_path() -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let macos = exe.parent()?;
let contents = macos.parent()?;
if macos.file_name()? == "MacOS" && contents.file_name()? == "Contents" {
contents.parent().map(|p| p.to_path_buf())
} else {
None
}
}
/// What a login item should run: the bundle (macOS) or the window host / the engine (Windows).
fn login_command(app: AppId) -> Vec<String> {
let _ = app; // macOS runs the bundle by path; Windows names the host executable
#[cfg(target_os = "macos")]
{
if let Some(b) = bundle_path() {
return vec!["/usr/bin/open".into(), "-a".into(), b.to_string_lossy().into_owned()];
}
}
let exe = std::env::current_exe().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
#[cfg(windows)]
{
if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) {
let host = dir.join(app.host_exe);
if host.exists() {
return vec![host.to_string_lossy().into_owned()];
}
}
}
vec![exe]
}
#[cfg(target_os = "macos")]
fn launch_agent_path(app: AppId) -> PathBuf {
home().join("Library").join("LaunchAgents").join(format!("{}.plist", app.bundle))
}
pub fn set_start_at_login(app: AppId, on: bool) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let path = launch_agent_path(app);
if on {
let args: String = login_command(app)
.iter()
.map(|a| format!(" <string>{}</string>\n", a.replace('&', "&amp;").replace('<', "&lt;")))
.collect();
let plist = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n <key>Label</key>\n <string>{}</string>\n <key>ProgramArguments</key>\n <array>\n{args} </array>\n <key>RunAtLoad</key>\n <true/>\n</dict>\n</plist>\n",
app.bundle
);
if let Some(d) = path.parent() {
std::fs::create_dir_all(d).map_err(|e| e.to_string())?;
}
std::fs::write(&path, plist).map_err(|e| e.to_string())?;
} else if path.exists() {
std::fs::remove_file(&path).map_err(|e| e.to_string())?;
}
Ok(())
}
#[cfg(windows)]
{
let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
let out = if on {
let cmd = login_command(app).iter().map(|a| format!("\"{a}\"")).collect::<Vec<_>>().join(" ");
Command::new(tool("reg")).args(["add", key, "/v", app.name, "/t", "REG_SZ", "/d", &cmd, "/f"]).output()
} else {
Command::new(tool("reg")).args(["delete", key, "/v", app.name, "/f"]).output()
};
match out {
Ok(o) if o.status.success() || !on => Ok(()),
Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
Err(e) => Err(e.to_string()),
}
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let _ = (app, on);
Err("start at login is not supported on this platform".into())
}
}
pub fn start_at_login_is_on(app: AppId) -> bool {
#[cfg(target_os = "macos")]
{
launch_agent_path(app).exists()
}
#[cfg(windows)]
{
Command::new(tool("reg"))
.args(["query", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", "/v", app.name])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let _ = app;
false
}
}
/// Removes the quarantine flag from the app's own files (macOS): after Gatekeeper lets the app through, each binary
/// inside would still be checked on its first exec. Best effort; only works on a writable volume.
pub fn clear_quarantine() {
#[cfg(target_os = "macos")]
if let Some(b) = bundle_path() {
let _ = Command::new(tool("xattr")).args(["-dr", "com.apple.quarantine"]).arg(b.join("Contents")).output();
}
}
/// The manual instruction for fixing the clock on this platform.
pub fn clock_hint() -> &'static str {
#[cfg(target_os = "macos")]
{
"System Settings > General > Date & Time: turn on \"Set time and date automatically\", or run: sudo sntp -sS time.apple.com"
}
#[cfg(windows)]
{
"Settings > Time & language > Date & time: turn on \"Set time automatically\" and click \"Sync now\", or run as administrator: w32tm /resync"
}
#[cfg(not(any(target_os = "macos", windows)))]
{
"run: sudo chronyc makestep, or sudo timedatectl set-ntp true"
}
}
/// Asks the operating system to set the clock from a time server (an administrator prompt appears). Returns what
/// happened, for the window. Blocking; call from a thread.
pub fn sync_clock() -> Result<String, String> {
#[cfg(target_os = "macos")]
{
let out = Command::new(tool("osascript"))
.args(["-e", "do shell script \"/usr/bin/sntp -sS time.apple.com 2>&1\" with administrator privileges"])
.output()
.map_err(|e| e.to_string())?;
let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
if out.status.success() {
Ok(if text.trim().is_empty() { "clock set from time.apple.com".into() } else { text.trim().to_string() })
} else if text.contains("canceled") || text.contains("cancelled") {
Err("the administrator prompt was cancelled".into())
} else {
Err(text.trim().to_string())
}
}
#[cfg(windows)]
{
let cmd = tool("cmd").display().to_string();
let script = format!("Start-Process -FilePath '{cmd}' -ArgumentList '/c net start w32time & w32tm /resync /force' -Verb RunAs -Wait -WindowStyle Hidden");
let mut c = Command::new(tool("powershell"));
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script]);
quiet(&mut c);
let out = c.output().map_err(|e| e.to_string())?;
if out.status.success() {
Ok("asked Windows Time to resync (w32tm /resync)".into())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
#[cfg(not(any(target_os = "macos", windows)))]
{
for args in [vec!["chronyc", "makestep"], vec!["timedatectl", "set-ntp", "true"]] {
if let Ok(out) = Command::new("pkexec").args(&args).output() {
if out.status.success() {
return Ok(format!("ran {}", args.join(" ")));
}
}
}
Err("neither chronyc nor timedatectl could set the clock".into())
}
}
/// Runs a command line with administrator rights (one prompt): the NVIDIA power cap needs it on Windows.
/// Blocking; call from a thread.
pub fn run_elevated(cmdline: &str) -> Result<(), String> {
#[cfg(windows)]
{
let escaped = cmdline.replace('\'', "''");
let cmd = tool("cmd").display().to_string();
let script = format!("$p = Start-Process -FilePath '{cmd}' -ArgumentList '/c {escaped}' -Verb RunAs -Wait -WindowStyle Hidden -PassThru; exit $p.ExitCode");
let mut c = Command::new(tool("powershell"));
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script]);
quiet(&mut c);
let out = c.output().map_err(|e| e.to_string())?;
if out.status.success() {
Ok(())
} else {
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
Err(if err.contains("canceled") || err.contains("cancelled") || err.is_empty() { "the administrator prompt was cancelled".into() } else { err })
}
}
#[cfg(target_os = "linux")]
{
let out = Command::new("pkexec").args(["sh", "-c", cmdline]).output().map_err(|e| e.to_string())?;
if out.status.success() { Ok(()) } else { Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) }
}
#[cfg(not(any(windows, target_os = "linux")))]
{
let _ = cmdline;
Err("not supported on this platform".into())
}
}
/// Builds a command that runs without a console window on Windows.
pub fn quiet(cmd: &mut Command) -> &mut Command {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
cmd
}
#[cfg(test)]
mod tests {
#[test]
fn token_redaction() {
let l = "dashboard at http://127.0.0.1:58776/t/a3a01c537130bceeaa1f6118ba48d63e/ (log x)";
assert_eq!(super::redact(l), "dashboard at http://127.0.0.1:58776/t/<token>/ (log x)");
assert!(super::carries_token(l));
assert!(!super::carries_token("GET /t/short/ nothing"));
assert_eq!(super::redact("no token here"), "no token here");
}
}

View file

@ -1,40 +0,0 @@
//! A command with a time limit (app/igneum-app/src/detect.rs `run_timeout`).
use std::io::Write;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// Runs a command with a time limit; returns stdout (and stderr appended) or None.
pub fn run_timeout(cmd: &mut Command, stdin_text: Option<&str>, limit: Duration) -> Option<String> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
cmd.stdin(if stdin_text.is_some() { Stdio::piped() } else { Stdio::null() });
crate::platform::quiet(cmd);
let mut child = cmd.spawn().ok()?;
if let (Some(text), Some(mut stdin)) = (stdin_text, child.stdin.take()) {
let _ = stdin.write_all(text.as_bytes());
drop(stdin);
}
let out = child.stdout.take()?;
let err = child.stderr.take()?;
let reader = std::thread::spawn(move || {
let mut s = String::new();
let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(out), &mut s);
let mut e = String::new();
let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(err), &mut e);
(s, e)
});
let deadline = Instant::now() + limit;
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)),
_ => {
let _ = child.kill();
let _ = child.wait();
break;
}
}
}
let (s, e) = reader.join().ok()?;
Some(if e.is_empty() { s } else { format!("{s}\n{e}") })
}

File diff suppressed because it is too large Load diff

View file

@ -1,41 +0,0 @@
[package]
name = "igneum-wallet"
version = "0.1.6"
edition = "2021"
description = "Igneum Wallet engine: keeps the key encrypted, signs in Rust, reads a node, verifies finality certificates itself, and serves the window on 127.0.0.1"
license = "MIT"
publish = false
[[bin]]
name = "igneum-wallet"
path = "src/main.rs"
[dependencies]
igneum-common = { path = "../igneum-common" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
k256 = { version = "0.13", features = ["ecdsa", "std"] }
sha3 = { version = "0.10", default-features = false }
sha2 = { version = "0.10", default-features = false }
getrandom = "0.2"
bip39 = { version = "2.1", features = ["rand"] }
bip32 = "0.5"
argon2 = "0.5"
chacha20poly1305 = "0.10"
zeroize = { version = "1", features = ["derive"] }
qrcodegen = "1.8"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
# the node's own code: its gRPC client, the RPC model and the finality certificate verification (vendor/igneum-node)
kaspa-grpc-client = { path = "../../vendor/igneum-node/rpc/grpc/client" }
kaspa-rpc-core = { path = "../../vendor/igneum-node/rpc/core" }
kaspa-consensus-core = { path = "../../vendor/igneum-node/consensus/core" }
kaspa-hashes = { path = "../../vendor/igneum-node/crypto/hashes" }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[profile.release]
opt-level = 2
lto = "thin"
codegen-units = 4
strip = true

View file

@ -1,227 +0,0 @@
# Igneum Wallet
Desktop wallet on the miner's bones: a Rust engine (`src/`) that keeps the key encrypted, signs, reads a node and
verifies finality certificates, plus a window served on 127.0.0.1 (`ui/`). macOS host: `app/mac/IgneumWallet.swift`;
packaging: `packaging/mac/build-wallet-dmg.sh`, `packaging/windows/Igneum-Wallet.iss`. Shared code with the miner:
`app/igneum-common`.
## Versions
| Version | Date | What |
|---|---|---|
| 0.1.0 | 4 Oct 2026 | first DMG; built before `/coin.png` existed, so the coin on the home screen is blank; updates download and offer "Open the download" only |
| 0.1.1 | 5 Oct 2026 | coin served from `brand/igneum-coin-1024.png`; over-the-air updates v2 (unattended install) |
| 0.1.2 | 5 Oct 2026 | Touch ID (macOS) and Windows Hello (Windows, untested): unlock, confirm sends, show the backup, idle lock; the coin and the "reading the chain" line on the balance card; the version in the header and in Settings |
| 0.1.3 | 5 Oct 2026 | the update card: one centred card over the window when a new version is ready, with what changed and one tap to install (`ui/update-card.js`) |
| 0.1.6 | 8 Oct 2026 | the page bridge (`src/bridge.rs`, `src/eip712.rs`, `site/wallet-provider.js`): websites connect through 127.0.0.1:26811 after your approval in the window; eth_requestAccounts, eth_chainId, eth_sendTransaction, personal_sign and typed data, each shown and confirmed here (Touch ID or Windows Hello when enrolled), the finality line after a transaction; Settings lists the connected sites; a page without approval gets nothing |
| 0.1.4 | 5 Oct 2026 | the lock screen (`ui/lock-screen.js`): the coin on the ember glow, the name, the short address, one control; the Touch ID sheet on a tap, once by itself when the wallet opens (setting), never on an idle lock; locking is a 250 ms transition; the idle lock's minutes in Settings (1, 5, 15, 60, never) |
## The lock screen (ui/lock-screen.js, index.html #screen-unlock, app.js onLockScreen; 0.1.4)
The coin large and centred on the obsidian ground with the ember glow (it breathes over 6 s; reduced motion stops
it), "Igneum Wallet" in Unbounded, the wallet's short address in mono, one primary control. With Touch ID (or Windows
Hello) on and the app window as the host: the fingerprint button "Unlock with Touch ID" in ember, its line under it,
and a quiet "Use password" that reveals the password field in place (the control area keeps one height, so nothing
above it moves). Otherwise the password field is the control and the button is absent. Locking (the Lock button,
the menu bar, the idle lock) is a 250 ms transition from the home screen to the lock screen, not a cut.
When the system sheet appears (`LockScreen.autoPrompt`, the rules in `ui/lock-screen.test.mjs`):
| Moment | The sheet |
|---|---|
| a tap on the button, or Return or Space on it (it has the focus on arrival, and again when the window comes back) | yes |
| the first arrival after the person opened the app, with "Ask for Touch ID when the wallet opens" on (Settings, default on) | once, 600 ms after the lock screen is fully drawn |
| the idle lock, the Lock button or menu item, the window coming back from the menu bar or the Dock | never |
| after a cancelled or unmatched sheet | never; the line says "Touch ID cancelled, tap to try again" |
| the first run after an over-the-air update (the updater opened the app, not the person) | never |
| the window not visible at arrival (opened at login behind another app) | never |
After the sheet the line under the button, in our words and never a modal: "Touch ID confirmed, unlocking", "Touch
ID cancelled, tap to try again", "Touch ID did not match, tap to try again", "Enter your password" (the sheet's
Use password button reveals the field), "Touch ID is locked, use your password". A sheet that cannot help (locked,
invalidated, not set up, a browser tab) reveals the password field and focuses it. Escape in the password field
returns to the button. `?bio=touch` on the page shows the Touch ID layout without a host (screenshots).
## Touch ID and Windows Hello (src/engine.rs, igneum-common/src/biometric.rs, app/mac/Biometric.swift, app/windows/biometric.h)
What it gates once "Use Touch ID" is on (Settings): unlocking at start and after the idle lock, every send, and
showing the words or the key. The password still works for all three. Nothing else asks for a finger.
| Piece | Where | What it does |
|---|---|---|
| the gate | `igneum-common/src/biometric.rs` | nonces the engine issues, the host confirms and the action consumes once; the one-time enrolment token; the state in `/api/state` |
| the engine | `src/engine.rs`, `src/server.rs` | `/api/biometric/*`; `/api/send` refuses without a confirmed nonce for that quote while enrolled; `/api/reveal` with a nonce reads the unlocked key in memory; the idle lock; the `HOST {...}` line on stdout |
| the Mac host | `app/mac/Biometric.swift` | the `biometric` script message handler; `LAPolicy.deviceOwnerAuthenticationWithBiometrics`; the Secure Enclave seal |
| the Windows host | `app/windows/biometric.h` | the `window.chrome.webview` bridge; `UserConsentVerifier` through `IUserConsentVerifierInterop`; DPAPI |
| the page | `ui/app.js` | the fingerprint controls on the unlock, send and Settings screens; the activity ping for the idle lock |
The prompt's lines, worded by the engine or the host, never by the page, in our voice, under 60 characters, no
trailing full stop:
| Prompt | Line |
|---|---|
| unlock | Unlock your wallet |
| send | Send 1.5 IGN to 0x7E5F…5Bdf (the amount to 4 decimals; the address as its first 6 and last 4 characters) |
| backup and export | Show your recovery words |
| turn on | Turn on Touch ID for your wallet |
The prompt's buttons: "Use password" (the fallback button; the policy is biometrics only, so it never reaches the
device password: the window asks for the wallet's own password) and "Cancel". After the system prompt the page shows
its own line with the fingerprint glyph in ember: "Touch ID confirmed, unlocking", "Touch ID cancelled, enter your
password", "Touch ID did not match, try again or enter your password", and so on (`bioLine` in `ui/app.js`). The
prompt's title and icon are the bundled app's ("Igneum Wallet", the mark): the window host is the bundle's own
executable, so the system attributes the prompt to it; a bare engine or a test binary shows its own name instead.
macOS composes the sentence itself ("Igneum Wallet is trying to unlock your wallet."), so the Mac host lowercases
the line's first letter at display time; Windows Hello shows the line on its own, with its capital.
The flow for a send: the quote (`/api/send/quote`) comes with `confirm_nonce` and `confirm_reason`. The page hands the nonce to the host; the host
reads the reason from the engine with its host token, shows the prompt with that line, and on success posts
`/api/biometric/confirm`. The page then calls `/api/send` with the quote and the nonce; the engine checks the nonce
was confirmed within 30 s, for this exact quote (address, value, transaction nonce, chain id), and not used before.
Unlock: the host shows the prompt, unseals the password and posts it to `/api/unlock` itself. The backup: a
`reveal` challenge, the prompt, then `/api/reveal` with the nonce; the words come from the key already unlocked in
memory, so the password is neither typed nor stored for it.
The host token: the engine prints `HOST {"token": ..., "biometric_file": ...}` on its stdout, which only the window
host reads. The host sends it as `X-Igneum-Host` on the calls only it may make (status, report, confirm, taking the
parked password at enrolment, recording the enrolment). The page cannot confirm its own challenges.
Enrolment: the page posts the password to `/api/biometric/enrol/begin`; the engine checks it against the vault and
parks it under a one-time token for 120 s; the host shows the prompt ("Turn on Touch ID for Igneum Wallet"), takes
the password with the token (once), seals it and writes the file, then posts `/api/biometric/enrolled`. A password
change deletes the sealed file and Settings asks to turn Touch ID on again. Removing the wallet deletes it too.
The idle lock: with Touch ID on, Settings > "Lock when idle" chooses 1, 5 (the default), 15 or 60 minutes, or never
(`settings.json: idle_lock_min`, 0 for never; `idle_lock` mirrors on/off for 0.1.2 and 0.1.3). The engine zeroes
the key after that long without the window reporting input (mouse, keys; `/api/activity` every 20 s while there is
some), never during a send or with a confirm screen open. The next unlock is a tap on the button, or the password;
the sheet is never raised by itself after an idle lock.
### What is stored, and where (macOS)
The packaging signs the app ad hoc. Under an ad hoc signature macOS refuses every Keychain item that carries a
biometric access control, on the login keychain and the data-protection keychain alike (`errSecMissingEntitlement`,
-34018: `keychain-access-groups` needs a team signature; measured 5 October 2026 on this Mac with a 40-line probe).
A Secure Enclave key with the same control is allowed, so the password is sealed to one instead:
- enrol: a P-256 key is made in the Secure Enclave with `SecAccessControl(.privateKeyUsage, .biometryCurrentSet)`;
an ephemeral P-256 key agrees a shared secret with its public half (no prompt), HKDF-SHA256 derives an AES-GCM key
and the password is sealed. `~/Library/Application Support/Igneum/wallet/biometric.json` (0600) holds the Secure
Enclave key's wrapped form, the ephemeral public key and the box.
- unlock or confirm: the host evaluates `LAPolicy.deviceOwnerAuthenticationWithBiometrics` (fallback button "Use
password", which only closes the prompt with `LAError.userFallback`; the device password is never offered), then
uses the Secure Enclave key under that context. The key agreement runs on every confirm
too, so a changed fingerprint set is caught on a send, not only on an unlock.
- `.biometryCurrentSet`: a fingerprint added or removed in System Settings makes the Secure Enclave refuse the key.
The host reports "invalidated"; the window says to use the password and turn Touch ID on again.
Windows: the password is sealed with DPAPI (`CryptProtectData`, current user, `CRYPTPROTECT_UI_FORBIDDEN`) only after
a `UserConsentVerifier` success and written to `%LOCALAPPDATA%\igneum\wallet\biometric.json`. DPAPI has no
biometric binding of its own: the host unseals only after Hello verified.
### Threat model
| | Touch ID protects | Touch ID does not protect |
|---|---|---|
| Casual access at an unlocked Mac (someone at the keyboard while the wallet is locked) | yes: no finger, no unlock, no send, no words; the idle lock closes the window within 5 minutes of nobody being there | |
| A copy of `vault.json` taken off the machine | yes, as before: Argon2id + XChaCha20-Poly1305 under the password; the sealed file is useless off this Mac's Secure Enclave | |
| A copy of `biometric.json` by another user on this Mac | yes: 0600, and the Secure Enclave key is bound to this device and the current fingerprint set | |
| A root attacker, or malware running as the signed-in user | | no: it can read the wallet's memory while unlocked, patch the app, or drive the window; the sealed file is as strong as the user's macOS login and Secure Enclave, not stronger |
| Someone who knows the password | | no: the password works everywhere Touch ID does, by design |
| A fingerprint added to this Mac by someone with the macOS password | | the Secure Enclave key dies (`.biometryCurrentSet`), so the new finger cannot unlock; the person with the macOS password could still enrol again, which needs the wallet password |
| The page (ui/) or a cross-site page in the browser | yes: the host token is never in the page; confirmations are host-only; `/api/send` binds the nonce to the quote; the same-origin guard stays | |
Windows (untested): DPAPI protects against other accounts and offline copies, not against code running as the user;
the same table applies with "Windows Hello" and "the Windows account".
### Verified (5 October 2026)
- Unit tests: `cargo test biometric` in `app/igneum-common` (confirm/take once, replay, expiry, stale, mismatch,
the enrolment token, reason clipping, the constant-time compare, the binding).
- The Swift host compiles with `Biometric.swift` and links LocalAuthentication; the DMG builds.
- The probe: `SecItemAdd` with `.biometryCurrentSet` fails with -34018 under the ad hoc signature; a non-permanent
Secure Enclave key with the same control is created, exported (`dataRepresentation`, 569 bytes), re-imported, and
the ephemeral key agreement seals a box without a prompt.
### Untested
- The Windows path: `biometric.h` was written on a Mac; the first compile is BUILD-WALLET-APP.bat on the runner.
- See the report for whether the Touch ID prompt was exercised end to end on this Mac (a finger is needed).
## Over-the-air updates (src/updater.rs, igneum-common/src/{fetch,ota}.rs)
The signed manifest `igneum-wallet-latest.json` (+ `.sig`, the miner's Ed25519 key) is published with
`packaging/ota/publish-manifest.sh --product wallet --version <v> --mac packaging/mac/dist/Igneum-Wallet-<v>.dmg --notes "..." --deploy`.
The engine checks it 25 s after start, then hourly (10 minutes after an error), and from Settings > Check for updates.
States (`state.update.status`, what the banner says):
| State | Meaning |
|---|---|
| off | the build has no manifest URL |
| unknown | not checked yet |
| checking | fetching and verifying the manifest |
| current | this is the latest version |
| available | a newer version has a build for this platform; the download starts at once |
| downloading | curl with resume into `<wallet data>/updates/`; size and sha256 checked against the manifest |
| staging | macOS: the DMG mounted, the bundle copied next to the running one as `.Igneum Wallet.app.new`, its engine asked `--version`, the bundle digested; Windows: the installer is the staged artefact |
| ready | waits for the safe moment (below); "Install now" applies at once; "Later" hides the banner for that version only |
| applying | the download and the staged bundle re-verified, `update-pending.json` written, the helper started; macOS: the engine quits and the helper swaps the bundle and opens the new app |
| deferred | Windows only: the installer's administrator prompt was not answered; retried in 6 hours or on Install now |
| manual | the engine cannot swap itself (not in a bundle, Applications not writable): "Open the download" |
| error | what failed, in `state.update.error`; a version whose apply failed is never re-applied by itself |
The window shows an update twice over. The card (`ui/update-card.js`, `renderUpdateCard` in `ui/app.js`) is the
first sight: one centred card with the mark and a progress ring, "Igneum Wallet 0.1.3", one line, up to three lines of
the manifest's notes (the rest behind "What changed"), the size, Install now and Later. It never opens while the send
screen is open, while a Touch ID or Windows Hello line is on screen, or while a wallet is being created or imported;
it waits and comes once that is over. Later (also Escape and the backdrop) hides that version at that stage and
leaves the banner; the card comes back for a newer version, or when the download is ready and automatic updates are
off. An open card follows its update through downloading, ready, installing ("Installing. The app restarts itself.")
and failed (the one-line cause, Try again). The banner is the small strip that stays. `?update=<state>[&auto=0][&card=1]`
on the page shows each state without a manifest; `ui/update-card.test.mjs` checks the words and the rules.
The setting "Install updates by itself when nothing is being sent" (`settings.json: auto_update`) defaults to on.
The safe moment is no send in flight: no `/api/send` running, no quote given in the last 180 s (a confirm screen may
be open), no sent transaction still waiting for its block, no create flow half way. A version below the manifest's
`min_supported_version` installs at once.
Rollback: the macOS helper puts `Igneum Wallet.app.previous` back when the new app does not start twice. The new
engine counts its starts in `update-pending.json`; on the third start without 90 healthy seconds it restores the
previous version (never below `min_supported_version`). The window shows "Updated from X" on the first run after an
update and "Rolled back: ..." after a restore.
Files in `~/Library/Application Support/Igneum/wallet/` (Windows: `%LOCALAPPDATA%\igneum\wallet\`): `updates/`
(manifest, download), `update-pending.json`, `update-result.json`, `ota-apply.sh` or `ota-apply.ps1`,
`ota-apply.log`, `failed-versions.json`, `ota-relaunch.env` (test runs only).
### Verified (5 October 2026)
- Unit tests: manifest parse, version comparison, plan, safe moment (`cargo test` in `app/igneum-wallet`); helper
templates, pending/result files, env file, digest recipe (`cargo test` in `app/igneum-common`).
- End to end on this Mac with a scratch copy of the 0.1.1 bundle against a 0.1.2 test manifest served from
127.0.0.1 (`publish-manifest.sh --dest <folder> --base-url http://127.0.0.1:<port>`; the engine run with
`IGNEUM_APP_DATA`, `IGNEUM_WALLET_UPDATE_MANIFEST`, `IGNEUM_WALLET_UPDATE_FIRST_SECS=3`, `IGNEUM_OTA_RELAUNCH_ENGINE=1`
so the helper relaunches the engine alone): check, download, stage, apply, swap, relaunch as 0.1.2,
"updated to Igneum Wallet 0.1.2 from 0.1.1".
- For real on the founder's Mac, through LaunchServices with the real window host: 0.1.1 -> 0.1.2 (5 Oct 2026, 09:14Z)
and 0.1.2 -> 0.1.3 (5 Oct 2026: check posted 13:23:18Z, staged 13:23:31Z, applied 13:23:40Z, 0.1.3 up 13:23:46Z,
28 s end to end; `/api/state` then showed `updated_from` 0.1.2 and `current`).
- 0.1.3 -> 0.1.4 (5 Oct 2026): check posted 15:48:50Z, downloaded and verified 15:48:52Z (`staging` in `/api/state`),
staged bundle digested 15:49:15Z, helper started 15:49:16Z (the 0.1.3 engine gone), 0.1.4 up 15:49:19Z with
`updated_from` 0.1.3 and `unknown`, `current` at 15:49:45Z after its own check. 0.1.4 raised no Touch ID sheet on
that first run (the updater's relaunch, not the person's: `last_op` stayed empty).
- The download token rotated on 5 Oct 2026 (docs/plans/rotation-phase-2.md): `publish-manifest.sh` writes the NEW
folder (it reads `~/.config/igneum/dl-token`), and a 0.1.4 bundle points there. The installed 0.1.3 polls the OLD
folder's `igneum-wallet-latest.json`, which phase 2 had removed (the plan's section 8b kept only the miner's bridge),
so the 0.1.4 manifest, its signature and the DMG were copied into the OLD folder too, as a bridge, until the 0.1.3
wallets have moved; the old folder goes on 7 October (section 8f).
### Untested
- The Windows path (installer first, `/IGNOTA=1`, deferral on an unanswered prompt): copied from the miner's, never
run for the wallet. Needs the wallet installer on the GitHub runner and a PC.
- The rollback paths (the helper's "did not start twice", the engine's third-start restore) and `deferred`.
- A version below `min_supported_version` (no wallet manifest has set one).
- Code signatures: bundles are signed ad hoc by the packaging script; the updater verifies the manifest's sha256 and
the staged bundle's digest, not a Developer ID signature (the miner does the same).

View file

@ -1,491 +0,0 @@
//! The page bridge (0.1.6, 8 October 2026, main's order: igneum.network/swap connects any injected wallet, the Igneum
//! Wallet signed only inside its own window). An EIP-1193 provider for pages: the engine listens on a FIXED loopback port
//! (BRIDGE_PORT, igneum_common::http::serve_on) beside its token-guarded window server; a page's shim
//! (site/wallet-provider.js) POSTs JSON-RPC to /bridge/rpc with an Origin the browser sets and the X-Igneum-Bridge
//! header (so a preflight runs first). Nothing leaves without the person's word in the wallet's own window:
//!
//! - a site is approved once (eth_requestAccounts raises a Connect request; Approve in the window stores the origin
//! in the settings; Decline or 5 minutes of silence ends it with 4001); every other method from an origin that is
//! not approved answers 4100 and creates nothing (the known-failed test: a page without approval gets nothing);
//! - eth_sendTransaction, personal_sign and eth_signTypedData(_v4) each raise a request the window shows with the
//! origin and the facts (to, value, the call's bytes, the fee; the message; the domain and the primary type);
//! Confirm signs (through Touch ID or Windows Hello when enrolled, the same gate as a send) and the page's poll
//! reads the hash or the signature; after a transaction the window keeps the finality certificate's line;
//! - reads (eth_call, eth_estimateGas, receipts, balances, blocks, logs) go to the wallet's node for an approved
//! origin, the chain id and net version with them; wallet_switchEthereumChain accepts the wallet's own chain
//! and refuses any other with 4902.
//!
//! This module holds the pure part (what a request means, what a decision does, what the page may read); the engine
//! wires it to the vault, the node and the biometric gate (engine.rs, the "page bridge" section) and the server routes
//! it (server.rs). Tests here run without a vault.
use serde_json::{json, Value};
use std::collections::HashMap;
use std::time::{Duration, Instant};
/// The bridge's port: fixed, so a page can find the wallet (IGNEUM_WALLET_BRIDGE_PORT overrides it for tests).
pub const BRIDGE_PORT: u16 = 26811;
/// A request the window has not answered expires after this.
pub const PENDING_TTL: Duration = Duration::from_secs(300);
/// A result the page has not read is kept this long after the decision.
pub const RESULT_TTL: Duration = Duration::from_secs(120);
pub const MAX_PENDING: usize = 8;
// EIP-1193 provider error codes
pub const E_REJECTED: i64 = 4001;
pub const E_UNAUTHORIZED: i64 = 4100;
pub const E_UNSUPPORTED: i64 = 4200;
pub const E_DISCONNECTED: i64 = 4900;
pub const E_CHAIN: i64 = 4902;
pub const E_PARAMS: i64 = -32602;
pub const E_INTERNAL: i64 = -32603;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Connect,
Send,
Sign,
Typed,
}
impl Kind {
pub fn name(&self) -> &'static str {
match self {
Kind::Connect => "connect",
Kind::Send => "send",
Kind::Sign => "sign",
Kind::Typed => "typed",
}
}
}
/// One request waiting for the window, or decided and waiting for the page to read it.
#[derive(Clone, Debug)]
pub struct Pending {
pub id: String,
pub origin: String,
pub kind: Kind,
pub created: Instant,
pub created_unix: f64,
/// what the window shows (never the key, never the page's whole payload)
pub detail: Value,
/// what the engine acts on at Confirm (the transfer's fields, the digest to sign)
pub request: Value,
/// the biometric challenge for this request ("" when nothing is enrolled)
pub confirm_nonce: String,
pub confirm_reason: String,
pub done: Option<Result<Value, (i64, String)>>,
pub decided: Option<Instant>,
}
/// What the engine knows at the moment of a request.
pub struct Ask<'a> {
pub address: &'a str,
pub unlocked: bool,
pub chain_id: u64,
pub approved: bool,
pub listening: bool,
}
/// What a request means.
#[derive(Debug, PartialEq)]
pub enum Reply {
Result(Value),
Error(i64, String),
/// the page polls igneum_poll with this id
Pending(String),
/// a read the engine forwards to its node as it is
Proxy,
}
const PROXIED: &[&str] = &[
"eth_call", "eth_estimateGas", "eth_blockNumber", "eth_getBalance", "eth_getTransactionReceipt", "eth_getTransactionByHash",
"eth_gasPrice", "eth_maxPriorityFeePerGas", "eth_feeHistory", "eth_getCode", "eth_getLogs", "eth_getBlockByNumber", "eth_getBlockByHash",
"eth_getTransactionCount", "eth_getStorageAt", "igneum_getTransactionStatus",
];
pub struct Bridge {
pub pending: Vec<Pending>,
/// the last call from each origin (the Settings card's "last seen")
pub last_seen: HashMap<String, f64>,
seq: u64,
}
fn hex_quantity(v: &Value) -> Option<u128> {
match v {
Value::String(s) => {
let h = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))?;
if h.is_empty() {
return Some(0);
}
u128::from_str_radix(h, 16).ok()
}
Value::Number(n) => n.as_u64().map(|x| x as u128),
_ => None,
}
}
pub fn hex_bytes(v: &Value) -> Option<Vec<u8>> {
let s = v.as_str()?;
let h = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))?;
if h.len() % 2 != 0 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
(0..h.len() / 2).map(|i| u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).ok()).collect()
}
fn is_address(s: &str) -> bool {
s.len() == 42 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit())
}
/// A personal_sign message: hex bytes when it looks like hex, else the text itself.
pub fn message_bytes(v: &Value) -> Option<Vec<u8>> {
let s = v.as_str()?;
if s.starts_with("0x") && s.len() % 2 == 0 && s[2..].chars().all(|c| c.is_ascii_hexdigit()) {
hex_bytes(v)
} else {
Some(s.as_bytes().to_vec())
}
}
impl Default for Bridge {
fn default() -> Self {
Self::new()
}
}
impl Bridge {
pub fn new() -> Bridge {
Bridge { pending: Vec::new(), last_seen: HashMap::new(), seq: 0 }
}
fn new_id(&mut self) -> String {
self.seq += 1;
let mut raw = [0u8; 8];
getrandom::getrandom(&mut raw).expect("os randomness");
format!("{}-{}", self.seq, igneum_common::keys::hex(&raw))
}
/// Requests nobody answered in PENDING_TTL end with 4001; results nobody read in RESULT_TTL are dropped. Returns
/// the expired ones (the engine logs them).
pub fn expire(&mut self, now: Instant) -> Vec<Pending> {
let mut gone = Vec::new();
for p in self.pending.iter_mut() {
if p.done.is_none() && now.duration_since(p.created) > PENDING_TTL {
p.done = Some(Err((E_REJECTED, "the request expired: nobody answered it in the Igneum Wallet window".into())));
p.decided = Some(now);
gone.push(p.clone());
}
}
self.pending.retain(|p| !(p.done.is_some() && p.decided.map(|d| now.duration_since(d) > RESULT_TTL).unwrap_or(false)));
gone
}
pub fn open(&self) -> Vec<&Pending> {
self.pending.iter().filter(|p| p.done.is_none()).collect()
}
/// What the page's call means. `approved` is the engine's reading of its settings for this origin.
pub fn request(&mut self, origin: &str, method: &str, params: &Value, a: &Ask, now: Instant, now_unix: f64) -> Reply {
if origin.is_empty() {
return Reply::Error(E_UNAUTHORIZED, "a page bridge call carries its origin".into());
}
self.last_seen.insert(origin.to_string(), now_unix);
let list = params.as_array().cloned().unwrap_or_default();
let chain_hex = format!("0x{:x}", a.chain_id);
match method {
"igneum_poll" => {
let id = list.first().and_then(|v| v.as_str()).unwrap_or("");
return self.poll(origin, id);
}
"eth_requestAccounts" => {
if a.approved {
return if a.unlocked {
Reply::Result(json!([a.address]))
} else {
Reply::Error(E_UNAUTHORIZED, "the Igneum Wallet is locked: unlock it in its window, then try again".into())
};
}
if let Some(p) = self.pending.iter().find(|p| p.done.is_none() && p.kind == Kind::Connect && p.origin == origin) {
return Reply::Pending(p.id.clone());
}
if self.open().len() >= MAX_PENDING {
return Reply::Error(E_INTERNAL, "too many requests are waiting in the wallet window".into());
}
let id = self.new_id();
self.pending.push(Pending { id: id.clone(), origin: origin.into(), kind: Kind::Connect, created: now, created_unix: now_unix, detail: json!({ "origin": origin }), request: Value::Null, confirm_nonce: String::new(), confirm_reason: String::new(), done: None, decided: None });
return Reply::Pending(id);
}
"eth_accounts" => {
return Reply::Result(if a.approved && a.unlocked { json!([a.address]) } else { json!([]) });
}
"igneum_disconnect" => return Reply::Result(Value::Null),
_ => {}
}
if !a.approved {
return Reply::Error(E_UNAUTHORIZED, "this site is not connected to the Igneum Wallet: call eth_requestAccounts first".into());
}
match method {
"eth_chainId" => Reply::Result(json!(chain_hex)),
"net_version" => Reply::Result(json!(a.chain_id.to_string())),
"wallet_switchEthereumChain" | "wallet_addEthereumChain" => {
let want = list.first().and_then(|v| v.get("chainId")).and_then(hex_quantity);
match want {
Some(c) if c == a.chain_id as u128 => Reply::Result(Value::Null),
Some(_) => Reply::Error(E_CHAIN, format!("the Igneum Wallet runs one chain, id {} ({chain_hex})", a.chain_id)),
None => Reply::Error(E_PARAMS, "chainId missing".into()),
}
}
"wallet_requestPermissions" | "wallet_getPermissions" => Reply::Result(json!([{ "parentCapability": "eth_accounts", "caveats": [{ "type": "restrictReturnedAccounts", "value": [a.address] }] }])),
"eth_sendTransaction" | "personal_sign" | "eth_sign" | "eth_signTypedData" | "eth_signTypedData_v3" | "eth_signTypedData_v4" => {
if !a.unlocked {
return Reply::Error(E_UNAUTHORIZED, "the Igneum Wallet is locked: unlock it in its window, then try again".into());
}
if self.open().len() >= MAX_PENDING {
return Reply::Error(E_INTERNAL, "too many requests are waiting in the wallet window".into());
}
let (kind, request, detail) = match method {
"eth_sendTransaction" => {
let tx = list.first().cloned().unwrap_or(Value::Null);
let from = tx.get("from").and_then(|v| v.as_str()).unwrap_or("").to_ascii_lowercase();
if !from.is_empty() && from != a.address.to_ascii_lowercase() {
return Reply::Error(E_UNAUTHORIZED, "from is not this wallet's address".into());
}
let to = tx.get("to").and_then(|v| v.as_str()).unwrap_or("").to_ascii_lowercase();
if !is_address(&to) {
return Reply::Error(E_PARAMS, "to must be an address (contract creation is not offered)".into());
}
let value = tx.get("value").map(|v| hex_quantity(v).ok_or(())).unwrap_or(Ok(0));
let Ok(value) = value else { return Reply::Error(E_PARAMS, "value must be a hex quantity".into()) };
let data = match tx.get("data").or_else(|| tx.get("input")) {
None | Some(Value::Null) => Vec::new(),
Some(d) => match hex_bytes(d) {
Some(b) => b,
None => return Reply::Error(E_PARAMS, "data must be 0x hex".into()),
},
};
if data.len() > 128 * 1024 {
return Reply::Error(E_PARAMS, "data over 128 KiB".into());
}
let gas = tx.get("gas").or_else(|| tx.get("gasLimit")).and_then(hex_quantity).map(|g| g as u64);
let selector = if data.len() >= 4 { format!("0x{}", igneum_common::keys::hex(&data[..4])) } else { String::new() };
(Kind::Send, json!({ "to": to, "value": value.to_string(), "data": format!("0x{}", igneum_common::keys::hex(&data)), "gas": gas }),
json!({ "origin": origin, "to": to, "to_display": igneum_common::keys::checksum(&to), "value": value.to_string(), "data_len": data.len(), "selector": selector, "gas": gas }))
}
"personal_sign" | "eth_sign" => {
// personal_sign is [message, address]; eth_sign is [address, message]: take whichever is not the address
let (m, addr) = if method == "personal_sign" { (list.first(), list.get(1)) } else { (list.get(1), list.first()) };
if let Some(ad) = addr.and_then(|v| v.as_str()) {
if !ad.eq_ignore_ascii_case(a.address) {
return Reply::Error(E_UNAUTHORIZED, "the address is not this wallet's".into());
}
}
let Some(bytes) = m.and_then(message_bytes) else { return Reply::Error(E_PARAMS, "message missing".into()) };
if bytes.len() > 64 * 1024 {
return Reply::Error(E_PARAMS, "message over 64 KiB".into());
}
let text = String::from_utf8(bytes.clone()).ok().filter(|t| !t.chars().any(|c| c.is_control() && c != '\n' && c != '\t'));
let digest = crate::tx::personal_digest(&bytes);
(Kind::Sign, json!({ "digest": format!("0x{}", igneum_common::keys::hex(&digest)) }),
json!({ "origin": origin, "text": text, "bytes": bytes.len(), "hex": if text.is_none() { format!("0x{}", igneum_common::keys::hex(&bytes[..bytes.len().min(64)])) } else { String::new() } }))
}
_ => {
// eth_signTypedData(_v3, _v4): [address, typed data as an object or a JSON string]
if let Some(ad) = list.first().and_then(|v| v.as_str()) {
if !ad.eq_ignore_ascii_case(a.address) {
return Reply::Error(E_UNAUTHORIZED, "the address is not this wallet's".into());
}
}
let typed = match list.get(1) {
Some(Value::String(s)) => match serde_json::from_str::<Value>(s) {
Ok(v) => v,
Err(e) => return Reply::Error(E_PARAMS, format!("typed data is not JSON: {e}")),
},
Some(v) if v.is_object() => v.clone(),
_ => return Reply::Error(E_PARAMS, "typed data missing".into()),
};
let t = match crate::eip712::hash(&typed) {
Ok(t) => t,
Err(e) => return Reply::Error(E_PARAMS, format!("typed data: {e}")),
};
if let Some(c) = t.chain_id {
if c != a.chain_id as u128 {
return Reply::Error(E_CHAIN, format!("the typed data names chain {c}; this wallet is on {}", a.chain_id));
}
}
let message = typed.get("message").cloned().unwrap_or(Value::Null);
let shown = serde_json::to_string_pretty(&message).unwrap_or_default();
(Kind::Typed, json!({ "digest": format!("0x{}", igneum_common::keys::hex(&t.digest)) }),
json!({ "origin": origin, "domain": t.domain_name, "primary_type": t.primary_type, "message": if shown.len() > 4000 { format!("{}\n…", &shown[..4000]) } else { shown } }))
}
};
let id = self.new_id();
self.pending.push(Pending { id: id.clone(), origin: origin.into(), kind, created: now, created_unix: now_unix, detail, request, confirm_nonce: String::new(), confirm_reason: String::new(), done: None, decided: None });
Reply::Pending(id)
}
m if PROXIED.contains(&m) => Reply::Proxy,
_ => Reply::Error(E_UNSUPPORTED, format!("{method} is not offered by the Igneum Wallet")),
}
}
/// The page reads a request's outcome: pending, the result, or the error. Only the origin that made it may read it.
pub fn poll(&mut self, origin: &str, id: &str) -> Reply {
let Some(p) = self.pending.iter().find(|p| p.id == id) else { return Reply::Error(E_INTERNAL, "unknown request".into()) };
if p.origin != origin {
return Reply::Error(E_UNAUTHORIZED, "not your request".into());
}
match &p.done {
None => Reply::Pending(id.into()),
Some(Ok(v)) => {
let v = v.clone();
self.pending.retain(|q| q.id != id);
Reply::Result(v)
}
Some(Err((c, m))) => {
let (c, m) = (*c, m.clone());
self.pending.retain(|q| q.id != id);
Reply::Error(c, m)
}
}
}
/// The window's word. Decline ends the request with 4001; Approve hands the request back to the engine, which
/// signs or connects and then `resolve`s it.
pub fn decide(&mut self, id: &str, ok: bool, now: Instant) -> Result<Pending, String> {
let p = self.pending.iter_mut().find(|p| p.id == id && p.done.is_none()).ok_or("no such request is waiting")?;
if !ok {
p.done = Some(Err((E_REJECTED, "the person declined in the Igneum Wallet".into())));
p.decided = Some(now);
}
Ok(p.clone())
}
pub fn resolve(&mut self, id: &str, r: Result<Value, (i64, String)>, now: Instant) {
if let Some(p) = self.pending.iter_mut().find(|p| p.id == id) {
p.done = Some(r);
p.decided = Some(now);
}
}
pub fn set_challenge(&mut self, id: &str, nonce: &str, reason: &str) {
if let Some(p) = self.pending.iter_mut().find(|p| p.id == id) {
p.confirm_nonce = nonce.into();
p.confirm_reason = reason.into();
}
}
/// The open requests as the window shows them (the oldest first).
pub fn pending_json(&self, enrolled: bool) -> Value {
Value::Array(self.pending.iter().filter(|p| p.done.is_none()).map(|p| {
let mut d = p.detail.clone();
d["id"] = json!(p.id);
d["kind"] = json!(p.kind.name());
d["created_at"] = json!(p.created_unix);
d["confirm_needed"] = json!(enrolled && p.kind != Kind::Connect);
d["confirm_nonce"] = json!(p.confirm_nonce);
d["confirm_reason"] = json!(p.confirm_reason);
d
}).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
const ME: &str = "0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826";
fn ask(approved: bool, unlocked: bool) -> Ask<'static> {
Ask { address: ME, unlocked, chain_id: 4463, approved, listening: true }
}
fn tx() -> Value {
json!([{ "from": ME, "to": "0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7", "value": "0xde0b6b3a7640000", "data": "0x38ed17390000000000000000000000000000000000000000000000000000000000000000" }])
}
/// The known-failed shape of 0.1.5: a page could reach nothing at all; with the bridge, a page without approval
/// must still get nothing but the connect question.
#[test]
fn a_page_without_approval_gets_nothing() {
let mut b = Bridge::new();
let now = Instant::now();
for (m, p) in [("eth_chainId", json!([])), ("eth_sendTransaction", tx()), ("personal_sign", json!(["hello", ME])), ("eth_call", json!([{}, "latest"])), ("eth_signTypedData_v4", json!([ME, "{}"]))] {
match b.request("https://igneum.network", m, &p, &ask(false, true), now, 1.0) {
Reply::Error(c, _) => assert_eq!(c, E_UNAUTHORIZED, "{m}"),
other => panic!("{m} answered {other:?}"),
}
}
assert_eq!(b.request("https://igneum.network", "eth_accounts", &json!([]), &ask(false, true), now, 1.0), Reply::Result(json!([])));
assert!(b.open().is_empty(), "no request was created for an unapproved page");
assert_eq!(b.request("", "eth_requestAccounts", &json!([]), &ask(false, true), now, 1.0), Reply::Error(E_UNAUTHORIZED, "a page bridge call carries its origin".into()));
}
#[test]
fn a_connect_request_waits_for_the_windows_word_and_only_its_origin_reads_it() {
let mut b = Bridge::new();
let now = Instant::now();
let Reply::Pending(id) = b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(false, true), now, 1.0) else { panic!() };
assert_eq!(b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(false, true), now, 2.0), Reply::Pending(id.clone()), "the same page asking again gets the same request");
assert_eq!(b.poll("https://igneum.network", &id), Reply::Pending(id.clone()));
assert_eq!(b.poll("https://evil.example", &id), Reply::Error(E_UNAUTHORIZED, "not your request".into()));
let p = b.decide(&id, true, now).unwrap();
assert_eq!(p.kind, Kind::Connect);
b.resolve(&id, Ok(json!([ME])), now);
assert_eq!(b.poll("https://igneum.network", &id), Reply::Result(json!([ME])));
assert_eq!(b.poll("https://igneum.network", &id), Reply::Error(E_INTERNAL, "unknown request".into()), "read once");
// declined
let Reply::Pending(id2) = b.request("https://other.example", "eth_requestAccounts", &json!([]), &ask(false, true), now, 3.0) else { panic!() };
b.decide(&id2, false, now).unwrap();
assert!(matches!(b.poll("https://other.example", &id2), Reply::Error(E_REJECTED, _)));
// once approved: the address, the chain, the reads proxied, another chain refused
assert_eq!(b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(true, true), now, 4.0), Reply::Result(json!([ME])));
assert_eq!(b.request("https://igneum.network", "eth_chainId", &json!([]), &ask(true, true), now, 4.0), Reply::Result(json!("0x116f")));
assert_eq!(b.request("https://igneum.network", "eth_call", &json!([{}, "latest"]), &ask(true, true), now, 4.0), Reply::Proxy);
assert_eq!(b.request("https://igneum.network", "wallet_switchEthereumChain", &json!([{ "chainId": "0x116f" }]), &ask(true, true), now, 4.0), Reply::Result(Value::Null));
assert!(matches!(b.request("https://igneum.network", "wallet_switchEthereumChain", &json!([{ "chainId": "0x1" }]), &ask(true, true), now, 4.0), Reply::Error(E_CHAIN, _)));
assert!(matches!(b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(true, false), now, 4.0), Reply::Error(E_UNAUTHORIZED, _)), "locked: no address");
}
#[test]
fn a_send_a_message_and_typed_data_each_wait_as_their_own_request_with_the_facts_the_window_shows() {
let mut b = Bridge::new();
let now = Instant::now();
let Reply::Pending(id) = b.request("https://igneum.network", "eth_sendTransaction", &tx(), &ask(true, true), now, 1.0) else { panic!() };
let p = b.pending.iter().find(|p| p.id == id).unwrap().clone();
assert_eq!(p.kind, Kind::Send);
assert_eq!(p.detail["to_display"], json!("0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7"));
assert_eq!(p.detail["value"], json!("1000000000000000000"));
assert_eq!(p.detail["selector"], json!("0x38ed1739"));
assert_eq!(p.detail["data_len"], json!(36));
assert_eq!(p.request["gas"], Value::Null);
assert!(matches!(b.request("https://igneum.network", "eth_sendTransaction", &json!([{ "from": "0x1111111111111111111111111111111111111111", "to": ME }]), &ask(true, true), now, 1.0), Reply::Error(E_UNAUTHORIZED, _)));
assert!(matches!(b.request("https://igneum.network", "eth_sendTransaction", &json!([{ "from": ME }]), &ask(true, true), now, 1.0), Reply::Error(E_PARAMS, _)));
let Reply::Pending(id2) = b.request("https://igneum.network", "personal_sign", &json!(["0x68656c6c6f", ME]), &ask(true, true), now, 1.0) else { panic!() };
let p2 = b.pending.iter().find(|p| p.id == id2).unwrap().clone();
assert_eq!(p2.kind, Kind::Sign);
assert_eq!(p2.detail["text"], json!("hello"));
assert_eq!(p2.request["digest"].as_str().unwrap().len(), 66);
let typed = json!({ "types": { "Person": [{ "name": "name", "type": "string" }] }, "primaryType": "Person", "domain": { "name": "Igneum Swap", "chainId": 4463 }, "message": { "name": "Cow" } });
let Reply::Pending(id3) = b.request("https://igneum.network", "eth_signTypedData_v4", &json!([ME, typed.to_string()]), &ask(true, true), now, 1.0) else { panic!() };
let p3 = b.pending.iter().find(|p| p.id == id3).unwrap().clone();
assert_eq!(p3.kind, Kind::Typed);
assert_eq!(p3.detail["domain"], json!("Igneum Swap"));
assert_eq!(p3.detail["primary_type"], json!("Person"));
let other_chain = json!({ "types": { "Person": [{ "name": "name", "type": "string" }] }, "primaryType": "Person", "domain": { "chainId": 1 }, "message": { "name": "Cow" } });
assert!(matches!(b.request("https://igneum.network", "eth_signTypedData_v4", &json!([ME, other_chain]), &ask(true, true), now, 1.0), Reply::Error(E_CHAIN, _)));
assert_eq!(b.pending_json(true).as_array().unwrap().len(), 3);
assert_eq!(b.pending_json(true)[0]["confirm_needed"], json!(true));
// the window's confirm, the engine's resolve, the page's read
b.decide(&id2, true, now).unwrap();
b.resolve(&id2, Ok(json!("0xsig")), now);
assert_eq!(b.poll("https://igneum.network", &id2), Reply::Result(json!("0xsig")));
assert_eq!(b.open().len(), 2);
}
#[test]
fn an_unanswered_request_expires_and_a_read_result_is_dropped() {
let mut b = Bridge::new();
let t0 = Instant::now();
let Reply::Pending(id) = b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(false, true), t0, 1.0) else { panic!() };
assert!(b.expire(t0 + PENDING_TTL / 2).is_empty());
let gone = b.expire(t0 + PENDING_TTL + Duration::from_secs(1));
assert_eq!(gone.len(), 1);
assert!(matches!(b.poll("https://igneum.network", &id), Reply::Error(E_REJECTED, m) if m.contains("expired")));
let Reply::Pending(id2) = b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(false, true), t0, 2.0) else { panic!() };
b.decide(&id2, false, t0).unwrap();
b.expire(t0 + RESULT_TTL + Duration::from_secs(1));
assert!(matches!(b.poll("https://igneum.network", &id2), Reply::Error(E_INTERNAL, _)), "an unread result is gone after RESULT_TTL");
}
}

View file

@ -1,334 +0,0 @@
//! EIP-712 typed data hashing for the page bridge (8 October 2026): the digest a page asks the wallet to sign with
//! eth_signTypedData_v4, computed here so the window can show the domain and the primary type and the key never
//! leaves the engine. Supports the atomic types (uintN, intN, bytesN, bool, address), the dynamic ones (string,
//! bytes), arrays (fixed and dynamic) and nested structs; the domain's fields are the ones present in `domain`
//! (name, version, chainId, verifyingContract, salt) when `types` carries no EIP712Domain of its own.
use serde_json::Value;
use sha3::{Digest, Keccak256};
pub struct Typed {
pub digest: [u8; 32],
pub domain_name: String,
pub primary_type: String,
pub chain_id: Option<u128>,
}
fn keccak(b: &[u8]) -> [u8; 32] {
Keccak256::digest(b).into()
}
/// A 256-bit unsigned number from a JSON number, a decimal string or a 0x hex string, big-endian in 32 bytes.
pub fn u256_bytes(v: &Value) -> Result<[u8; 32], String> {
let mut out = [0u8; 32];
match v {
Value::Number(n) => {
let x = n.as_u64().ok_or("a number must be a whole non-negative number")?;
out[24..].copy_from_slice(&x.to_be_bytes());
Ok(out)
}
Value::String(s) => {
let s = s.trim();
if let Some(h) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
if h.is_empty() || h.len() > 64 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!("not a 256-bit hex number: {s}"));
}
let padded = format!("{:0>64}", h);
for i in 0..32 {
out[i] = u8::from_str_radix(&padded[i * 2..i * 2 + 2], 16).map_err(|e| e.to_string())?;
}
Ok(out)
} else {
if s.is_empty() || !s.chars().all(|c| c.is_ascii_digit()) {
return Err(format!("not a decimal number: {s}"));
}
// schoolbook base-10 into 32 big-endian bytes
for c in s.bytes() {
let mut carry = (c - b'0') as u32;
for i in (0..32).rev() {
let x = out[i] as u32 * 10 + carry;
out[i] = (x & 0xff) as u8;
carry = x >> 8;
}
if carry != 0 {
return Err("number over 256 bits".into());
}
}
Ok(out)
}
}
_ => Err("not a number".into()),
}
}
fn i256_bytes(v: &Value) -> Result<[u8; 32], String> {
let neg = match v {
Value::Number(n) => n.as_i64().map(|x| x < 0).unwrap_or(false),
Value::String(s) => s.trim().starts_with('-'),
_ => false,
};
if !neg {
return u256_bytes(v);
}
let mag = match v {
Value::Number(n) => Value::String((n.as_i64().unwrap().unsigned_abs()).to_string()),
Value::String(s) => Value::String(s.trim()[1..].to_string()),
_ => unreachable!(),
};
let m = u256_bytes(&mag)?;
// two's complement: invert and add one
let mut out = [0u8; 32];
let mut carry = 1u16;
for i in (0..32).rev() {
let x = (!m[i]) as u16 + carry;
out[i] = (x & 0xff) as u8;
carry = x >> 8;
}
Ok(out)
}
fn hex_bytes(s: &str) -> Result<Vec<u8>, String> {
let h = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).ok_or("bytes must be 0x hex")?;
if h.len() % 2 != 0 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("bytes must be even-length hex".into());
}
(0..h.len() / 2).map(|i| u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).map_err(|e| e.to_string())).collect()
}
fn is_struct(types: &Value, t: &str) -> bool {
types.get(t).map(|v| v.is_array()).unwrap_or(false)
}
fn base_type(t: &str) -> &str {
match t.find('[') {
Some(i) => &t[..i],
None => t,
}
}
/// The struct types `t` depends on, `t` first, the rest sorted by name (EIP-712 encodeType).
fn dependencies(types: &Value, t: &str) -> Result<Vec<String>, String> {
let mut found: Vec<String> = Vec::new();
let mut todo = vec![t.to_string()];
while let Some(cur) = todo.pop() {
if found.contains(&cur) {
continue;
}
let fields = types.get(&cur).and_then(|v| v.as_array()).ok_or(format!("unknown type {cur}"))?;
found.push(cur.clone());
for f in fields {
let ft = f.get("type").and_then(|v| v.as_str()).ok_or("a field without a type")?;
let b = base_type(ft);
if is_struct(types, b) && !found.contains(&b.to_string()) {
todo.push(b.to_string());
}
}
}
let mut rest: Vec<String> = found.iter().skip(1).cloned().collect();
rest.sort();
let mut out = vec![found[0].clone()];
out.extend(rest);
Ok(out)
}
pub fn encode_type(types: &Value, t: &str) -> Result<String, String> {
let mut s = String::new();
for name in dependencies(types, t)? {
let fields = types.get(&name).and_then(|v| v.as_array()).ok_or("type")?;
s.push_str(&name);
s.push('(');
let mut first = true;
for f in fields {
if !first {
s.push(',');
}
first = false;
s.push_str(f.get("type").and_then(|v| v.as_str()).ok_or("field type")?);
s.push(' ');
s.push_str(f.get("name").and_then(|v| v.as_str()).ok_or("field name")?);
}
s.push(')');
}
Ok(s)
}
pub fn type_hash(types: &Value, t: &str) -> Result<[u8; 32], String> {
Ok(keccak(encode_type(types, t)?.as_bytes()))
}
fn encode_value(types: &Value, t: &str, v: &Value) -> Result<[u8; 32], String> {
if let Some(i) = t.find('[') {
// an array: keccak of the concatenated encodings of its elements
let inner = &t[..i];
let rest = &t[i + 1..];
let items = v.as_array().ok_or(format!("{t}: not an array"))?;
if let Some(n) = rest.strip_suffix(']').and_then(|n| if n.is_empty() { None } else { n.parse::<usize>().ok() }) {
if items.len() != n {
return Err(format!("{t}: {} items, {n} expected", items.len()));
}
}
let tail = &rest[rest.find(']').map(|j| j + 1).unwrap_or(rest.len())..];
let elem_type = format!("{inner}{tail}");
let mut cat = Vec::new();
for it in items {
cat.extend_from_slice(&encode_value(types, &elem_type, it)?);
}
return Ok(keccak(&cat));
}
if is_struct(types, t) {
return hash_struct(types, t, v);
}
match t {
"string" => Ok(keccak(v.as_str().ok_or("string expected")?.as_bytes())),
"bytes" => Ok(keccak(&hex_bytes(v.as_str().ok_or("bytes expected")?)?)),
"bool" => {
let mut out = [0u8; 32];
out[31] = if v.as_bool().ok_or("bool expected")? { 1 } else { 0 };
Ok(out)
}
"address" => {
let b = hex_bytes(v.as_str().ok_or("address expected")?)?;
if b.len() != 20 {
return Err("an address is 20 bytes".into());
}
let mut out = [0u8; 32];
out[12..].copy_from_slice(&b);
Ok(out)
}
_ if t.starts_with("uint") => {
let bits: usize = t[4..].parse().map_err(|_| format!("bad type {t}"))?;
if bits == 0 || bits > 256 || bits % 8 != 0 {
return Err(format!("bad type {t}"));
}
u256_bytes(v)
}
_ if t.starts_with("int") => {
let bits: usize = t[3..].parse().map_err(|_| format!("bad type {t}"))?;
if bits == 0 || bits > 256 || bits % 8 != 0 {
return Err(format!("bad type {t}"));
}
i256_bytes(v)
}
_ if t.starts_with("bytes") => {
let n: usize = t[5..].parse().map_err(|_| format!("bad type {t}"))?;
if n == 0 || n > 32 {
return Err(format!("bad type {t}"));
}
let b = hex_bytes(v.as_str().ok_or("bytes expected")?)?;
if b.len() != n {
return Err(format!("{t}: {} bytes given", b.len()));
}
let mut out = [0u8; 32];
out[..n].copy_from_slice(&b);
Ok(out)
}
_ => Err(format!("unsupported type {t}")),
}
}
pub fn hash_struct(types: &Value, t: &str, v: &Value) -> Result<[u8; 32], String> {
let fields = types.get(t).and_then(|x| x.as_array()).ok_or(format!("unknown type {t}"))?;
let obj = v.as_object().ok_or(format!("{t}: an object expected"))?;
let mut enc = Vec::new();
enc.extend_from_slice(&type_hash(types, t)?);
for f in fields {
let name = f.get("name").and_then(|x| x.as_str()).ok_or("field name")?;
let ft = f.get("type").and_then(|x| x.as_str()).ok_or("field type")?;
let fv = obj.get(name).ok_or(format!("{t}.{name} is missing"))?;
enc.extend_from_slice(&encode_value(types, ft, fv)?);
}
Ok(keccak(&enc))
}
/// The domain type when `types` has none: the fields present in `domain`, in the canonical order.
fn domain_types(domain: &Value) -> Value {
let order = [("name", "string"), ("version", "string"), ("chainId", "uint256"), ("verifyingContract", "address"), ("salt", "bytes32")];
let mut v = Vec::new();
for (n, t) in order {
if domain.get(n).is_some() {
v.push(serde_json::json!({ "name": n, "type": t }));
}
}
Value::Array(v)
}
/// The EIP-712 digest of a typed-data object ({types, primaryType, domain, message}).
pub fn hash(typed: &Value) -> Result<Typed, String> {
let mut types = typed.get("types").cloned().ok_or("typed data without types")?;
let domain = typed.get("domain").cloned().unwrap_or(Value::Object(Default::default()));
if !types.get("EIP712Domain").map(|v| v.is_array()).unwrap_or(false) {
types["EIP712Domain"] = domain_types(&domain);
}
let primary = typed.get("primaryType").and_then(|v| v.as_str()).ok_or("typed data without primaryType")?.to_string();
let message = typed.get("message").cloned().ok_or("typed data without message")?;
let ds = hash_struct(&types, "EIP712Domain", &domain)?;
let ms = if primary == "EIP712Domain" { None } else { Some(hash_struct(&types, &primary, &message)?) };
let mut pre = vec![0x19, 0x01];
pre.extend_from_slice(&ds);
if let Some(m) = ms {
pre.extend_from_slice(&m);
}
let chain_id = domain.get("chainId").and_then(|v| u256_bytes(v).ok()).map(|b| {
let mut x = 0u128;
for byte in &b[16..] {
x = (x << 8) | *byte as u128;
}
x
});
Ok(Typed { digest: keccak(&pre), domain_name: domain.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(), primary_type: primary, chain_id })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// The specification's own example (EIP-712, "Mail" from Cow to Bob): the three known hashes.
fn mail() -> Value {
json!({
"types": {
"EIP712Domain": [{"name": "name", "type": "string"}, {"name": "version", "type": "string"}, {"name": "chainId", "type": "uint256"}, {"name": "verifyingContract", "type": "address"}],
"Person": [{"name": "name", "type": "string"}, {"name": "wallet", "type": "address"}],
"Mail": [{"name": "from", "type": "Person"}, {"name": "to", "type": "Person"}, {"name": "contents", "type": "string"}]
},
"primaryType": "Mail",
"domain": {"name": "Ether Mail", "version": "1", "chainId": 1, "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"},
"message": {"from": {"name": "Cow", "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"}, "to": {"name": "Bob", "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"}, "contents": "Hello, Bob!"}
})
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
#[test]
fn the_specifications_mail_example_hashes_as_published() {
let m = mail();
assert_eq!(encode_type(&m["types"], "Mail").unwrap(), "Mail(Person from,Person to,string contents)Person(string name,address wallet)");
assert_eq!(hex(&hash_struct(&m["types"], "EIP712Domain", &m["domain"]).unwrap()), "f2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f"); // no-secrets-ok: the EIP-712 specification's published example hash
assert_eq!(hex(&hash_struct(&m["types"], "Mail", &m["message"]).unwrap()), "c52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e"); // no-secrets-ok: the EIP-712 specification's published example hash
let t = hash(&m).unwrap();
assert_eq!(hex(&t.digest), "be609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2"); // no-secrets-ok: the EIP-712 specification's published example digest
assert_eq!(t.domain_name, "Ether Mail");
assert_eq!(t.primary_type, "Mail");
assert_eq!(t.chain_id, Some(1));
}
#[test]
fn a_domain_without_declared_types_takes_the_fields_present_in_order() {
let mut m = mail();
m["types"].as_object_mut().unwrap().remove("EIP712Domain");
assert_eq!(hex(&hash(&m).unwrap().digest), "be609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2"); // no-secrets-ok: the EIP-712 specification's published example digest
}
#[test]
fn numbers_arrays_and_bytes_encode() {
assert_eq!(hex(&u256_bytes(&json!("115792089237316195423570985008687907853269984665640564039457584007913129639935")).unwrap()), "f".repeat(64));
assert_eq!(hex(&u256_bytes(&json!("0x1f")).unwrap()), format!("{:0>64}", "1f"));
assert_eq!(hex(&u256_bytes(&json!(31)).unwrap()), format!("{:0>64}", "1f"));
assert_eq!(hex(&i256_bytes(&json!(-1)).unwrap()), "f".repeat(64));
assert!(u256_bytes(&json!("1157920892373161954235709850086879078532699846656405640394575840079131296399350")).is_err());
let types = json!({ "T": [{"name": "xs", "type": "uint8[]"}, {"name": "b", "type": "bytes4"}, {"name": "ok", "type": "bool"}] });
let one = hash_struct(&types, "T", &json!({ "xs": [1, 2], "b": "0x01020304", "ok": true })).unwrap();
let two = hash_struct(&types, "T", &json!({ "xs": [2, 1], "b": "0x01020304", "ok": true })).unwrap();
assert_ne!(one, two);
assert!(hash_struct(&types, "T", &json!({ "xs": [1], "b": "0x0102", "ok": true })).is_err(), "bytes4 with two bytes is refused");
assert!(hash_struct(&types, "T", &json!({ "b": "0x01020304", "ok": true })).is_err(), "a missing field is refused");
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,156 +0,0 @@
//! The node's Ethereum JSON-RPC: what the wallet reads and the one thing it writes (eth_sendRawTransaction). Plain
//! HTTP to 127.0.0.1 through igneum-common; a public https RPC (no local node) goes through curl.
use serde_json::{json, Value};
use std::time::Duration;
#[derive(Clone, Debug)]
pub struct Evm {
/// "127.0.0.1:26790" (plain http) or "https://..." (curl)
pub endpoint: String,
}
pub fn q(v: &Value) -> Option<u128> {
let s = v.as_str()?;
u128::from_str_radix(s.trim_start_matches("0x"), 16).ok()
}
pub fn hexq(v: u128) -> String {
format!("0x{v:x}")
}
impl Evm {
pub fn local(port: u16) -> Evm {
Evm { endpoint: format!("127.0.0.1:{port}") }
}
pub fn call(&self, method: &str, params: Value) -> Result<Value, String> {
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
let text = if self.endpoint.starts_with("https://") || self.endpoint.starts_with("http://") {
let mut c = std::process::Command::new(igneum_common::platform::tool("curl"));
c.args(["-sS", "--max-time", "20", "-X", "POST", &self.endpoint, "-H", "Content-Type: application/json", "-d", &body]);
igneum_common::run::run_timeout(&mut c, None, Duration::from_secs(25)).ok_or("curl is not available")?
} else {
igneum_common::http::post_json(&self.endpoint, "/", &body, Duration::from_secs(20))?
};
let v: Value = serde_json::from_str(text.trim()).map_err(|_| format!("{method}: not JSON: {}", text.chars().take(120).collect::<String>()))?;
if let Some(e) = v.get("error") {
let msg = e.get("message").and_then(|m| m.as_str()).unwrap_or("error");
return Err(format!("{method}: {msg}"));
}
Ok(v.get("result").cloned().unwrap_or(Value::Null))
}
pub fn chain_id(&self) -> Result<u64, String> {
q(&self.call("eth_chainId", json!([]))?).map(|v| v as u64).ok_or("eth_chainId: no number".into())
}
pub fn block_number(&self) -> Result<u64, String> {
q(&self.call("eth_blockNumber", json!([]))?).map(|v| v as u64).ok_or("eth_blockNumber: no number".into())
}
pub fn balance(&self, address: &str) -> Result<u128, String> {
q(&self.call("eth_getBalance", json!([address, "latest"]))?).ok_or("eth_getBalance: no number".into())
}
pub fn nonce(&self, address: &str) -> Result<u64, String> {
q(&self.call("eth_getTransactionCount", json!([address, "pending"]))?).map(|v| v as u64).ok_or("nonce: no number".into())
}
/// (base fee per gas of the latest block, the node's suggested priority fee)
pub fn fees(&self) -> Result<(u128, u128), String> {
let b = self.call("eth_getBlockByNumber", json!(["latest", false]))?;
let base = b.get("baseFeePerGas").and_then(q).unwrap_or(0);
let tip = self.call("eth_maxPriorityFeePerGas", json!([])).ok().and_then(|v| q(&v)).unwrap_or(1_000_000_000);
Ok((base, tip))
}
pub fn estimate_gas(&self, from: &str, to: &str, value: u128) -> Result<u64, String> {
q(&self.call("eth_estimateGas", json!([{ "from": from, "to": to, "value": hexq(value) }]))?).map(|v| v as u64).ok_or("eth_estimateGas: no number".into())
}
/// The gas a call needs, with its data (the page bridge's contract calls; the node prices proving gas inside
/// execution gas on Devnet 3, so the node's own estimate is the one to use).
pub fn estimate_gas_call(&self, from: &str, to: &str, value: u128, data: &str) -> Result<u64, String> {
let mut call = json!({ "from": from, "to": to, "value": hexq(value) });
if data.len() > 2 {
call["data"] = json!(data);
}
q(&self.call("eth_estimateGas", json!([call]))?).map(|v| v as u64).ok_or("eth_estimateGas: no number".into())
}
pub fn send_raw(&self, raw: &[u8]) -> Result<String, String> {
let v = self.call("eth_sendRawTransaction", json!([crate::tx::hex0x(raw)]))?;
v.as_str().map(|s| s.to_string()).ok_or("eth_sendRawTransaction: no hash".into())
}
pub fn receipt(&self, hash: &str) -> Result<Option<Value>, String> {
let v = self.call("eth_getTransactionReceipt", json!([hash]))?;
Ok(if v.is_null() { None } else { Some(v) })
}
pub fn tx(&self, hash: &str) -> Result<Option<Value>, String> {
let v = self.call("eth_getTransactionByHash", json!([hash]))?;
Ok(if v.is_null() { None } else { Some(v) })
}
pub fn block(&self, number: u64, full: bool) -> Result<Option<Value>, String> {
let v = self.call("eth_getBlockByNumber", json!([hexq(number as u128), full]))?;
Ok(if v.is_null() { None } else { Some(v) })
}
pub fn block_by_hash(&self, hash: &str) -> Result<Option<Value>, String> {
let v = self.call("eth_getBlockByHash", json!([hash, false]))?;
Ok(if v.is_null() { None } else { Some(v) })
}
/// igneum_getSegment: the chain block's execution record (rewards per blue block's miner, proving pool credit).
pub fn segment(&self, number: u64) -> Result<Option<Value>, String> {
let v = self.call("igneum_getSegment", json!([hexq(number as u128)]))?;
Ok(if v.is_null() { None } else { Some(v) })
}
pub fn tx_status(&self, hash: &str) -> Result<Value, String> {
self.call("igneum_getTransactionStatus", json!([hash]))
}
}
/// wei to a decimal IGN string with up to `places` decimals, trailing zeros trimmed (never scientific, never rounded up).
pub fn ign(wei: u128, places: usize) -> String {
let whole = wei / 1_000_000_000_000_000_000;
let frac = wei % 1_000_000_000_000_000_000;
let mut f = format!("{frac:018}");
f.truncate(places);
let f = f.trim_end_matches('0');
if f.is_empty() { format!("{whole}") } else { format!("{whole}.{f}") }
}
/// A typed amount ("1.5", "0.001", "12") to wei; refuses more than 18 decimals and anything that is not a number.
pub fn parse_ign(text: &str) -> Result<u128, String> {
let t = text.trim().replace(',', "");
if t.is_empty() {
return Err("type an amount".into());
}
let (w, f) = match t.split_once('.') {
Some((w, f)) => (w, f),
None => (t.as_str(), ""),
};
if !w.chars().all(|c| c.is_ascii_digit()) || !f.chars().all(|c| c.is_ascii_digit()) || (w.is_empty() && f.is_empty()) {
return Err("an amount is digits with one dot".into());
}
if f.len() > 18 {
return Err("at most 18 decimals".into());
}
let whole: u128 = if w.is_empty() { 0 } else { w.parse().map_err(|_| "amount too large")? };
let mut frac = f.to_string();
while frac.len() < 18 {
frac.push('0');
}
let frac: u128 = if frac.is_empty() { 0 } else { frac.parse().map_err(|_| "amount")? };
whole.checked_mul(1_000_000_000_000_000_000).and_then(|v| v.checked_add(frac)).ok_or("amount too large".into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn amounts() {
assert_eq!(parse_ign("1.5").unwrap(), 1_500_000_000_000_000_000);
assert_eq!(parse_ign("0.000000000000000001").unwrap(), 1);
assert_eq!(parse_ign("12").unwrap(), 12_000_000_000_000_000_000);
assert!(parse_ign("1e3").is_err());
assert!(parse_ign("0.0000000000000000001").is_err());
assert_eq!(ign(1_500_000_000_000_000_000, 6), "1.5");
assert_eq!(ign(1, 18), "0.000000000000000001");
assert_eq!(ign(1, 6), "0");
assert_eq!(ign(42_000_000_000_000_000_000, 6), "42");
assert_eq!(q(&json!("0x116f")), Some(4463));
}
}

View file

@ -1,227 +0,0 @@
//! Finality the wallet checks itself. A transaction is "final" only when its block sits under a certified checkpoint
//! whose BLS certificate this wallet verified with the node's own code (kaspa_consensus_core::finality), never from a
//! confirmation count and never on the node's word alone. The steps are the browser verifier's (site/verify/core.js):
//! 1. the certificate as a block carried it (the coinbase finality section of the blocks after the checkpoint)
//! 2. the canonical voter list: every key above dust and not stripped, sorted by key hash, 48-byte G1 keys that
//! hash (BLAKE2b-256 keyed "IgneumVoteKeyHash") to the key hashes the node names, as many as the certificate says
//! 3. the aggregate G2 signature over `igneum-vote-v1/<chain id> 0x00 index_le64 checkpoint` by the bitmap's keys
//! 4. the rule: signed weight at least 2/3 of the active weight and at least 2/3 of the total weight (the floor
//! decided 4 October 2026, O-3.15; stricter than the 17/30 this node line still carries)
//! Then "under": the checkpoint block's selected-chain height from the Ethereum RPC; a transaction's block is under
//! it when its number is at most that height and its hash is still the chain's hash at that number.
use kaspa_consensus_core::finality::{block_finality_content, verify_aggregate, vote_key_hash, Certificate, FinalityItem, PUBKEY_LEN};
use kaspa_hashes::Hash;
use kaspa_rpc_core::model::{GetFinalityWeightsResponse, RpcCheckpoint, RpcKeyWeight};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct VerifiedCheckpoint {
pub index: u64,
pub hash: String,
/// the checkpoint block's selected-chain height on the execution side (None until the Ethereum RPC names it)
pub chain_number: Option<u64>,
pub signers: usize,
pub voters: usize,
pub signed_weight: u64,
pub total_weight: u64,
pub active_weight: f64,
pub fraction_total: f64,
pub fraction_active: f64,
/// the checkpoint index the node's weight table was taken at (equal to `index` when exact)
pub weights_at_index: u64,
pub carrier: String,
pub verified_at: f64,
}
/// What the window shows for one transaction.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum Status {
/// not in any block the node knows
Pending,
/// in chain block `number`; no verified checkpoint covers it yet
InBlock { number: u64 },
/// under verified checkpoint `index`
Final { number: u64, index: u64 },
/// the receipt says the execution failed; still subject to the same finality
Failed { number: u64, reason: String },
}
/// The state machine, pure: receipt (block number, block hash), the chain's hash at that number now, the newest
/// verified checkpoint. Tested below.
pub fn status(receipt: Option<(u64, &str, bool)>, canonical_hash: Option<&str>, verified: Option<&VerifiedCheckpoint>) -> Status {
let Some((number, hash, ok)) = receipt else { return Status::Pending };
// the block the receipt names must still be the chain's block at that height
match canonical_hash {
Some(h) if h.eq_ignore_ascii_case(hash) => {}
_ => return Status::Pending,
}
if !ok {
return Status::Failed { number, reason: "the execution failed (reverted or out of gas)".into() };
}
match verified.and_then(|v| v.chain_number.map(|n| (n, v.index))) {
Some((cp_number, index)) if number <= cp_number => Status::Final { number, index },
_ => Status::InBlock { number },
}
}
/// The certificate for `cp` as a block after it carried it, scanning up to `pages` pages of getBlocks.
pub fn find_certificate(grpc: &crate::node::Grpc, cp: &RpcCheckpoint, pages: usize) -> Result<(Certificate, String), String> {
let mut low: Hash = cp.hash;
let mut seen = 0usize;
for _ in 0..pages {
let blocks = grpc.blocks_after(low)?;
if blocks.is_empty() {
break;
}
for b in &blocks {
seen += 1;
if let Some(tx) = b.transactions.first() {
let (_, items) = block_finality_content(&tx.payload);
for it in items {
if let FinalityItem::Certificate(c) = it {
if c.index == cp.index && c.checkpoint == cp.hash {
return Ok((c, b.header.hash.to_string()));
}
}
}
}
}
let last = blocks.last().unwrap().header.hash;
if last == low {
break;
}
low = last;
}
Err(format!("no block within {seen} of checkpoint {} carries its certificate yet", cp.index))
}
/// Steps 2 to 4 over a certificate and the node's weight table.
pub fn verify(chain_id: &str, cp: &RpcCheckpoint, cert: &Certificate, carrier: &str, w: &GetFinalityWeightsResponse) -> Result<VerifiedCheckpoint, String> {
let mut voters: Vec<&RpcKeyWeight> = w.keys.iter().filter(|k| k.voter).collect();
voters.sort_by(|a, b| a.key_hash.cmp(&b.key_hash));
if voters.len() != cert.voter_count as usize {
return Err(format!("certificate names {} voters, the node's table at checkpoint {} has {}", cert.voter_count, w.checkpoint_index, voters.len()));
}
let mut pubkeys: Vec<[u8; PUBKEY_LEN]> = Vec::with_capacity(voters.len());
for (i, v) in voters.iter().enumerate() {
let raw = igneum_common::keys::unhex(&v.pubkey).ok_or(format!("voter {i} key is not hex"))?;
let pk: [u8; PUBKEY_LEN] = raw.try_into().map_err(|_| format!("voter {i} key is not 48 bytes"))?;
if vote_key_hash(&pk) != v.key_hash {
return Err(format!("voter {i} key does not hash to its key hash"));
}
pubkeys.push(pk);
}
let positions = cert.signer_positions();
if positions.is_empty() {
return Err("the certificate has no signers".into());
}
let signing: Vec<[u8; PUBKEY_LEN]> = positions.iter().map(|&p| pubkeys[p]).collect();
if !verify_aggregate(chain_id, cert.index, cert.checkpoint, &signing, &cert.signature) {
return Err("the aggregate signature does not verify".into());
}
let total: u64 = voters.iter().map(|v| v.blocks).sum();
let active: f64 = voters.iter().map(|v| v.blocks as f64 * v.participation).sum();
let signed: u64 = positions.iter().map(|&p| voters[p].blocks).sum();
if total == 0 {
return Err("total weight is zero".into());
}
let fraction_total = signed as f64 / total as f64;
let fraction_active = if active > 0.0 { signed as f64 / active } else { 0.0 };
if (3 * signed as u128) < (2 * active.round() as u128) {
return Err(format!("signed weight is {:.1}% of active, below 2/3", fraction_active * 100.0));
}
if 3 * (signed as u128) < 2 * (total as u128) {
return Err(format!("signed weight is {:.1}% of total, below 2/3", fraction_total * 100.0));
}
Ok(VerifiedCheckpoint {
index: cp.index,
hash: cp.hash.to_string(),
chain_number: None,
signers: positions.len(),
voters: voters.len(),
signed_weight: signed,
total_weight: total,
active_weight: active,
fraction_total,
fraction_active,
weights_at_index: w.checkpoint_index,
carrier: carrier.to_string(),
verified_at: igneum_common::platform::unix_now_f(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn cp(chain_number: Option<u64>) -> VerifiedCheckpoint {
VerifiedCheckpoint { index: 536, hash: "ac08".into(), chain_number, ..Default::default() }
}
#[test]
fn state_machine() {
// no receipt: pending, whatever the checkpoint says
assert_eq!(status(None, None, Some(&cp(Some(100)))), Status::Pending);
// in a block, no verified checkpoint
assert_eq!(status(Some((10, "0xaa", true)), Some("0xaa"), None), Status::InBlock { number: 10 });
// the checkpoint is below the block: still in a block
assert_eq!(status(Some((10, "0xaa", true)), Some("0xaa"), Some(&cp(Some(9)))), Status::InBlock { number: 10 });
// the checkpoint's chain height is unknown yet
assert_eq!(status(Some((10, "0xaa", true)), Some("0xaa"), Some(&cp(None))), Status::InBlock { number: 10 });
// under the checkpoint: final, with the index
assert_eq!(status(Some((10, "0xaa", true)), Some("0xaa"), Some(&cp(Some(10)))), Status::Final { number: 10, index: 536 });
assert_eq!(status(Some((3, "0xaa", true)), Some("0xAA"), Some(&cp(Some(10)))), Status::Final { number: 3, index: 536 });
// the chain moved away from the receipt's block: back to pending
assert_eq!(status(Some((10, "0xaa", true)), Some("0xbb"), Some(&cp(Some(10)))), Status::Pending);
assert_eq!(status(Some((10, "0xaa", true)), None, Some(&cp(Some(10)))), Status::Pending);
// a failed execution is reported as failed, never final
assert!(matches!(status(Some((10, "0xaa", false)), Some("0xaa"), Some(&cp(Some(10)))), Status::Failed { number: 10, .. }));
}
/// A certificate made with real BLS keys verifies; a flipped bit, a missing voter or a thin quorum does not.
#[test]
fn certificate_rule() {
use kaspa_consensus_core::finality::{aggregate_signatures, VoteSecretKey};
use kaspa_rpc_core::model::RpcFinalityParams;
let chain = "igneum-devnet-955";
let checkpoint = Hash::from_slice(&[7u8; 32]);
let keys: Vec<VoteSecretKey> = (0..3).map(|i| VoteSecretKey::from_label(&format!("wallet-test-{i}"))).collect();
let mut table: Vec<RpcKeyWeight> = keys
.iter()
.enumerate()
.map(|(i, k)| RpcKeyWeight { key_hash: k.key_hash(), pubkey: igneum_common::keys::hex(&k.public_key()), blocks: [50, 30, 20][i], voter: true, participation: 1.0, stripped_until_daa: 0 })
.collect();
table.sort_by(|a, b| a.key_hash.cmp(&b.key_hash));
let by_hash = |h: Hash| keys.iter().find(|k| k.key_hash() == h).unwrap();
// the two heaviest keys sign (80 of 100)
let mut signers: Vec<usize> = (0..3).filter(|&p| table[p].blocks >= 30).collect();
signers.sort();
let sigs: Vec<[u8; 96]> = signers.iter().map(|&p| by_hash(table[p].key_hash).sign_vote(chain, 536, checkpoint)).collect();
let agg = aggregate_signatures(&sigs).unwrap();
let cert = Certificate { index: 536, checkpoint, voter_count: 3, bitmap: Certificate::bitmap_from_positions(3, &signers), signature: agg, aggregator: Hash::from_slice(&[0u8; 32]), aggregator_proof: [0u8; 96] };
let rcp = RpcCheckpoint { index: 536, hash: checkpoint, blue_score: 0, daa_score: 0, state: "locked".into(), signed_weight: 80, active_weight: 100, total_weight: 100, fraction_active: 0.8, fraction_total: 0.8, votes_seen: 2, voters: 3, aggregators: vec![], locked_at_daa: 1, certificate_aggregator: Hash::from_slice(&[0u8; 32]) };
let params = RpcFinalityParams { checkpoint_interval: 30, checkpoint_depth: 20, weight_window: 120, dust: 5, presence_window: 1, aggregators: 8, equivocation_ban: 120, min_daa: 120 };
let w = GetFinalityWeightsResponse { params, checkpoint_index: 536, checkpoint_hash: checkpoint, daa_score: 0, total_weight: 100, active_weight: 100.0, voters: 3, keys: table.clone() };
let v = verify(chain, &rcp, &cert, "carrier", &w).unwrap();
assert_eq!(v.signers, 2);
assert_eq!(v.signed_weight, 80);
assert!((v.fraction_total - 0.8).abs() < 1e-9);
// wrong chain id: the message differs
assert!(verify("igneum-devnet-1", &rcp, &cert, "c", &w).is_err());
// a flipped signature byte
let mut bad = cert.clone();
bad.signature[5] ^= 1;
assert!(verify(chain, &rcp, &bad, "c", &w).unwrap_err().contains("aggregate signature"));
// only the lightest key signs: 20 of 100, below 2/3
let p = (0..3).find(|&p| table[p].blocks == 20).unwrap();
let s = by_hash(table[p].key_hash).sign_vote(chain, 536, checkpoint);
let thin = Certificate { bitmap: Certificate::bitmap_from_positions(3, &[p]), signature: aggregate_signatures(&[s]).unwrap(), ..cert.clone() };
assert!(verify(chain, &rcp, &thin, "c", &w).unwrap_err().contains("below 2/3"));
// a voter list that does not match the certificate's count
let mut w2 = w.clone();
w2.keys.pop();
assert!(verify(chain, &rcp, &cert, "c", &w2).unwrap_err().contains("voters"));
}
}

View file

@ -1,99 +0,0 @@
//! Keys from words: BIP-39 (24 English words, 256 bits of entropy from the OS) and BIP-32/44 at m/44'/60'/0'/0/0, the
//! Ethereum path, so the same words open the same account in MetaMask. A raw private key import skips the words.
use bip32::{DerivationPath, XPrv};
use bip39::{Language, Mnemonic};
use igneum_common::keys;
pub const PATH: &str = "m/44'/60'/0'/0/0";
pub struct Derived {
pub private_key: String, // 0x + 64 hex
pub address: String, // 0x + 40 lower-case hex
}
/// 24 new words from 256 bits of OS randomness.
pub fn new_words() -> Result<String, String> {
let mut entropy = [0u8; 32];
getrandom::getrandom(&mut entropy).map_err(|e| e.to_string())?;
let m = Mnemonic::from_entropy_in(Language::English, &entropy).map_err(|e| e.to_string())?;
Ok(m.words().collect::<Vec<_>>().join(" "))
}
/// Normalises a typed phrase: lower case, single spaces. Accepts 12, 15, 18, 21 or 24 words; checks the checksum.
pub fn parse_words(text: &str) -> Result<String, String> {
let words: Vec<String> = text.split_whitespace().map(|w| w.to_lowercase()).collect();
if ![12, 15, 18, 21, 24].contains(&words.len()) {
return Err(format!("{} words: a phrase has 12 or 24 words", words.len()));
}
let joined = words.join(" ");
Mnemonic::parse_in_normalized(Language::English, &joined).map_err(|e| match e {
bip39::Error::UnknownWord(i) => format!("word {} is not in the word list: {}", i + 1, words[i]),
bip39::Error::InvalidChecksum => "the words do not check out (one is wrong or out of order)".to_string(),
other => other.to_string(),
})?;
Ok(joined)
}
/// The Ethereum account at m/44'/60'/0'/0/0 of a phrase (no BIP-39 passphrase).
pub fn derive(words: &str) -> Result<Derived, String> {
let m = Mnemonic::parse_in_normalized(Language::English, words).map_err(|e| e.to_string())?;
let seed = m.to_seed("");
let path: DerivationPath = PATH.parse().map_err(|_| "bad path".to_string())?;
let xprv = XPrv::derive_from_path(seed, &path).map_err(|e| e.to_string())?;
let raw: [u8; 32] = xprv.private_key().to_bytes().into();
let address = keys::address_of_raw(&raw).ok_or("the derived key is invalid")?;
Ok(Derived { private_key: format!("0x{}", keys::hex(&raw)), address })
}
/// A raw private key, typed or pasted: 64 hex with or without 0x.
pub fn parse_private_key(text: &str) -> Result<Derived, String> {
let raw = keys::unhex(text).ok_or("a private key is 64 hex characters")?;
if raw.len() != 32 {
return Err(format!("a private key is 32 bytes, this is {}", raw.len()));
}
let arr: [u8; 32] = raw.try_into().unwrap();
let address = keys::address_of_raw(&arr).ok_or("this is not a valid secp256k1 key")?;
Ok(Derived { private_key: format!("0x{}", keys::hex(&arr)), address })
}
#[cfg(test)]
mod tests {
use super::*;
/// The reference phrase every Ethereum tool ships with (Hardhat account 0).
#[test]
fn hardhat_vector() {
let d = derive("test test test test test test test test test test test junk").unwrap();
assert_eq!(d.private_key, "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80");
assert_eq!(keys::checksum(&d.address), "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
}
/// BIP-39 vector 1 (entropy all zero): the words and, with the TREZOR passphrase, the published seed.
#[test]
fn bip39_vector_one() {
let m = Mnemonic::from_entropy_in(Language::English, &[0u8; 16]).unwrap();
assert_eq!(m.to_string(), "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about");
let seed = m.to_seed("TREZOR");
assert_eq!(keys::hex(&seed), "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04");
}
#[test]
fn words_are_24_and_parse() {
let w = new_words().unwrap();
assert_eq!(w.split(' ').count(), 24);
assert_eq!(parse_words(&w.to_uppercase()).unwrap(), w);
let mut broken: Vec<&str> = w.split(' ').collect();
broken[0] = "zzzz";
assert!(parse_words(&broken.join(" ")).unwrap_err().contains("word 1"));
assert!(parse_words("abandon abandon").is_err());
}
#[test]
fn raw_key_import() {
let d = parse_private_key("0000000000000000000000000000000000000000000000000000000000000001").unwrap();
assert_eq!(keys::checksum(&d.address), "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf");
assert!(parse_private_key("0x01").is_err());
assert!(parse_private_key(&"ff".repeat(32)).is_err()); // above the curve order
}
}

View file

@ -1,137 +0,0 @@
//! What happened to this address: transfers in and out from the chain's blocks, block rewards from the execution
//! records (igneum_getSegment: one reward per blue block, paid to the miner the block named), proving payouts when a
//! segment carries a proof record (none on this node line yet; the label is ready). Scanned forward from the last
//! block seen and kept in `<data>/wallet/history-<chain id>-<address>.json`.
use crate::evm::{q, Evm};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::Path;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Entry {
pub hash: String,
/// sent | received | self | reward | proving
pub kind: String,
pub block: u64,
pub block_hash: String,
pub from: String,
pub to: String,
/// wei, as a decimal string (u128 is wider than JSON numbers)
pub value: String,
#[serde(default)]
pub fee: String,
pub ok: bool,
pub time: u64,
/// pending | in_block | final | failed, with the checkpoint index when final
#[serde(default)]
pub finality: String,
#[serde(default)]
pub checkpoint: Option<u64>,
#[serde(default)]
pub note: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct History {
pub chain_id: u64,
pub address: String,
/// every block up to and including this one was read
pub scanned_to: Option<u64>,
pub entries: Vec<Entry>,
}
impl History {
pub fn load(path: &Path, chain_id: u64, address: &str) -> History {
std::fs::read_to_string(path)
.ok()
.and_then(|t| serde_json::from_str::<History>(&t).ok())
.filter(|h| h.chain_id == chain_id && h.address.eq_ignore_ascii_case(address))
.unwrap_or(History { chain_id, address: address.to_ascii_lowercase(), scanned_to: None, entries: vec![] })
}
pub fn save(&self, path: &Path) {
if let Some(d) = path.parent() {
let _ = std::fs::create_dir_all(d);
}
if let Ok(t) = serde_json::to_string(self) {
let _ = std::fs::write(path, t);
igneum_common::platform::lock_permissions(path, false);
}
}
pub fn has(&self, hash: &str) -> bool {
self.entries.iter().any(|e| e.hash.eq_ignore_ascii_case(hash))
}
/// Adds or replaces by hash, newest block first.
pub fn put(&mut self, e: Entry) {
self.entries.retain(|x| !x.hash.eq_ignore_ascii_case(&e.hash));
self.entries.push(e);
self.entries.sort_by(|a, b| b.block.cmp(&a.block).then(b.time.cmp(&a.time)));
if self.entries.len() > 2000 {
self.entries.truncate(2000);
}
}
}
fn s(v: &Value, k: &str) -> String {
v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string()
}
/// Reads blocks `from..=to` and returns the entries that touch `me` (lower-case 0x address).
pub fn scan(evm: &Evm, me: &str, from: u64, to: u64) -> Result<Vec<Entry>, String> {
let mut out = Vec::new();
for n in from..=to {
let Some(b) = evm.block(n, true)? else { break };
let bh = s(&b, "hash");
let time = b.get("timestamp").and_then(q).unwrap_or(0) as u64;
if let Some(txs) = b.get("transactions").and_then(|t| t.as_array()) {
for t in txs {
let from = s(t, "from").to_ascii_lowercase();
let to = s(t, "to").to_ascii_lowercase();
if from != me && to != me {
continue;
}
let hash = s(t, "hash");
let value = t.get("value").and_then(q).unwrap_or(0);
let kind = if from == me && to == me { "self" } else if from == me { "sent" } else { "received" };
// the receipt: status and the fee actually paid
let (ok, fee) = match evm.receipt(&hash)? {
Some(r) => {
let ok = r.get("status").and_then(q).unwrap_or(1) == 1;
let gas = r.get("gasUsed").and_then(q).unwrap_or(0);
let price = r.get("effectiveGasPrice").and_then(q).unwrap_or(0);
(ok, gas * price)
}
None => (true, 0),
};
out.push(Entry { hash, kind: kind.into(), block: n, block_hash: bh.clone(), from, to, value: value.to_string(), fee: fee.to_string(), ok, time, finality: "in_block".into(), checkpoint: None, note: String::new() });
}
}
// rewards: the segment names every blue block's miner and what it was paid
if let Some(seg) = evm.segment(n)? {
if let Some(rs) = seg.get("rewards").and_then(|r| r.as_array()) {
for (i, r) in rs.iter().enumerate() {
let miner = s(r, "miner").to_ascii_lowercase();
if miner != me {
continue;
}
let wei = r.get("wei").and_then(q).unwrap_or(0);
if wei == 0 {
continue;
}
out.push(Entry { hash: format!("reward-{n}-{i}"), kind: "reward".into(), block: n, block_hash: bh.clone(), from: String::new(), to: me.to_string(), value: wei.to_string(), fee: "0".into(), ok: true, time, finality: "in_block".into(), checkpoint: None, note: "block reward".into() });
}
}
if let Some(pr) = seg.get("proofRecord").filter(|v| !v.is_null()) {
if let Some(ps) = pr.get("payouts").and_then(|p| p.as_array()) {
for (i, p) in ps.iter().enumerate() {
if s(p, "address").eq_ignore_ascii_case(me) {
let wei = p.get("wei").and_then(q).unwrap_or(0);
out.push(Entry { hash: format!("proving-{n}-{i}"), kind: "proving".into(), block: n, block_hash: bh.clone(), from: String::new(), to: me.to_string(), value: wei.to_string(), fee: "0".into(), ok: true, time, finality: "in_block".into(), checkpoint: None, note: "shard payout".into() });
}
}
}
}
}
}
Ok(out)
}

View file

@ -1,130 +0,0 @@
//! Igneum Wallet engine. Keeps the key, signs, reads a node, verifies finality certificates, and serves the window on
//! 127.0.0.1:<random port>/t/<token>/. The window host (macOS: app/mac/IgneumWallet.swift, Windows: the WebView2
//! host) starts it with --wrapper, reads `URL ...` and `STATE {...}` lines from its stdout and writes `quit` on its
//! stdin. Without a host (--open) the window opens in the default browser. A host also reads one `HOST {...}` line:
//! its own token (sent as X-Igneum-Host on the calls only it may make: the biometric confirmations) and the path of
//! the sealed-password file it writes for Touch ID or Windows Hello.
//!
//! igneum-wallet [--wrapper | --open | --no-open | --launch] [--print-url]
//!
//! Environment (tests): IGNEUM_APP_DATA, IGNEUM_APP_LOGS, IGNEUM_APP_BIN, IGNEUM_WALLET_GRPC_PORT, IGNEUM_WALLET_EVM_PORT,
//! IGNEUM_WALLET_NO_NODE, IGNEUM_WALLET_NETWORK, IGNEUM_WALLET_DEVNET_SUFFIX, IGNEUM_WALLET_PEERS,
//! IGNEUM_WALLET_NODE_DIR, IGNEUM_WALLET_OVERRIDE_PARAMS, IGNEUM_WALLET_UPDATE_MANIFEST, IGNEUM_WALLET_IDLE_LOCK_S.
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
mod bridge;
mod eip712;
mod engine;
mod evm;
mod finality;
mod hd;
mod history;
mod node;
mod qr;
mod server;
mod state;
mod tx;
mod updater;
mod vault;
use igneum_common::platform;
use std::io::{BufRead, Write};
use std::sync::mpsc::channel;
use std::sync::Arc;
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let wrapper = args.iter().any(|a| a == "--wrapper");
let no_open = wrapper || args.iter().any(|a| a == "--no-open");
if args.iter().any(|a| a == "--version" || a == "-V") {
println!("igneum-wallet {}", engine::VERSION);
return;
}
if args.iter().any(|a| a == "--launch") {
if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) {
let host = dir.join(engine::APP.host_exe);
if host.exists() {
let mut c = std::process::Command::new(&host);
c.current_dir(&dir);
if c.spawn().is_ok() {
return;
}
}
}
}
platform::clear_quarantine();
let root = platform::data_root();
let app_dir = root.join(engine::APP.data_sub);
let log_dir = platform::log_root();
let _ = std::fs::create_dir_all(&app_dir);
let _ = std::fs::create_dir_all(&log_dir);
platform::lock_permissions(&app_dir, true);
let paths = engine::Paths {
vault: app_dir.join("vault.json"),
settings: app_dir.join("settings.json"),
miner_wallet: root.join(igneum_common::MINER.data_sub).join("wallet.json"),
biometric: app_dir.join("biometric.json"),
app_dir: app_dir.clone(),
log_dir: log_dir.clone(),
};
let packaged = igneum_common::config::Packaged::load(&igneum_common::config::Packaged::candidates("igneum-wallet.json"));
let settings = engine::Settings::load(&paths.settings);
let machine_id = igneum_common::config::machine_id(&app_dir);
let token = igneum_common::http::new_token();
let host_token = igneum_common::http::new_token();
let stamp_file = log_dir.join(format!("wallet-{}.log", stamp_now()));
let engine_log = std::fs::File::create(&stamp_file).ok();
let (tx, rx) = channel();
let biometric_file = paths.biometric.clone();
let shared = Arc::new(engine::Shared::new(token.clone(), host_token.clone(), paths, packaged, settings, machine_id, tx, engine_log, stamp_file.clone()));
let port = match server::start(shared.clone()) {
Ok(p) => p,
Err(e) => {
eprintln!("cannot listen on 127.0.0.1: {e}");
if wrapper {
println!("FATAL cannot listen on 127.0.0.1: {e}");
}
std::process::exit(1);
}
};
server::start_bridge(shared.clone());
let url = format!("http://127.0.0.1:{port}/t/{token}/");
let url_file = shared.paths.app_dir.join("wallet.url");
let _ = std::fs::write(&url_file, &url);
platform::lock_permissions(&url_file, false);
shared.log(&format!("window listening on 127.0.0.1:{port} (the URL with its token is in wallet.url; log {})", stamp_file.display()));
if wrapper || args.iter().any(|a| a == "--print-url") {
println!("URL {url}");
if wrapper {
// the host alone reads stdout: its token and where the sealed password goes
println!("HOST {}", serde_json::json!({ "token": host_token, "biometric_file": biometric_file.display().to_string() }));
}
let _ = std::io::stdout().flush();
}
if !no_open {
platform::open_url(&url);
}
{
let shared = shared.clone();
std::thread::spawn(move || {
let stdin = std::io::stdin();
for line in stdin.lock().lines() {
let Ok(l) = line else { break };
match l.trim() {
"quit" => shared.send(engine::Cmd::Quit),
"lock" => shared.lock(),
_ => {}
}
}
if wrapper {
shared.send(engine::Cmd::Quit);
}
});
}
engine::Engine::new(shared, rx, wrapper).run();
}
fn stamp_now() -> String {
let t = platform::unix_now();
format!("{}-{:02}{:02}{:02}", t / 86400, t % 86400 / 3600, t % 3600 / 60, t % 60)
}

View file

@ -1,223 +0,0 @@
//! Where the chain comes from, in this order:
//! 1. the miner app's node on this machine (gRPC 127.0.0.1:26610, Ethereum RPC 26790): used as it is, nothing started
//! 2. the seed's public Ethereum RPC when the package names one (`public_rpc` in igneum-wallet.json): balances,
//! sending and history work; finality verification needs a node's gRPC and is reported as "no node", so
//! transactions stay "in a block" until a node is there
//! 3. the bundled igneumd next to the app, started by the wallet on its own ports (gRPC 26620, Ethereum 26800, p2p
//! 26621) with the same arguments the miner uses, no mining, stopped when the wallet quits
//! The choice is made at start and whenever the source goes away; `State.node.source` says which.
//! Environment (tests): IGNEUM_WALLET_GRPC_PORT, IGNEUM_WALLET_EVM_PORT (an external node to use, no probe of the
//! miner's ports), IGNEUM_WALLET_NO_NODE=1 (never start one), IGNEUM_WALLET_NETWORK, IGNEUM_WALLET_DEVNET_SUFFIX,
//! IGNEUM_WALLET_PEERS, IGNEUM_WALLET_NODE_DIR, IGNEUM_WALLET_OVERRIDE_PARAMS (a file for --override-params-file).
use kaspa_grpc_client::GrpcClient;
use kaspa_rpc_core::api::rpc::RpcApi;
use kaspa_rpc_core::error::RpcError;
use kaspa_rpc_core::model::{GetBlockDagInfoResponse, GetFinalityCheckpointsResponse, GetFinalityWeightsResponse, RpcBlock, RpcHash};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::time::Duration;
use tokio::runtime::Runtime;
pub const MINER_GRPC: u16 = 26610;
pub const MINER_EVM: u16 = 26790;
pub const OWN_GRPC: u16 = 26620;
pub const OWN_EVM: u16 = 26800;
pub const OWN_P2P: u16 = 26621;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Source {
/// the miner app's node on this machine
Miner { grpc: u16, evm: u16 },
/// a node named by the environment (tests)
External { grpc: u16, evm: u16 },
/// the seed's public Ethereum RPC; no gRPC, so no certificate verification
Public { url: String },
/// the bundled node, started by the wallet
Own { grpc: u16, evm: u16 },
None,
}
impl Source {
pub fn name(&self) -> &'static str {
match self {
Source::Miner { .. } => "miner",
Source::External { .. } => "external",
Source::Public { .. } => "public",
Source::Own { .. } => "own",
Source::None => "none",
}
}
pub fn grpc_port(&self) -> Option<u16> {
match self {
Source::Miner { grpc, .. } | Source::External { grpc, .. } | Source::Own { grpc, .. } => Some(*grpc),
_ => None,
}
}
pub fn evm(&self) -> Option<crate::evm::Evm> {
match self {
Source::Miner { evm, .. } | Source::External { evm, .. } | Source::Own { evm, .. } => Some(crate::evm::Evm::local(*evm)),
Source::Public { url } => Some(crate::evm::Evm { endpoint: url.clone() }),
Source::None => None,
}
}
}
/// A node's Ethereum RPC answers eth_chainId on this port.
pub fn evm_alive(port: u16) -> bool {
crate::evm::Evm::local(port).chain_id().is_ok()
}
/// The environment's external node, when set.
pub fn external_from_env() -> Option<Source> {
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
let grpc = env("IGNEUM_WALLET_GRPC_PORT")?.parse().ok()?;
let evm = env("IGNEUM_WALLET_EVM_PORT")?.parse().ok()?;
Some(Source::External { grpc, evm })
}
// ---- gRPC, the node's own client, on a private tokio runtime -----------------------------------------------------
pub struct Grpc {
rt: Runtime,
client: Arc<GrpcClient>,
pub url: String,
}
impl Grpc {
pub fn connect(port: u16) -> Result<Grpc, String> {
let rt = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().map_err(|e| e.to_string())?;
let url = format!("grpc://127.0.0.1:{port}");
let client = rt.block_on(async {
tokio::time::timeout(Duration::from_secs(8), GrpcClient::connect(url.clone())).await.map_err(|_| "gRPC connect timed out".to_string())?.map_err(|e| e.to_string())
})?;
Ok(Grpc { rt, client: Arc::new(client), url })
}
fn run<F, T>(&self, f: F) -> Result<T, String>
where
F: std::future::Future<Output = Result<T, RpcError>>,
{
self.rt.block_on(async { tokio::time::timeout(Duration::from_secs(20), f).await.map_err(|_| "rpc timed out".to_string())?.map_err(|e| e.to_string()) })
}
pub fn checkpoints(&self, last: u32) -> Result<GetFinalityCheckpointsResponse, String> {
self.run(self.client.get_finality_checkpoints(last))
}
pub fn weights(&self) -> Result<GetFinalityWeightsResponse, String> {
self.run(self.client.get_finality_weights())
}
/// Blocks from `low` onward (the node's own page size), with transactions.
pub fn blocks_after(&self, low: RpcHash) -> Result<Vec<RpcBlock>, String> {
let r = self.run(self.client.get_blocks(Some(low), true, true))?;
Ok(r.blocks)
}
pub fn dag_info(&self) -> Result<GetBlockDagInfoResponse, String> {
self.run(self.client.get_block_dag_info())
}
pub fn disconnect(&self) {
let c = self.client.clone();
let _ = self.rt.block_on(async { c.disconnect().await });
}
}
// ---- the bundled node ---------------------------------------------------------------------------------------------
pub struct OwnNode {
pub child: Child,
}
pub struct OwnNodePlan {
pub bin: PathBuf,
pub args: Vec<String>,
pub dir: PathBuf,
pub log: PathBuf,
}
/// Finds igneumd next to the engine (Windows), in bin/, or in Contents/Resources/bin (macOS bundle).
pub fn find_igneumd() -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let here = exe.parent()?.to_path_buf();
let mut candidates = vec![];
if let Some(d) = std::env::var_os("IGNEUM_APP_BIN") {
candidates.push(PathBuf::from(d));
}
candidates.push(here.clone());
candidates.push(here.join("bin"));
if let Some(c) = here.parent() {
candidates.push(c.join("Resources").join("bin"));
}
let name = if cfg!(windows) { "igneumd.exe" } else { "igneumd" };
candidates.into_iter().map(|d| d.join(name)).find(|p| p.is_file())
}
pub fn plan_own_node(app_dir: &std::path::Path, log_dir: &std::path::Path, packaged: &igneum_common::config::Packaged) -> Result<OwnNodePlan, String> {
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
let bin = find_igneumd().ok_or("igneumd is not next to the wallet (the package is incomplete)")?;
let network = env("IGNEUM_WALLET_NETWORK").unwrap_or_else(|| "devnet".into());
let suffix: Option<u32> = env("IGNEUM_WALLET_DEVNET_SUFFIX").and_then(|v| v.parse().ok());
let root = igneum_common::platform::data_root();
let dir = match env("IGNEUM_WALLET_NODE_DIR") {
Some(d) => PathBuf::from(d),
None => root.join(match suffix {
Some(n) => format!("wallet-node-devnet-{n}"),
None => format!("wallet-node-{network}"),
}),
};
let peers: Vec<String> = match std::env::var("IGNEUM_WALLET_PEERS") {
Ok(v) => v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
Err(_) if network == "devnet" && suffix.is_none() => vec!["188.245.5.161:26611".into()],
Err(_) => vec![],
};
let mut args = vec![
format!("--{network}"),
format!("--appdir={}", dir.display()),
format!("--rpclisten=127.0.0.1:{OWN_GRPC}"),
format!("--evm-rpclisten=127.0.0.1:{OWN_EVM}"),
format!("--listen=0.0.0.0:{OWN_P2P}"),
];
if let Some(n) = suffix {
args.push(format!("--devnet-suffix={n}"));
}
for p in &peers {
args.push(format!("--addpeer={p}"));
}
// the packager's consensus pin, as the miner does it (igneum-wallet.json node_override_params)
if let Some(file) = env("IGNEUM_WALLET_OVERRIDE_PARAMS") {
args.push(format!("--override-params-file={file}"));
} else if let Some(v) = packaged.node_override_params.as_ref().filter(|v| v.as_object().map(|o| !o.is_empty()).unwrap_or(false)) {
let path = app_dir.join("override-params.json");
std::fs::write(&path, v.to_string()).map_err(|e| e.to_string())?;
args.push(format!("--override-params-file={}", path.display()));
}
args.extend(["--nodnsseed", "--disable-upnp", "--nologfiles", "--yes"].iter().map(|s| s.to_string()));
let log = log_dir.join("wallet-node.log");
Ok(OwnNodePlan { bin, args, dir, log })
}
pub fn start_own_node(plan: &OwnNodePlan) -> Result<OwnNode, String> {
let _ = std::fs::create_dir_all(&plan.dir);
let out = std::fs::OpenOptions::new().create(true).append(true).open(&plan.log).map_err(|e| e.to_string())?;
let err = out.try_clone().map_err(|e| e.to_string())?;
let mut c = Command::new(&plan.bin);
c.args(&plan.args).stdin(Stdio::null()).stdout(Stdio::from(out)).stderr(Stdio::from(err));
igneum_common::platform::quiet(&mut c);
let child = c.spawn().map_err(|e| format!("igneumd did not start: {e}"))?;
Ok(OwnNode { child })
}
impl OwnNode {
pub fn stop(&mut self) {
igneum_common::platform::terminate(&mut self.child);
for _ in 0..300 {
if let Ok(Some(_)) = self.child.try_wait() {
return;
}
std::thread::sleep(Duration::from_millis(100));
}
let _ = self.child.kill();
let _ = self.child.wait();
}
pub fn alive(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(None))
}
}

View file

@ -1,31 +0,0 @@
//! The receive QR code, drawn here (no image service, no library call across the network): qrcodegen to an SVG.
use qrcodegen::{QrCode, QrCodeEcc};
pub fn svg(text: &str) -> Result<String, String> {
let qr = QrCode::encode_text(text, QrCodeEcc::Medium).map_err(|e| format!("{e:?}"))?;
let n = qr.size();
let border = 2;
let dim = n + 2 * border;
let mut path = String::new();
for y in 0..n {
for x in 0..n {
if qr.get_module(x, y) {
path.push_str(&format!("M{} {}h1v1h-1z", x + border, y + border));
}
}
}
Ok(format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {dim} {dim}\" shape-rendering=\"crispEdges\" role=\"img\" aria-label=\"QR code\"><rect width=\"{dim}\" height=\"{dim}\" fill=\"#F4F1EC\"/><path d=\"{path}\" fill=\"#0C0C0E\"/></svg>"
))
}
#[cfg(test)]
mod tests {
#[test]
fn draws() {
let s = super::svg("ethereum:0x7e5f4552091a69125d5dfcb7b8c2659029395bdf").unwrap();
assert!(s.starts_with("<svg"));
assert!(s.contains("<path d=\"M"));
}
}

View file

@ -1,288 +0,0 @@
//! The local web server: the window's files from the binary and the JSON API, on 127.0.0.1 with a random port, every
//! path under `/t/<token>/` (igneum_common::http). Mutating calls must come from the window itself (same origin).
use crate::engine::{Cmd, Shared};
use igneum_common::http::{from_dashboard, json_resp, query_param, read_request, respond, respond_with};
use serde_json::{json, Value};
use std::net::TcpStream;
use std::sync::Arc;
const INDEX: &str = include_str!("../ui/index.html");
const CSS: &str = include_str!("../ui/app.css");
const JS: &str = include_str!("../ui/app.js");
const CARD_JS: &str = include_str!("../ui/update-card.js");
const LOCK_JS: &str = include_str!("../ui/lock-screen.js");
const MARK: &str = include_str!("../ui/mark.svg");
const COIN: &[u8] = include_bytes!("../../../brand/igneum-coin-1024.png");
const FONT_MONO_400: &[u8] = include_bytes!("../../igneum-app/ui/fonts/IBMPlexMono-400.woff2");
const FONT_MONO_500: &[u8] = include_bytes!("../../igneum-app/ui/fonts/IBMPlexMono-500.woff2");
const FONT_SANS_400: &[u8] = include_bytes!("../../igneum-app/ui/fonts/IBMPlexSans-400.woff2");
const FONT_SANS_500: &[u8] = include_bytes!("../../igneum-app/ui/fonts/IBMPlexSans-500.woff2");
const FONT_SANS_600: &[u8] = include_bytes!("../../igneum-app/ui/fonts/IBMPlexSans-600.woff2");
const FONT_UNB_500: &[u8] = include_bytes!("../../igneum-app/ui/fonts/Unbounded-500.woff2");
const FONT_UNB_700: &[u8] = include_bytes!("../../igneum-app/ui/fonts/Unbounded-700.woff2");
const FONT_UNB_900: &[u8] = include_bytes!("../../igneum-app/ui/fonts/Unbounded-900.woff2");
pub fn start(shared: Arc<Shared>) -> std::io::Result<u16> {
let s = shared.clone();
let port = igneum_common::http::serve(move |stream| handle(stream, s.clone()))?;
shared.set_port(port);
Ok(port)
}
/// The page bridge's listener on its fixed port (src/bridge.rs): GET /bridge/hello (the page's probe), POST /bridge/rpc
/// (JSON-RPC with the page's Origin and the X-Igneum-Bridge header), and the CORS preflight that must come first.
/// Nothing under /t/<token>/ is served here, and nothing here reads the window's token.
pub fn start_bridge(shared: Arc<Shared>) {
let port = std::env::var("IGNEUM_WALLET_BRIDGE_PORT").ok().and_then(|v| v.parse().ok()).unwrap_or(crate::bridge::BRIDGE_PORT);
let s = shared.clone();
match igneum_common::http::serve_on(port, move |stream| handle_bridge(stream, s.clone())) {
Ok(()) => {
*shared.bridge_listening.lock().unwrap() = (true, port, String::new());
shared.log(&format!("page bridge listening on 127.0.0.1:{port} (websites connect here after your approval in the window)"));
}
Err(e) => {
*shared.bridge_listening.lock().unwrap() = (false, port, format!("port {port} is not free: {e}"));
shared.log(&format!("page bridge not listening: port {port} is not free ({e}); websites cannot connect until the wallet restarts with the port free"));
}
}
}
fn bridge_origin_ok(o: &str) -> bool {
o.starts_with("https://") || o.starts_with("http://localhost") || o.starts_with("http://127.0.0.1")
}
fn handle_bridge(mut stream: TcpStream, shared: Arc<Shared>) {
let Some(req) = read_request(&mut stream) else { return };
let origin = req.origin.clone().unwrap_or_default();
let cors: Vec<(&str, &str)> = if bridge_origin_ok(&origin) {
vec![("Access-Control-Allow-Origin", origin.as_str()), ("Vary", "Origin"), ("Access-Control-Allow-Methods", "GET, POST, OPTIONS"), ("Access-Control-Allow-Headers", "content-type, x-igneum-bridge"), ("Access-Control-Allow-Private-Network", "true"), ("Access-Control-Max-Age", "600")]
} else {
vec![]
};
match (req.method.as_str(), req.path.as_str()) {
("OPTIONS", _) => respond_with(&mut stream, 204, "text/plain", b"", &cors),
("GET", "/bridge/hello") => {
// the probe: the wallet is here, its version and chain; never an address
let v = json!({ "ok": true, "wallet": "igneum", "version": crate::engine::VERSION, "chain_id": shared.network_json()["chain_id"], "chain_id_hex": shared.network_json()["chain_id_hex"], "bridge": 1 });
respond_with(&mut stream, 200, "application/json; charset=utf-8", v.to_string().as_bytes(), &cors)
}
("POST", "/bridge/rpc") => {
if origin.is_empty() || !bridge_origin_ok(&origin) || !req.bridge_header {
respond_with(&mut stream, 403, "application/json; charset=utf-8", json!({ "error": { "code": crate::bridge::E_UNAUTHORIZED, "message": "a page bridge call carries its origin and the X-Igneum-Bridge header" } }).to_string().as_bytes(), &cors);
return;
}
let body: Value = serde_json::from_slice(&req.body).unwrap_or(json!({}));
let id = body.get("id").cloned().unwrap_or(Value::Null);
let method = body.get("method").and_then(|v| v.as_str()).unwrap_or("").to_string();
let params = body.get("params").cloned().unwrap_or(json!([]));
let mut out = shared.bridge_request(&origin, &method, &params);
out["id"] = id;
out["jsonrpc"] = json!("2.0");
respond_with(&mut stream, 200, "application/json; charset=utf-8", out.to_string().as_bytes(), &cors)
}
_ => respond_with(&mut stream, 404, "text/plain", b"Igneum Wallet page bridge", &cors),
}
}
fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
let Some(req) = read_request(&mut stream) else { return };
if req.path == "/" {
respond(&mut stream, 404, "text/plain", b"Igneum Wallet: open the app window.", false);
return;
}
let prefix = format!("/t/{}", shared.token);
let Some(rest) = req.path.strip_prefix(&prefix) else {
respond(&mut stream, 404, "text/plain", b"not found", false);
return;
};
let rest = if rest.is_empty() { "/" } else { rest };
match (req.method.as_str(), rest) {
("GET", "/") | ("GET", "/index.html") => respond(&mut stream, 200, "text/html; charset=utf-8", INDEX.as_bytes(), false),
("GET", "/app.css") => respond(&mut stream, 200, "text/css; charset=utf-8", CSS.as_bytes(), false),
("GET", "/app.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", JS.as_bytes(), false),
("GET", "/update-card.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", CARD_JS.as_bytes(), false),
("GET", "/lock-screen.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", LOCK_JS.as_bytes(), false),
("GET", "/mark.svg") => respond(&mut stream, 200, "image/svg+xml", MARK.as_bytes(), true),
("GET", "/coin.png") => respond(&mut stream, 200, "image/png", COIN, true),
("GET", "/fonts/IBMPlexMono-400.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_400, true),
("GET", "/fonts/IBMPlexMono-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_500, true),
("GET", "/fonts/IBMPlexSans-400.woff2") => respond(&mut stream, 200, "font/woff2", FONT_SANS_400, true),
("GET", "/fonts/IBMPlexSans-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_SANS_500, true),
("GET", "/fonts/IBMPlexSans-600.woff2") => respond(&mut stream, 200, "font/woff2", FONT_SANS_600, true),
("GET", "/fonts/Unbounded-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_UNB_500, true),
("GET", "/fonts/Unbounded-700.woff2") => respond(&mut stream, 200, "font/woff2", FONT_UNB_700, true),
("GET", "/fonts/Unbounded-900.woff2") => respond(&mut stream, 200, "font/woff2", FONT_UNB_900, true),
("GET", "/api/state") => json_resp(&mut stream, 200, shared.state_json()),
("GET", "/api/network") => json_resp(&mut stream, 200, shared.network_json()),
("GET", "/api/log") => {
let after = query_param(&req.query, "after").and_then(|s| s.parse().ok()).unwrap_or(0u64);
let limit = query_param(&req.query, "limit").and_then(|s| s.parse().ok()).unwrap_or(400usize).min(2000);
let lines = shared.rings.lock().unwrap().since(after, limit);
json_resp(&mut stream, 200, json!({ "lines": lines }));
}
// the host reads a challenge's reason with its token (the page never needs this: it has the reason already)
("GET", "/api/biometric/challenge") => {
if !shared.host_ok(req.host_token.as_deref()) {
json_resp(&mut stream, 403, json!({ "ok": false, "error": "host token" }));
return;
}
let nonce = query_param(&req.query, "nonce").unwrap_or_default();
match shared.challenge_reason(&nonce) {
Ok(v) => json_resp(&mut stream, 200, v),
Err(e) => json_resp(&mut stream, 400, json!({ "ok": false, "error": e })),
}
}
("GET", p) if p.starts_with("/api/tx/") => {
let hash = p.trim_start_matches("/api/tx/").to_string();
match shared.tx_detail(&hash) {
Ok(v) => json_resp(&mut stream, 200, v),
Err(e) => json_resp(&mut stream, 400, json!({ "ok": false, "error": e })),
}
}
("POST", p) => {
if !from_dashboard(&req, shared.port()) {
json_resp(&mut stream, 403, json!({ "ok": false, "error": "not from the window" }));
return;
}
let body: Value = if req.body.is_empty() { json!({}) } else { serde_json::from_slice(&req.body).unwrap_or(json!({})) };
let from_host = shared.host_ok(req.host_token.as_deref());
if HOST_ONLY.contains(&p) && !from_host {
json_resp(&mut stream, 403, json!({ "ok": false, "error": "only the window host may call this" }));
return;
}
match api_post(&shared, p, body, from_host) {
Ok(v) => json_resp(&mut stream, 200, v),
Err(e) => json_resp(&mut stream, 400, json!({ "ok": false, "error": e })),
}
}
_ => respond(&mut stream, 404, "text/plain", b"not found", false),
}
}
/// Calls that carry a biometric result or take the parked password: the host's token (X-Igneum-Host) is required,
/// so the page cannot confirm its own challenges.
const HOST_ONLY: &[&str] = &["/api/biometric/status", "/api/biometric/report", "/api/biometric/confirm", "/api/biometric/enrol/take", "/api/biometric/enrolled"];
fn api_post(shared: &Arc<Shared>, path: &str, body: Value, from_host: bool) -> Result<Value, String> {
let s = |k: &str| body.get(k).and_then(|v| v.as_str()).map(|v| v.to_string());
let b = |k: &str| body.get(k).and_then(|v| v.as_bool());
match path {
"/api/create" => shared.create_begin(&s("password").ok_or("password missing")?),
"/api/create/confirm" => {
let list = body.get("checks").and_then(|v| v.as_array()).ok_or("checks missing")?;
let checks: Vec<(usize, String)> = list.iter().filter_map(|c| Some((c.get("position")?.as_u64()? as usize, c.get("word")?.as_str()?.to_string()))).collect();
shared.create_confirm(&checks)
}
"/api/import" => shared.import(&s("mode").unwrap_or_default(), &s("data").unwrap_or_default(), &s("password").ok_or("password missing")?),
"/api/unlock" => shared.unlock(&s("password").ok_or("password missing")?, from_host),
"/api/lock" => {
shared.lock();
Ok(json!({ "ok": true }))
}
"/api/reveal" => match s("nonce") {
Some(n) => shared.reveal_confirmed(&n),
None => shared.reveal(&s("password").ok_or("password missing")?),
},
"/api/backed-up" => shared.mark_backed_up(),
"/api/password" => shared.change_password(&s("old").ok_or("old missing")?, &s("new").ok_or("new missing")?),
"/api/remove" => shared.remove(&s("password").ok_or("password missing")?),
"/api/receive" => {
let address = shared.address();
if address.is_empty() {
return Err("no wallet".into());
}
let svg = crate::qr::svg(&format!("ethereum:{}", igneum_common::keys::checksum(&address)))?;
Ok(json!({ "ok": true, "address": address, "display": igneum_common::keys::checksum(&address), "svg": svg }))
}
"/api/send/quote" => {
let q = shared.quote(&s("to").unwrap_or_default(), &s("amount").unwrap_or_default())?;
let mut v = serde_json::to_value(&q).map_err(|e| e.to_string())?;
v["ok"] = json!(true);
v["value_ign"] = json!(crate::evm::ign(q.value.parse().unwrap_or(0), 18));
v["fee_max_ign"] = json!(crate::evm::ign(q.fee_max.parse().unwrap_or(0), 9));
v["total_max_ign"] = json!(crate::evm::ign(q.total_max.parse().unwrap_or(0), 9));
v["base_fee_gwei"] = json!(crate::evm::ign(q.base_fee.parse::<u128>().unwrap_or(0) * 1_000_000_000, 3));
v["tip_gwei"] = json!(crate::evm::ign(q.tip.parse::<u128>().unwrap_or(0) * 1_000_000_000, 3));
v["display_to"] = json!(igneum_common::keys::checksum(&q.to));
// the biometric challenge for this quote: the prompt's line and the nonce the host confirms
let (nonce, reason) = shared.challenge_for_send(&q, &crate::evm::ign(q.value.parse().unwrap_or(0), 4));
v["confirm_nonce"] = json!(nonce);
v["confirm_reason"] = json!(reason);
v["confirm_needed"] = json!(shared.enrolled());
Ok(v)
}
"/api/send" => {
let q: crate::engine::Quote = serde_json::from_value(body.get("quote").cloned().ok_or("quote missing")?).map_err(|e| format!("quote: {e}"))?;
shared.send_tx(&q, s("nonce").as_deref())
}
"/api/settings" => {
if let Some(on) = b("start_at_login") {
shared.set_start_at_login(on)?;
}
if let Some(on) = b("idle_lock") {
shared.set_idle_lock(on)?;
}
if let Some(min) = body.get("idle_lock_min").and_then(|v| v.as_u64()) {
shared.set_idle_lock_min(min)?;
}
if let Some(on) = b("ask_on_open") {
shared.set_ask_on_open(on)?;
}
Ok(json!({ "ok": true }))
}
// ---- Touch ID / Windows Hello: the page's side and the host's side (HOST_ONLY) ----
"/api/activity" => {
shared.touch_activity();
Ok(json!({ "ok": true }))
}
"/api/biometric/challenge" => shared.challenge(&s("purpose").unwrap_or_default()),
"/api/biometric/enrol/begin" => shared.enrol_begin(&s("password").ok_or("password missing")?),
"/api/biometric/enrol/cancel" => shared.enrol_cancel(),
"/api/biometric/remove" => shared.biometric_remove(),
"/api/biometric/status" => shared.biometric_status(b("available").unwrap_or(false), &s("kind").unwrap_or_default(), &s("message").unwrap_or_default()),
"/api/biometric/report" => shared.biometric_report(&s("op").unwrap_or_default(), b("ok").unwrap_or(false), &s("code").unwrap_or_default(), &s("message").unwrap_or_default()),
"/api/biometric/confirm" => shared.confirm(&s("nonce").ok_or("nonce missing")?),
"/api/biometric/enrol/take" => shared.enrol_take(&s("token").ok_or("token missing")?),
"/api/biometric/enrolled" => shared.enrolled_set(&s("kind").unwrap_or_default()),
// ---- the page bridge: the window's decisions (src/bridge.rs) ----
"/api/bridge/decide" => shared.bridge_decide(&s("id").ok_or("id missing")?, b("ok").unwrap_or(false), s("nonce").as_deref()),
"/api/bridge/sites/remove" => shared.bridge_sites_remove(&s("origin").ok_or("origin missing")?),
"/api/bridge/on" => shared.set_bridge_on(b("on").ok_or("on missing")?),
"/api/refresh" => {
shared.send(Cmd::Refresh);
Ok(json!({ "ok": true }))
}
"/api/update/check" => {
shared.send(Cmd::CheckUpdate);
Ok(json!({ "ok": true }))
}
"/api/update/open" => {
shared.send(Cmd::OpenUpdate);
Ok(json!({ "ok": true }))
}
"/api/update/install" => {
shared.send(Cmd::InstallUpdate);
Ok(json!({ "ok": true }))
}
"/api/update/auto" => {
let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?;
shared.send(Cmd::AutoUpdate(on));
Ok(json!({ "ok": true }))
}
"/api/open" => {
let url = s("url").ok_or("url missing")?;
if url.starts_with("https://") || url.starts_with("http://") {
igneum_common::platform::open_url(&url);
Ok(json!({ "ok": true }))
} else {
Err("only http(s) links open".into())
}
}
"/api/quit" => {
shared.send(Cmd::Quit);
Ok(json!({ "ok": true }))
}
_ => Err("unknown api".into()),
}
}

View file

@ -1,156 +0,0 @@
//! The engine's state as the window reads it (`GET /api/state`), plus the event and log rings (as the miner's).
use serde::Serialize;
use std::collections::VecDeque;
#[derive(Clone, Serialize, Default)]
pub struct NodeState {
/// miner | external | public | own | none
pub source: String,
pub state: String, // off | starting | connecting | ok | lost
pub chain_id: u64,
pub chain: String, // the consensus chain id (igneum-devnet-20) when known
pub block: u64,
pub evm: String, // the Ethereum RPC endpoint in use
pub grpc: String,
pub synced: bool,
pub message: String,
pub finality_active: bool,
pub latest_locked: u64,
}
#[derive(Clone, Serialize, Default)]
pub struct FinalityView {
pub verified_index: u64,
pub verified_hash: String,
pub chain_number: Option<u64>,
pub signers: usize,
pub voters: usize,
pub fraction_total: f64,
pub fraction_active: f64,
pub weights_exact: bool,
pub verified_at: f64,
pub message: String,
}
/// The over-the-air updater (src/updater.rs), as the miner's.
#[derive(Clone, Serialize, Default)]
pub struct UpdateState {
pub status: String, // off | unknown | checking | current | available | downloading | staging | ready | applying | deferred | manual | error
pub version: String,
pub url: String,
pub notes: String,
pub file: String, // the downloaded disk image or installer (the manual path opens it)
pub error: String,
pub checked_at: f64,
pub available: bool,
pub downloaded: bool,
pub ready: bool,
pub applying: bool,
pub progress: f64, // 0..1 of the download
pub size: u64,
pub auto: bool, // settings: install by itself when nothing is being sent
pub wait: String, // why it has not applied yet, in the window's words
pub urgent: bool, // this version is below min_supported_version: no waiting
pub urgent_text: String,
pub unsupported: bool,
pub min_supported: String,
pub updated_from: String, // set on the first run after an update
pub rolled_back: String, // set when the helper restored the previous version
}
#[derive(Clone, Serialize, Default)]
pub struct SettingsState {
pub start_at_login: bool,
pub network: String,
pub auto_update: bool,
/// the idle lock in minutes (1, 5, 15, 60), 0 for never; acts only while Touch ID or Windows Hello is enrolled
pub idle_lock_min: u64,
/// the first arrival after the person opened the app may raise the Touch ID sheet once, by itself
pub ask_on_open: bool,
}
#[derive(Clone, Serialize)]
pub struct Event {
pub t: f64,
pub kind: String,
pub text: String,
}
#[derive(Clone, Serialize)]
pub struct LogLine {
pub seq: u64,
pub t: f64,
pub src: String,
pub err: bool,
pub text: String,
}
#[derive(Clone, Serialize, Default)]
pub struct State {
pub version: String,
/// welcome | unlock | home
pub phase: String,
pub has_vault: bool,
pub unlocked: bool,
pub backed_up: bool,
pub source: String, // created | seed | key | miner
pub address: String,
pub display: String,
pub balance_wei: String,
pub balance: String,
pub balance_known: bool,
pub node: NodeState,
pub finality: FinalityView,
pub history: Vec<crate::history::Entry>,
pub scanning: bool,
pub scanned_to: Option<u64>,
pub update: UpdateState,
pub settings: SettingsState,
/// Touch ID / Windows Hello (igneum_common::biometric; the prompt lives in the window host)
pub biometric: igneum_common::biometric::BiometricState,
pub events: Vec<Event>,
pub miner_wallet_file: String,
pub miner_wallet_present: bool,
pub add_network_page: String,
pub public_rpc: String,
pub machine_id: String,
pub host: String,
pub log_dir: String,
pub app_dir: String,
pub uptime_s: u64,
pub now: f64,
pub quitting: bool,
}
pub struct Rings {
pub events: VecDeque<Event>,
pub log: VecDeque<LogLine>,
pub seq: u64,
}
impl Rings {
pub fn new() -> Rings {
Rings { events: VecDeque::new(), log: VecDeque::new(), seq: 0 }
}
pub fn event(&mut self, kind: &str, text: &str) {
self.events.push_front(Event { t: igneum_common::platform::unix_now_f(), kind: kind.into(), text: text.into() });
while self.events.len() > 40 {
self.events.pop_back();
}
}
pub fn log(&mut self, src: &str, err: bool, text: &str) {
self.seq += 1;
self.log.push_back(LogLine { seq: self.seq, t: igneum_common::platform::unix_now_f(), src: src.into(), err, text: text.into() });
while self.log.len() > 3000 {
self.log.pop_front();
}
}
pub fn since(&self, after: u64, limit: usize) -> Vec<LogLine> {
let mut out: Vec<LogLine> = self.log.iter().filter(|l| l.seq > after).cloned().collect();
if out.len() > limit {
out = out.split_off(out.len() - limit);
}
out
}
}

View file

@ -1,319 +0,0 @@
//! EIP-1559 transfers: RLP, the signing hash, the secp256k1 signature (low-s, with the recovery bit), the raw bytes
//! for eth_sendRawTransaction. Written here so the key never leaves the engine; nothing on the window side signs.
use k256::ecdsa::{RecoveryId, Signature, SigningKey, VerifyingKey};
use sha3::{Digest, Keccak256};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Transfer {
pub chain_id: u64,
pub nonce: u64,
pub max_priority_fee: u128,
pub max_fee: u128,
pub gas_limit: u64,
pub to: [u8; 20],
pub value: u128,
pub data: Vec<u8>,
}
// ---- RLP --------------------------------------------------------------------------------------------------
fn rlp_len(len: usize, offset: u8, out: &mut Vec<u8>) {
if len < 56 {
out.push(offset + len as u8);
} else {
let be = (len as u64).to_be_bytes();
let first = be.iter().position(|b| *b != 0).unwrap_or(7);
out.push(offset + 55 + (8 - first) as u8);
out.extend_from_slice(&be[first..]);
}
}
pub fn rlp_bytes(b: &[u8], out: &mut Vec<u8>) {
if b.len() == 1 && b[0] < 0x80 {
out.push(b[0]);
} else {
rlp_len(b.len(), 0x80, out);
out.extend_from_slice(b);
}
}
pub fn rlp_uint(v: u128, out: &mut Vec<u8>) {
if v == 0 {
out.push(0x80);
return;
}
let be = v.to_be_bytes();
let first = be.iter().position(|b| *b != 0).unwrap();
rlp_bytes(&be[first..], out);
}
pub fn rlp_list(items: &[u8], out: &mut Vec<u8>) {
rlp_len(items.len(), 0xc0, out);
out.extend_from_slice(items);
}
fn fields(t: &Transfer, out: &mut Vec<u8>) {
rlp_uint(t.chain_id as u128, out);
rlp_uint(t.nonce as u128, out);
rlp_uint(t.max_priority_fee, out);
rlp_uint(t.max_fee, out);
rlp_uint(t.gas_limit as u128, out);
rlp_bytes(&t.to, out);
rlp_uint(t.value, out);
rlp_bytes(&t.data, out);
out.push(0xc0); // empty access list
}
/// 0x02 || rlp([chainId, nonce, maxPriorityFee, maxFee, gasLimit, to, value, data, accessList])
pub fn unsigned_bytes(t: &Transfer) -> Vec<u8> {
let mut items = Vec::new();
fields(t, &mut items);
let mut out = vec![0x02];
rlp_list(&items, &mut out);
out
}
pub fn signing_hash(t: &Transfer) -> [u8; 32] {
Keccak256::digest(unsigned_bytes(t)).into()
}
pub struct Signed {
pub raw: Vec<u8>,
pub hash: [u8; 32],
}
/// Signs with the raw private key. The signature is normalised to low s (the EVM rejects high s) and the recovery bit
/// flipped with it; the signer's address is recovered from the result and checked before anything is returned.
pub fn sign(t: &Transfer, private_key: &[u8; 32]) -> Result<Signed, String> {
let sk = SigningKey::from_bytes(private_key.into()).map_err(|_| "invalid private key")?;
let h = signing_hash(t);
let (sig, rec): (Signature, RecoveryId) = sk.sign_prehash_recoverable(&h).map_err(|e| e.to_string())?;
let (sig, rec) = match sig.normalize_s() {
Some(low) => (low, RecoveryId::from_byte(rec.to_byte() ^ 1).ok_or("recovery id")?),
None => (sig, rec),
};
// the recovered key must be ours
let vk = VerifyingKey::recover_from_prehash(&h, &sig, rec).map_err(|e| format!("recovery check: {e}"))?;
if vk != *sk.verifying_key() {
return Err("the signature does not recover to the signing key".into());
}
let mut items = Vec::new();
fields(t, &mut items);
rlp_uint(rec.to_byte() as u128, &mut items);
rlp_scalar(&sig.r().to_bytes(), &mut items);
rlp_scalar(&sig.s().to_bytes(), &mut items);
let mut raw = vec![0x02];
rlp_list(&items, &mut raw);
let hash: [u8; 32] = Keccak256::digest(&raw).into();
Ok(Signed { raw, hash })
}
/// Signs a 32-byte digest (EIP-191 personal messages, EIP-712 typed data) with the raw private key: the 65-byte
/// r || s || v signature pages expect (v = 27 + recovery bit), low s, the recovered key checked before anything is
/// returned. The page bridge (8 October 2026).
pub fn sign_digest(h: &[u8; 32], private_key: &[u8; 32]) -> Result<[u8; 65], String> {
let sk = SigningKey::from_bytes(private_key.into()).map_err(|_| "invalid private key")?;
let (sig, rec): (Signature, RecoveryId) = sk.sign_prehash_recoverable(h).map_err(|e| e.to_string())?;
let (sig, rec) = match sig.normalize_s() {
Some(low) => (low, RecoveryId::from_byte(rec.to_byte() ^ 1).ok_or("recovery id")?),
None => (sig, rec),
};
let vk = VerifyingKey::recover_from_prehash(h, &sig, rec).map_err(|e| format!("recovery check: {e}"))?;
if vk != *sk.verifying_key() {
return Err("the signature does not recover to the signing key".into());
}
let mut out = [0u8; 65];
out[..32].copy_from_slice(&sig.r().to_bytes());
out[32..64].copy_from_slice(&sig.s().to_bytes());
out[64] = 27 + rec.to_byte();
Ok(out)
}
/// The EIP-191 digest of a personal message: keccak("\x19Ethereum Signed Message:\n" + len + message).
pub fn personal_digest(message: &[u8]) -> [u8; 32] {
let mut pre = format!("\x19Ethereum Signed Message:\n{}", message.len()).into_bytes();
pre.extend_from_slice(message);
Keccak256::digest(&pre).into()
}
/// The address a 65-byte signature over `h` recovers to, lower-case 0x hex.
pub fn recover_digest(h: &[u8; 32], sig65: &[u8; 65]) -> Option<String> {
let v = sig65[64];
let rec = RecoveryId::from_byte(if v >= 27 { v - 27 } else { v })?;
let sig = Signature::from_slice(&sig65[..64]).ok()?;
let vk = VerifyingKey::recover_from_prehash(h, &sig, rec).ok()?;
let pk = vk.to_encoded_point(false);
let h2: [u8; 32] = Keccak256::digest(&pk.as_bytes()[1..]).into();
Some(format!("0x{}", h2[12..].iter().map(|b| format!("{b:02x}")).collect::<String>()))
}
/// A big-endian scalar (r, s: 32 bytes) as an RLP integer: leading zeros stripped, zero is the empty string.
pub fn rlp_scalar(b: &[u8], out: &mut Vec<u8>) {
let first = b.iter().position(|x| *x != 0);
match first {
None => out.push(0x80),
Some(i) => rlp_bytes(&b[i..], out),
}
}
/// The signed transaction's `from`, recovered from its raw bytes: what the node will see.
pub fn recover_from(raw: &[u8]) -> Option<String> {
// decode the outer list, pull the last three items (v, r, s), rebuild the signing payload
let (items, _) = rlp_decode_list(&raw[1..])?;
if items.len() != 12 {
return None;
}
let mut payload = Vec::new();
for it in &items[..9] {
payload.extend_from_slice(it);
}
let mut unsigned = vec![0x02];
rlp_list(&payload, &mut unsigned);
let h: [u8; 32] = Keccak256::digest(&unsigned).into();
let v = rlp_item_bytes(&items[9])?;
let r = rlp_item_bytes(&items[10])?;
let s = rlp_item_bytes(&items[11])?;
let rec = RecoveryId::from_byte(if v.is_empty() { 0 } else { v[0] })?;
let mut rs = [0u8; 64];
rs[32 - r.len()..32].copy_from_slice(r);
rs[64 - s.len()..].copy_from_slice(s);
let sig = Signature::from_slice(&rs).ok()?;
let vk = VerifyingKey::recover_from_prehash(&h, &sig, rec).ok()?;
let point = vk.to_encoded_point(false);
let hh = Keccak256::digest(&point.as_bytes()[1..]);
Some(format!("0x{}", igneum_common::keys::hex(&hh[12..])))
}
/// Minimal RLP decoding for the recovery check: returns the encoded items (bytes of each item, prefix included).
fn rlp_decode_list(b: &[u8]) -> Option<(Vec<&[u8]>, usize)> {
let (payload_start, payload_len) = rlp_head(b)?;
if b[0] < 0xc0 {
return None;
}
let mut items = Vec::new();
let mut o = payload_start;
let end = payload_start + payload_len;
while o < end {
let (ps, pl) = rlp_head(&b[o..])?;
items.push(&b[o..o + ps + pl]);
o += ps + pl;
}
Some((items, end))
}
fn rlp_head(b: &[u8]) -> Option<(usize, usize)> {
let f = *b.first()?;
Some(match f {
0..=0x7f => (0, 1),
0x80..=0xb7 => (1, (f - 0x80) as usize),
0xb8..=0xbf => {
let n = (f - 0xb7) as usize;
(1 + n, be_len(b.get(1..1 + n)?))
}
0xc0..=0xf7 => (1, (f - 0xc0) as usize),
_ => {
let n = (f - 0xf7) as usize;
(1 + n, be_len(b.get(1..1 + n)?))
}
})
}
fn be_len(b: &[u8]) -> usize {
b.iter().fold(0usize, |a, x| (a << 8) | *x as usize)
}
fn rlp_item_bytes(it: &[u8]) -> Option<&[u8]> {
let (ps, pl) = rlp_head(it)?;
if it[0] < 0x80 {
return Some(&it[..1]);
}
it.get(ps..ps + pl)
}
pub fn hex0x(b: &[u8]) -> String {
format!("0x{}", igneum_common::keys::hex(b))
}
#[cfg(test)]
mod tests {
#[test]
fn a_personal_message_signature_recovers_to_the_signer_and_carries_v_27_or_28() {
let key = [7u8; 32];
let sk = SigningKey::from_bytes((&key).into()).unwrap();
let pk = sk.verifying_key().to_encoded_point(false);
let addr_h: [u8; 32] = Keccak256::digest(&pk.as_bytes()[1..]).into();
let me = format!("0x{}", addr_h[12..].iter().map(|b| format!("{b:02x}")).collect::<String>());
let h = personal_digest(b"hello world");
// the prefix is the EIP-191 one: the same digest as keccak of the literal prefixed bytes
let mut lit = b"\x19Ethereum Signed Message:\n11hello world".to_vec();
let direct: [u8; 32] = Keccak256::digest(&lit).into();
lit.clear();
assert_eq!(h, direct);
let sig = sign_digest(&h, &key).unwrap();
assert!(sig[64] == 27 || sig[64] == 28);
assert_eq!(recover_digest(&h, &sig).as_deref(), Some(me.as_str()));
assert!(sig[32] < 0x80, "low s");
}
use super::*;
fn to20() -> [u8; 20] {
let mut t = [0u8; 20];
t[19] = 0xaa;
t
}
/// The unsigned bytes computed by hand for small values: 0x02 then a 31-byte list.
#[test]
fn rlp_vector() {
let t = Transfer { chain_id: 1, nonce: 0, max_priority_fee: 1, max_fee: 1, gas_limit: 21000, to: to20(), value: 0, data: vec![], };
let b = unsigned_bytes(&t);
let want = format!("02df0180010182520894{}aa8080c0", "00".repeat(19));
assert_eq!(igneum_common::keys::hex(&b), want);
let mut out = Vec::new();
rlp_scalar(&[0u8; 32], &mut out);
assert_eq!(out, vec![0x80]);
let mut out = Vec::new();
let mut one = [0u8; 32];
one[31] = 0x7f;
rlp_scalar(&one, &mut out);
assert_eq!(out, vec![0x7f]);
// keccak256 of the empty string, the classic constant
assert_eq!(igneum_common::keys::hex(&Keccak256::digest(b"")), "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470");
}
#[test]
fn rlp_long_values() {
let mut out = Vec::new();
rlp_uint(1_000_000_000_000_000_000u128, &mut out); // 1 IGN in wei = 0x0de0b6b3a7640000
assert_eq!(igneum_common::keys::hex(&out), "880de0b6b3a7640000");
let mut out = Vec::new();
rlp_bytes(&[0u8; 60], &mut out);
assert_eq!(out[0], 0xb8);
assert_eq!(out[1], 60);
let mut out = Vec::new();
rlp_uint(4463, &mut out);
assert_eq!(igneum_common::keys::hex(&out), "82116f");
}
/// Signing recovers to the signing address, is low-s, and the raw bytes decode back to the same from.
#[test]
fn sign_recovers() {
let mut key = [0u8; 32];
key[31] = 1;
let t = Transfer { chain_id: 4463, nonce: 7, max_priority_fee: 1_000_000_000, max_fee: 3_000_000_000, gas_limit: 21000, to: to20(), value: 5_000_000_000_000_000_000, data: vec![] };
let s = sign(&t, &key).unwrap();
assert_eq!(s.raw[0], 0x02);
assert_eq!(recover_from(&s.raw).unwrap(), "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf");
// deterministic (RFC 6979): the same input signs to the same bytes
let s2 = sign(&t, &key).unwrap();
assert_eq!(s.raw, s2.raw);
assert_eq!(s.hash, s2.hash);
// the Hardhat key signs to its known address too
let hh = igneum_common::keys::unhex("ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80").unwrap();
let hk: [u8; 32] = hh.try_into().unwrap();
let s3 = sign(&t, &hk).unwrap();
assert_eq!(recover_from(&s3.raw).unwrap(), "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266");
}
}

View file

@ -1,751 +0,0 @@
//! Over-the-air updates for the wallet, v2 (5 October 2026): unattended, on the miner's bones (app/igneum-app/src/ota.rs)
//! with the shared parts in igneum_common::{fetch, ota}. The founder's rule: every app updates itself and downloads the
//! update without being asked.
//!
//! The loop, driven from the engine's tick:
//! check (25 s after start, then hourly; 10 minutes after an error): fetch igneum-wallet-latest.json and its .sig,
//! verify the Ed25519 signature with the key compiled into igneum_common::manifest, parse, compare versions
//! -> download (curl with resume into <wallet data>/updates/, then size and sha256 against the manifest)
//! -> stage (macOS: mount the DMG, copy the bundle next to the running one, check its engine answers --version with
//! the manifest's version, digest the staged bundle; Windows: the installer is the staged artefact)
//! -> ready: with the setting "install updates by itself" (default on) the engine applies at the next safe moment,
//! which for a wallet is simply no send in flight (no /api/send running, no quote shown in the last 3 minutes, no
//! sent transaction still waiting for its block, no create flow half way); at once when the user clicks Install
//! now or the version is below min_supported_version
//! -> apply: the engine re-hashes the download and the staged bundle, writes update-pending.json, starts the
//! detached helper and exits (macOS: the helper swaps /Applications/Igneum Wallet.app and opens the new one;
//! Windows: the installer runs first and stops the engine itself; untested for the wallet)
//! -> rollback: the helper restores the previous bundle when the new app does not start twice; the new engine
//! counts its starts and, on the third start without 90 healthy seconds, restores the previous version.
//! "Later" is the window's: it hides the banner for that version; the engine still installs at the safe moment.
//!
//! Environment (tests): IGNEUM_WALLET_UPDATE_MANIFEST overrides the manifest URL from igneum-wallet.json,
//! IGNEUM_WALLET_UPDATE_CHECK_SECS the hourly interval, IGNEUM_WALLET_UPDATE_FIRST_SECS the delay of the first check.
use crate::engine::{Cmd, Shared};
use igneum_common::fetch;
use igneum_common::manifest::{self, Manifest, PlatformEntry};
use igneum_common::ota::{self, Launch, Pending};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
const CHECK_EVERY_S: u64 = 3600;
const FIRST_CHECK_S: u64 = 25;
const RETRY_AFTER_ERROR_S: u64 = 600;
/// A quote shown on the confirm screen counts as a send in flight for this long.
pub const QUOTE_HOLDS_S: u64 = 180;
/// The environment the helper carries to a relaunch (test runs); a normal run has none of these set.
const ENV_PREFIXES: &[&str] = &["IGNEUM_APP_", "IGNEUM_WALLET_", "IGNEUM_OTA_"];
pub enum Event {
/// The manifest fetched, verified and parsed (or why not).
Checked(Result<Manifest, String>),
/// The disk image or installer on disk, size and sha256 checked.
Downloaded(Result<PathBuf, String>),
/// macOS: the new bundle staged next to the running one. Windows: the installer path again.
Staged(Result<PathBuf, String>),
}
/// What the engine must do now.
#[derive(Debug, PartialEq)]
pub enum Action {
Apply,
}
/// What the engine knows when it asks whether now is a safe moment.
#[derive(Clone, Debug, Default)]
pub struct Ctx {
/// A send is in flight: /api/send running, a quote on the confirm screen, or a sent transaction not yet in a block.
pub send_in_flight: bool,
/// The create flow is half way (the 24 words are on the screen, waiting for the confirmation).
pub creating: bool,
}
/// What the manifest means for this install.
#[derive(Debug, PartialEq)]
pub enum Plan {
/// This version is the latest (or newer than the manifest).
Current,
/// A newer version is published without a build for this platform yet.
NoBuild(String),
/// A newer version with a build for this platform.
Update(PlatformEntry),
}
pub fn plan(m: &Manifest, current: &str) -> Plan {
if !manifest::newer(&m.version, current) {
return Plan::Current;
}
match m.this_platform() {
Some(e) => Plan::Update(e.clone()),
None => Plan::NoBuild(m.version.clone()),
}
}
/// Ok when a ready update may be applied now; Err carries the reason to wait, in the words the window shows.
pub fn safe_to_apply(ctx: &Ctx, auto: bool, install_asked: bool, urgent: bool, failed_before: bool) -> Result<(), String> {
if urgent || install_asked {
return Ok(());
}
if !auto {
return Err("waiting for Install now (automatic updates are off)".into());
}
if failed_before {
return Err("this version failed to install before; it waits for Install now".into());
}
if ctx.send_in_flight {
return Err("a send is in flight; installing after it".into());
}
if ctx.creating {
return Err("a wallet is being created; installing after it".into());
}
Ok(())
}
pub struct Updater {
pub url: String,
pub current: String,
app_dir: PathBuf,
dir: PathBuf,
auto: bool,
manifest: Option<Manifest>,
entry: Option<PlatformEntry>,
file: Option<PathBuf>,
staged: Option<PathBuf>,
staged_digest: String,
busy: bool,
next_check: Instant,
ready_since: Option<Instant>,
install_asked: bool,
pending: Option<Pending>,
started: Instant,
healthy_marked: bool,
/// versions whose apply failed or that were rolled back: never re-applied by themselves
failed_versions: Vec<String>,
/// the last manifest's min_supported_version, kept across restarts (updates/manifest.json): the rollback floor
min_supported: String,
/// Windows: the installer was started and the engine is still up (it stops us when it may run)
apply_launched: Option<Instant>,
/// Windows: the administrator prompt was not answered; no automatic retry before this
deferred_until: Option<Instant>,
}
impl Updater {
pub fn new(shared: &Arc<Shared>) -> Updater {
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
let url = env("IGNEUM_WALLET_UPDATE_MANIFEST").unwrap_or_else(|| shared.packaged.update_manifest.clone());
let app_dir = shared.paths.app_dir.clone();
let dir = app_dir.join("updates");
let _ = std::fs::create_dir_all(&dir);
let first = env("IGNEUM_WALLET_UPDATE_FIRST_SECS").and_then(|v| v.parse().ok()).unwrap_or(FIRST_CHECK_S);
let auto = shared.settings.lock().unwrap().auto_update;
let now = Instant::now();
let mut u = Updater {
url,
current: crate::engine::VERSION.to_string(),
app_dir,
dir,
auto,
manifest: None,
entry: None,
file: None,
staged: None,
staged_digest: String::new(),
busy: false,
next_check: now + Duration::from_secs(first),
ready_since: None,
install_asked: false,
pending: None,
started: now,
healthy_marked: false,
failed_versions: Vec::new(),
min_supported: String::new(),
apply_launched: None,
deferred_until: None,
};
u.failed_versions = std::fs::read_to_string(u.failed_path()).ok().and_then(|t| serde_json::from_str::<Vec<String>>(&t).ok()).unwrap_or_default();
if let Ok(text) = std::fs::read_to_string(u.dir.join("manifest.json")) {
if let Ok(m) = manifest::parse(&text) {
u.min_supported = m.min_supported_version.clone();
}
}
{
let mut st = shared.state.lock().unwrap();
st.update.status = if u.url.is_empty() { "off".into() } else { "unknown".into() };
st.settings.auto_update = auto;
}
u.settle_previous(shared);
u.publish(shared);
u
}
fn failed_path(&self) -> PathBuf {
self.app_dir.join("failed-versions.json")
}
fn remember_failed(&mut self, shared: &Arc<Shared>, ver: &str) {
if ver.is_empty() || self.failed_versions.iter().any(|v| v == ver) {
return;
}
self.failed_versions.push(ver.to_string());
let _ = std::fs::write(self.failed_path(), serde_json::to_string(&self.failed_versions).unwrap_or_default());
shared.log(&format!("update: {ver} is marked failed; it will not be applied by itself again (Install now still can)"));
}
/// On start: did we just update (or fail to)? Reports it, counts this start, and asks for a rollback when the
/// new version keeps dying before it is healthy.
fn settle_previous(&mut self, shared: &Arc<Shared>) {
let pending = ota::read_pending(&self.app_dir);
if let Some(r) = ota::read_result(&self.app_dir) {
let _ = std::fs::remove_file(ota::result_path(&self.app_dir));
if !r.ok && r.deferred {
shared.log(&format!("OTA: the update to {} was deferred before this start ({}); it tries again", r.version, r.error));
let _ = std::fs::remove_file(ota::pending_path(&self.app_dir));
} else if !r.ok {
{
let mut st = shared.state.lock().unwrap();
st.update.error = r.error.clone();
st.update.status = "error".into();
if r.rolled_back {
st.update.rolled_back = format!("{}: {}", r.version, r.error);
}
}
shared.event("error", &format!("update to {} failed: {}", r.version, r.error));
let _ = std::fs::remove_file(ota::pending_path(&self.app_dir));
self.remember_failed(shared, &r.version);
return;
}
}
let Some(mut p) = pending else { return };
if p.to == self.current {
// we are the new version
p.starts += 1;
ota::write_pending(&self.app_dir, &p);
if p.starts == 1 {
shared.event("ok", &format!("updated to Igneum Wallet {} from {}", p.to, p.from));
shared.state.lock().unwrap().update.updated_from = p.from.clone();
} else {
shared.log(&format!("start {} of {} since the update from {}; healthy after {} s", p.starts, p.to, p.from, ota::HEALTHY_AFTER_S));
}
self.pending = Some(p);
} else if p.from == self.current {
// the old version runs again: the helper restored it, or the installer never ran
shared.event("error", &format!("the update to {} did not take; still on {}", p.to, p.from));
let _ = std::fs::remove_file(ota::pending_path(&self.app_dir));
self.remember_failed(shared, &p.to);
} else {
let _ = std::fs::remove_file(ota::pending_path(&self.app_dir));
}
}
/// True when this (new) version has died twice before reaching HEALTHY_AFTER_S: the engine rolls back and exits.
pub fn needs_rollback(&self) -> bool {
self.pending.as_ref().map(|p| p.starts >= 3).unwrap_or(false)
}
// ---- state for the window ------------------------------------------------------------------------------------
fn publish(&self, shared: &Arc<Shared>) {
let mut st = shared.state.lock().unwrap();
let u = &mut st.update;
u.auto = self.auto;
if let Some(m) = &self.manifest {
u.version = m.version.clone();
u.notes = m.notes.clone();
u.unsupported = manifest::unsupported(m, &self.current);
u.min_supported = m.min_supported_version.clone();
}
if let Some(e) = &self.entry {
u.url = e.url.clone();
u.size = e.size;
}
u.available = self.entry.is_some();
u.downloaded = self.file.is_some();
u.ready = self.staged.is_some();
u.file = self.file.as_ref().map(|p| p.display().to_string()).unwrap_or_default();
if u.status != "applying" && u.status != "manual" && u.status != "error" && u.status != "deferred" && u.status != "off" {
u.status = if self.staged.is_some() {
"ready".into()
} else if self.file.is_some() {
"staging".into()
} else if self.entry.is_some() {
if self.busy { "downloading".into() } else { "available".into() }
} else if self.manifest.is_some() {
"current".into()
} else if u.status.is_empty() {
"unknown".into()
} else {
u.status.clone()
};
}
}
fn set_error(&mut self, shared: &Arc<Shared>, e: &str) {
shared.log(&format!("update: {e}"));
let mut st = shared.state.lock().unwrap();
st.update.error = e.to_string();
st.update.status = "error".into();
st.update.applying = false;
st.update.wait = String::new();
}
fn clear_error(&self, shared: &Arc<Shared>) {
let mut st = shared.state.lock().unwrap();
st.update.error = String::new();
if st.update.status == "error" {
st.update.status = "unknown".into();
}
}
pub fn set_auto(&mut self, shared: &Arc<Shared>, on: bool) {
self.auto = on;
{
let mut s = shared.settings.lock().unwrap();
s.auto_update = on;
s.save(&shared.paths.settings);
}
shared.state.lock().unwrap().settings.auto_update = on;
shared.event("info", if on { "updates install by themselves when nothing is being sent" } else { "updates download but wait for Install now" });
self.publish(shared);
}
// ---- the tick ------------------------------------------------------------------------------------------------
pub fn tick(&mut self, shared: &Arc<Shared>, ctx: &Ctx) -> Option<Action> {
let now = Instant::now();
// the new version is healthy once it has run this long: the update is complete, the leftovers can go
if !self.healthy_marked && self.pending.is_some() && now.duration_since(self.started) >= Duration::from_secs(ota::HEALTHY_AFTER_S) {
self.healthy_marked = true;
let p = self.pending.take().unwrap();
let _ = std::fs::remove_file(ota::pending_path(&self.app_dir));
let _ = std::fs::remove_file(ota::result_path(&self.app_dir)); // the helper's "ok" lands after this engine started
shared.log(&format!("update to {} complete (from {}); keeping the previous version for a rollback", p.to, p.from));
self.tidy();
}
if now >= self.next_check && !self.busy && self.staged.is_none() {
self.start_check(shared);
}
if self.busy {
if let (Some(e), None) = (&self.entry, &self.file) {
let mut st = shared.state.lock().unwrap();
if st.update.status == "downloading" {
st.update.progress = fetch::progress(e, &self.dir);
}
}
return None;
}
let urgent = self.urgent();
{
let mut st = shared.state.lock().unwrap();
st.update.urgent = urgent;
st.update.urgent_text = if urgent {
format!("Igneum Wallet {} is no longer supported; the network needs {} or newer.", self.current, self.min_supported)
} else {
String::new()
};
}
if self.staged.is_none() {
return None;
}
// Windows: the installer was started with the engine still running; it stops us when it may run. Until then
// watch for the helper's verdict (an unanswered administrator prompt).
if let Some(t) = self.apply_launched {
match ota::read_result(&self.app_dir) {
Some(r) if !r.ok => {
let _ = std::fs::remove_file(ota::result_path(&self.app_dir));
self.defer(shared, &r.error);
}
Some(_) => {} // the installer is in: it stops this engine any moment now
None if now.duration_since(t) >= Duration::from_secs(15 * 60) => self.defer(shared, "no answer from the installer in 15 minutes"),
None => {}
}
return None;
}
if let Some(u) = self.deferred_until {
if now < u && !self.install_asked {
return None;
}
self.deferred_until = None;
shared.state.lock().unwrap().update.status = "ready".into();
}
let v = self.version();
let failed_before = self.failed_versions.iter().any(|f| f == &v);
match safe_to_apply(ctx, self.auto, self.install_asked, urgent, failed_before) {
Ok(()) => Some(Action::Apply),
Err(why) => {
shared.state.lock().unwrap().update.wait = why;
None
}
}
}
/// Windows: the installer could not run (the administrator prompt was declined, timed out, or nobody was there).
fn defer(&mut self, shared: &Arc<Shared>, err: &str) {
self.apply_launched = None;
self.install_asked = false;
self.deferred_until = Some(Instant::now() + Duration::from_secs(6 * 3600));
let _ = std::fs::remove_file(ota::pending_path(&self.app_dir));
let v = self.version();
{
let mut st = shared.state.lock().unwrap();
st.update.applying = false;
st.update.status = "deferred".into();
st.update.wait = "waits for the next time someone is at this PC (Windows asks for permission)".into();
}
shared.event("info", &format!("OTA: administrator approval not given for Igneum Wallet {v} ({err}); the update waits for the next time someone is at this PC"));
}
fn urgent(&self) -> bool {
match &self.manifest {
Some(m) => self.entry.is_some() && manifest::unsupported(m, &self.current),
None => false,
}
}
/// After a completed update: the downloads of older versions go; the installer of the version now running stays
/// on Windows (the rollback target of the next update); a failed bundle from an earlier rollback goes on macOS.
fn tidy(&self) {
if let Ok(rd) = std::fs::read_dir(&self.dir) {
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
let keep = cfg!(windows) && name.contains(&self.current) && name.ends_with(".exe");
if !keep && name != "manifest.json" && name != "manifest.json.sig" {
let _ = std::fs::remove_file(e.path());
}
}
}
if let Some(b) = igneum_common::platform::bundle_path() {
let failed = PathBuf::from(format!("{}.failed", b.display()));
if failed.exists() {
let _ = std::fs::remove_dir_all(&failed);
}
}
}
// ---- check ---------------------------------------------------------------------------------------------------
pub fn check_now(&mut self, shared: &Arc<Shared>) {
if self.busy {
return;
}
self.clear_error(shared);
self.start_check(shared);
}
fn start_check(&mut self, shared: &Arc<Shared>) {
let every = std::env::var("IGNEUM_WALLET_UPDATE_CHECK_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(CHECK_EVERY_S);
self.next_check = Instant::now() + Duration::from_secs(every);
if self.url.is_empty() {
let mut st = shared.state.lock().unwrap();
st.update.status = "off".into();
st.update.checked_at = igneum_common::platform::unix_now_f();
return;
}
self.busy = true;
shared.state.lock().unwrap().update.status = "checking".into();
let url = self.url.clone();
let dir = self.dir.clone();
let shared2 = shared.clone();
std::thread::spawn(move || {
let r = fetch::fetch_manifest(&url, &dir);
shared2.send(Cmd::Ota(Event::Checked(r)));
});
}
pub fn event(&mut self, shared: &Arc<Shared>, ev: Event) {
self.busy = false;
match ev {
Event::Checked(r) => {
shared.state.lock().unwrap().update.checked_at = igneum_common::platform::unix_now_f();
match r {
Err(e) => {
self.next_check = Instant::now() + Duration::from_secs(RETRY_AFTER_ERROR_S);
if self.manifest.is_none() {
self.set_error(shared, &e);
} else {
shared.log(&format!("update check: {e}; keeping the last manifest"));
}
}
Ok(m) => {
self.clear_error(shared);
let entry = match plan(&m, &self.current) {
Plan::Update(e) => Some(e),
Plan::NoBuild(v) => {
shared.log(&format!("update check: {v} is published but has no {} build yet", manifest::platform_name()));
None
}
Plan::Current => {
shared.log(&format!("update check: {} is current (manifest {})", self.current, m.version));
None
}
};
let changed = self.entry != entry;
if entry.is_none() {
self.entry = None;
self.file = None;
self.staged = None;
self.ready_since = None;
}
self.manifest = Some(m.clone());
self.min_supported = m.min_supported_version.clone();
if let Some(e) = entry {
if changed {
self.file = None;
self.staged = None;
self.ready_since = None;
shared.event("info", &format!("Igneum Wallet {} is available: downloading ({} MB){}", m.version, e.size / 1_000_000, if m.notes.is_empty() { String::new() } else { format!(". {}", m.notes) }));
}
self.entry = Some(e);
if self.staged.is_none() {
self.start_download(shared);
}
}
}
}
}
Event::Downloaded(r) => match r {
Err(e) => {
self.set_error(shared, &format!("download failed: {e}"));
self.next_check = Instant::now() + Duration::from_secs(RETRY_AFTER_ERROR_S);
}
Ok(p) => {
self.clear_error(shared);
shared.log(&format!("update: {} downloaded and verified", p.display()));
self.file = Some(p.clone());
self.publish(shared);
self.start_stage(shared, p);
}
},
Event::Staged(r) => match r {
Err(e) if e.starts_with("manual:") => {
let why = e.trim_start_matches("manual:").trim().to_string();
{
let mut st = shared.state.lock().unwrap();
st.update.status = "manual".into();
st.update.wait = why.clone();
}
shared.event("info", &format!("update downloaded; {why}"));
}
Err(e) => {
self.set_error(shared, &format!("could not prepare the update: {e}"));
self.file = None;
self.next_check = Instant::now() + Duration::from_secs(RETRY_AFTER_ERROR_S);
}
Ok(p) => {
self.clear_error(shared);
#[cfg(target_os = "macos")]
{
self.staged_digest = manifest::digest_dir(&p).unwrap_or_default();
shared.log(&format!("update: staged bundle digest {}", self.staged_digest));
}
self.staged = Some(p);
self.ready_since = Some(Instant::now());
let v = self.version();
{
let mut st = shared.state.lock().unwrap();
st.update.wait = if self.auto { "installs as soon as nothing is being sent".into() } else { "waiting for Install now".into() };
st.update.progress = 1.0;
}
shared.event("ok", &format!("Igneum Wallet {v} is ready; {}", if self.auto { "it installs as soon as nothing is being sent" } else { "automatic updates are off, so it waits for Install now" }));
}
},
}
self.publish(shared);
}
fn start_download(&mut self, shared: &Arc<Shared>) {
let Some(e) = self.entry.clone() else { return };
self.busy = true;
{
let mut st = shared.state.lock().unwrap();
st.update.status = "downloading".into();
st.update.progress = 0.0;
}
let dir = self.dir.clone();
let shared2 = shared.clone();
std::thread::spawn(move || {
let r = fetch::download(&e, &dir);
shared2.send(Cmd::Ota(Event::Downloaded(r)));
});
}
fn start_stage(&mut self, shared: &Arc<Shared>, file: PathBuf) {
let Some(e) = self.entry.clone() else { return };
let version = self.version();
self.busy = true;
shared.state.lock().unwrap().update.status = "staging".into();
let dir = self.dir.clone();
let shared2 = shared.clone();
std::thread::spawn(move || {
let r = ota::stage(crate::engine::APP, &e, &file, &dir, &version);
shared2.send(Cmd::Ota(Event::Staged(r)));
});
}
// ---- the user's buttons ----------------------------------------------------------------------------------------
/// Install now: a ready update applies at once; a downloaded one as soon as it is staged; else a check runs.
pub fn install_now(&mut self, shared: &Arc<Shared>) {
self.install_asked = true;
self.deferred_until = None;
if self.apply_launched.is_some() {
return; // the installer is already up (its prompt may be waiting on the screen)
}
if self.staged.is_some() {
shared.state.lock().unwrap().update.wait = "installing now".into();
return;
}
if self.busy {
return;
}
self.clear_error(shared);
if let Some(f) = self.file.clone() {
self.start_stage(shared, f);
} else if self.entry.is_some() {
self.start_download(shared);
} else {
self.start_check(shared);
}
}
/// The manual path: open the downloaded disk image or installer for the user.
pub fn open_file(&self) -> Result<(), String> {
let f = self.file.as_ref().ok_or("nothing downloaded yet")?;
#[cfg(target_os = "macos")]
let r = std::process::Command::new(igneum_common::platform::tool("open")).arg(f).spawn();
#[cfg(windows)]
let r = igneum_common::platform::quiet(&mut std::process::Command::new(igneum_common::platform::tool("cmd"))).args(["/c", "start", "", &f.display().to_string()]).spawn();
#[cfg(not(any(target_os = "macos", windows)))]
let r = std::process::Command::new("xdg-open").arg(f).spawn();
r.map(|_| ()).map_err(|e| e.to_string())
}
// ---- apply ---------------------------------------------------------------------------------------------------
pub fn version(&self) -> String {
self.manifest.as_ref().map(|m| m.version.clone()).unwrap_or_default()
}
/// Re-verifies the download and the staged bundle, writes update-pending.json, starts the helper. macOS: the
/// engine exits right after (Launch::QuitNow). Windows: the installer runs first (Launch::InstallerRunning).
pub fn launch_apply(&mut self, shared: &Arc<Shared>, host_pid: u32) -> Result<Launch, String> {
let staged = self.staged.clone().ok_or("no update is ready")?;
let to = self.version();
let entry = self.entry.clone().ok_or("no manifest entry")?;
if let Some(f) = &self.file {
let sum = manifest::sha256_file(f).map_err(|e| format!("cannot hash the download: {e}"))?;
if sum != entry.sha256 {
self.staged = None;
self.file = None;
return Err("the downloaded file no longer matches the manifest's sha256; it is discarded".into());
}
}
#[cfg(target_os = "macos")]
{
let d = manifest::digest_dir(&staged).map_err(|e| format!("cannot digest the staged app: {e}"))?;
if d != self.staged_digest || d.is_empty() {
let _ = std::fs::remove_dir_all(&staged);
self.staged = None;
return Err("the staged app changed since it was verified; it is discarded".into());
}
}
let previous_installer = if cfg!(windows) { self.dir.join(ota::installer_name_for(crate::engine::APP, &self.current)).to_string_lossy().into_owned() } else { String::new() };
let previous_installer = if Path::new(&previous_installer).is_file() { previous_installer } else { String::new() };
let env_file = ota::write_env_file(&self.app_dir, ENV_PREFIXES);
let a = ota::Apply { app: crate::engine::APP, app_dir: &self.app_dir, current: &self.current, version: &to, staged: &staged, staged_digest: &self.staged_digest, sha256: &entry.sha256, host_pid, env_file, previous_installer };
shared.log(&format!("update: starting the helper for {to} (host pid {host_pid}, staged {})", staged.display()));
let launched = ota::launch_apply(&a)?;
if launched == Launch::InstallerRunning {
self.apply_launched = Some(Instant::now());
}
Ok(launched)
}
/// The new version failed to start twice: restore the previous one through the helper and exit.
pub fn launch_rollback(&mut self, shared: &Arc<Shared>, host_pid: u32) -> Result<(), String> {
let p = self.pending.clone().ok_or("no update pending")?;
// never below the network's minimum; this version stays and is marked failed so it is not re-applied
if !self.min_supported.is_empty() && manifest::newer(&self.min_supported, &p.from) {
let _ = std::fs::remove_file(ota::pending_path(&self.app_dir));
self.pending = None;
return Err(format!("not rolling back to {}: the network needs {} or newer; staying on {}", p.from, self.min_supported, p.to));
}
self.remember_failed(shared, &p.to);
shared.event("error", &format!("Igneum Wallet {} did not stay up twice; restoring {}", p.to, p.from));
let env_file = ota::write_env_file(&self.app_dir, ENV_PREFIXES);
ota::launch_rollback(crate::engine::APP, &self.app_dir, &p, host_pid, env_file)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// What packaging/ota/publish-manifest.sh --product wallet writes (canonical JSON, sorted keys, no whitespace).
const WALLET_MANIFEST: &str = r#"{"channel":"devnet","consensus":{"activation_height":null,"deadline_note":""},"min_supported_version":"","notes":"coin on the home screen, updates install by themselves","platforms":{"mac":{"kind":"dmg","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":19479807,"url":"https://dl.igneum.network/dl/t/Igneum-Wallet-0.1.1.dmg"}},"published_at":"2026-10-05T09:00:00Z","version":"0.1.1"}"#;
#[test]
fn the_wallet_manifest_parses() {
let m = manifest::parse(WALLET_MANIFEST).unwrap();
assert_eq!(m.version, "0.1.1");
assert_eq!(m.channel, "devnet");
assert_eq!(m.activation_height, None);
assert!(m.min_supported_version.is_empty());
let mac = m.mac.as_ref().unwrap();
assert_eq!(mac.kind, "dmg");
assert_eq!(mac.size, 19479807);
assert!(mac.url.ends_with("/Igneum-Wallet-0.1.1.dmg"));
assert!(m.windows.is_none());
assert!(m.notes.contains("coin"));
}
#[test]
fn versions_the_wallet_will_see() {
assert!(manifest::newer("0.1.1", "0.1.0"));
assert!(manifest::newer("0.1.10", "0.1.9"));
assert!(manifest::newer("0.2.0", "0.1.11"));
assert!(!manifest::newer("0.1.0", "0.1.0"));
assert!(!manifest::newer("0.1.0", "0.1.1"));
assert!(manifest::newer("0.1.1", "0.1.1-rc1"));
assert!(!manifest::newer("latest", "0.1.0"));
assert!(!manifest::newer("", "0.1.0"));
}
#[test]
fn the_plan_follows_the_version_and_the_platform() {
let m = manifest::parse(WALLET_MANIFEST).unwrap();
match plan(&m, "0.1.0") {
Plan::Update(e) if manifest::platform_name() == "mac" => assert_eq!(e.size, 19479807),
Plan::NoBuild(v) if manifest::platform_name() != "mac" => assert_eq!(v, "0.1.1"),
other => panic!("unexpected plan {other:?}"),
}
assert_eq!(plan(&m, "0.1.1"), Plan::Current);
assert_eq!(plan(&m, "0.2.0"), Plan::Current);
// a newer version without any platform entry: nothing to download
let none = manifest::parse(r#"{"version":"0.1.2","platforms":{}}"#).unwrap();
assert_eq!(plan(&none, "0.1.1"), Plan::NoBuild("0.1.2".into()));
// a tampered manifest fails before any plan is made
assert!(manifest::verify_and_parse(WALLET_MANIFEST.as_bytes(), &"00".repeat(64), manifest::OTA_PUBLIC_KEY_HEX).is_err());
}
#[test]
fn safe_moments() {
let quiet = Ctx { send_in_flight: false, creating: false };
let sending = Ctx { send_in_flight: true, creating: false };
let creating = Ctx { send_in_flight: false, creating: true };
assert!(safe_to_apply(&quiet, true, false, false, false).is_ok());
assert_eq!(safe_to_apply(&sending, true, false, false, false).unwrap_err(), "a send is in flight; installing after it");
assert!(safe_to_apply(&creating, true, false, false, false).unwrap_err().contains("being created"));
// automatic updates off: only Install now (or an unsupported version) applies
assert!(safe_to_apply(&quiet, false, false, false, false).unwrap_err().contains("Install now"));
assert!(safe_to_apply(&quiet, false, true, false, false).is_ok());
assert!(safe_to_apply(&quiet, false, false, true, false).is_ok());
// Install now and an unsupported version beat a send in flight
assert!(safe_to_apply(&sending, true, true, false, false).is_ok());
assert!(safe_to_apply(&sending, true, false, true, false).is_ok());
// a version that failed before waits for Install now
assert!(safe_to_apply(&quiet, true, false, false, true).unwrap_err().contains("failed"));
assert!(safe_to_apply(&quiet, true, true, false, true).is_ok());
}
}

View file

@ -1,153 +0,0 @@
//! The encrypted key file: `<data root>/wallet/vault.json`. The secret (the 32-byte private key and, when the wallet
//! was made or imported from words, the 24-word phrase) is sealed with XChaCha20-Poly1305 under a key derived from
//! the password with Argon2id (64 MiB, 3 passes). The password is never written anywhere; the plaintext only ever
//! exists in the engine's memory and is zeroed when the wallet locks.
use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
use serde::{Deserialize, Serialize};
use std::path::Path;
use zeroize::{Zeroize, ZeroizeOnDrop};
pub const M_KIB: u32 = 65_536;
pub const T_COST: u32 = 3;
pub const P_COST: u32 = 1;
/// What the vault seals. Zeroed on drop.
#[derive(Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
pub struct Secret {
/// 0x + 64 hex, secp256k1
pub private_key: String,
/// the 24 words, space separated; None for a raw key import
pub mnemonic: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Kdf {
pub algo: String,
pub m_kib: u32,
pub t: u32,
pub p: u32,
pub salt: String,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Vault {
pub version: u32,
pub address: String, // 0x + 40 lower-case hex, in the clear: the window shows it while locked
pub source: String, // created | seed | key | miner
pub created: u64,
pub kdf: Kdf,
pub cipher: String,
pub nonce: String,
pub ciphertext: String,
/// The one-time backup sheet (the words or the key) was confirmed.
#[serde(default)]
pub backed_up: bool,
}
fn derive(password: &str, salt: &[u8], kdf: &Kdf) -> Result<[u8; 32], String> {
let params = Params::new(kdf.m_kib, kdf.t, kdf.p, Some(32)).map_err(|e| e.to_string())?;
let a = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut key = [0u8; 32];
a.hash_password_into(password.as_bytes(), salt, &mut key).map_err(|e| e.to_string())?;
Ok(key)
}
pub fn seal(secret: &Secret, password: &str, address: &str, source: &str) -> Result<Vault, String> {
if password.len() < 8 {
return Err("the password needs at least 8 characters".into());
}
let mut salt = [0u8; 16];
let mut nonce = [0u8; 24];
getrandom::getrandom(&mut salt).map_err(|e| e.to_string())?;
getrandom::getrandom(&mut nonce).map_err(|e| e.to_string())?;
let kdf = Kdf { algo: "argon2id".into(), m_kib: M_KIB, t: T_COST, p: P_COST, salt: igneum_common::keys::hex(&salt) };
let mut key = derive(password, &salt, &kdf)?;
let cipher = XChaCha20Poly1305::new(Key::from_slice(&key));
let mut plain = serde_json::to_vec(secret).map_err(|e| e.to_string())?;
let ct = cipher.encrypt(XNonce::from_slice(&nonce), plain.as_ref()).map_err(|_| "encryption failed".to_string());
plain.zeroize();
key.zeroize();
let ct = ct?;
Ok(Vault {
version: 1,
address: address.to_ascii_lowercase(),
source: source.into(),
created: igneum_common::platform::unix_now(),
kdf,
cipher: "xchacha20poly1305".into(),
nonce: igneum_common::keys::hex(&nonce),
ciphertext: igneum_common::keys::hex(&ct),
backed_up: false,
})
}
pub fn open(v: &Vault, password: &str) -> Result<Secret, String> {
if v.kdf.algo != "argon2id" || v.cipher != "xchacha20poly1305" {
return Err("this vault was written by a newer wallet".into());
}
let salt = igneum_common::keys::unhex(&v.kdf.salt).ok_or("vault salt is not hex")?;
let nonce = igneum_common::keys::unhex(&v.nonce).ok_or("vault nonce is not hex")?;
let ct = igneum_common::keys::unhex(&v.ciphertext).ok_or("vault ciphertext is not hex")?;
let mut key = derive(password, &salt, &v.kdf)?;
let cipher = XChaCha20Poly1305::new(Key::from_slice(&key));
let plain = cipher.decrypt(XNonce::from_slice(&nonce), ct.as_ref());
key.zeroize();
let mut plain = plain.map_err(|_| "wrong password".to_string())?;
let s: Result<Secret, _> = serde_json::from_slice(&plain);
plain.zeroize();
s.map_err(|_| "the vault did not decode".to_string())
}
pub fn save(path: &Path, v: &Vault) -> Result<(), String> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
igneum_common::platform::lock_permissions(dir, true);
}
let text = serde_json::to_string_pretty(v).map_err(|e| e.to_string())?;
let tmp = path.with_extension("json.new");
std::fs::write(&tmp, text).map_err(|e| e.to_string())?;
igneum_common::platform::lock_permissions(&tmp, false);
std::fs::rename(&tmp, path).map_err(|e| e.to_string())?;
igneum_common::platform::lock_permissions(path, false);
Ok(())
}
pub fn load(path: &Path) -> Option<Vault> {
let text = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&text).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_and_wrong_password() {
let s = Secret { private_key: "0x0000000000000000000000000000000000000000000000000000000000000001".into(), mnemonic: Some("abandon abandon about".into()) }; // no-secrets-ok: the test's throwaway key (0x..01), not a secret
let v = seal(&s, "correct horse", "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf", "created").unwrap();
assert_eq!(v.kdf.algo, "argon2id");
let back = open(&v, "correct horse").unwrap();
assert_eq!(back.private_key, s.private_key);
assert_eq!(back.mnemonic, s.mnemonic);
assert_eq!(open(&v, "correct horsf").err().unwrap(), "wrong password");
// a flipped ciphertext byte fails the tag
let mut bad = v.clone();
let mut ct = igneum_common::keys::unhex(&bad.ciphertext).unwrap();
ct[3] ^= 1;
bad.ciphertext = igneum_common::keys::hex(&ct);
assert!(open(&bad, "correct horse").is_err());
// the JSON round trip keeps the fields
let text = serde_json::to_string(&v).unwrap();
let v2: Vault = serde_json::from_str(&text).unwrap();
assert_eq!(open(&v2, "correct horse").unwrap().private_key, s.private_key);
}
#[test]
fn short_password_refused() {
let s = Secret { private_key: "0x01".into(), mnemonic: None };
assert!(seal(&s, "short", "0x", "key").is_err());
}
}

View file

@ -1,489 +0,0 @@
/* Igneum Wallet window (wallet-ui-3, on the miner's system: app/igneum-app/ui/app.css, miner-ui-3). The site's tokens:
obsidian, graphite, ember, molten, bone, ash; Unbounded for the page title, the balance and the row's amount; IBM Plex
Sans for words; IBM Plex Mono for labels, units, addresses and small numbers. Fonts ship in the binary. One type scale
(--t-*), one spacing scale (--s-*), one accent (ember). Dark by default, light under prefers-color-scheme or
[data-theme=light]. Layout: a rail on the left (five sections), a thin top bar, the status strip under it, the page in
the middle. The window lays out from 900 x 620 (the hosts' minimum); under 720 px the rail becomes a bottom tab bar.
Nothing here is newer than 2022 CSS (the WebView2 host). */
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/IBMPlexMono-400.woff2) format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(fonts/IBMPlexMono-500.woff2) format('woff2')}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/IBMPlexSans-400.woff2) format('woff2')}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(fonts/IBMPlexSans-500.woff2) format('woff2')}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(fonts/IBMPlexSans-600.woff2) format('woff2')}
@font-face{font-family:'Unbounded';font-style:normal;font-weight:500;font-display:swap;src:url(fonts/Unbounded-500.woff2) format('woff2')}
@font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/Unbounded-700.woff2) format('woff2')}
@font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(fonts/Unbounded-900.woff2) format('woff2')}
:root{
/* colour, dark */
--obsidian:#0C0C0E;--graphite:#16161A;--row:#111114;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;
--ember-12:rgba(242,84,27,.12);--ember-40:rgba(242,84,27,.4);--molten-10:rgba(255,179,92,.1);--molten-40:rgba(255,179,92,.4);--rail-bg:#111114;--hover:rgba(255,255,255,.04);--top-bg:rgba(12,12,14,.86);--shadow:rgba(0,0,0,.6);--scrim:rgba(12,12,14,.72);--qr-bg:#F4F1EC;
/* type scale */
--t-xs:11px;--t-sm:12px;--t-base:13px;--t-md:14px;--t-lg:15px;--t-xl:17px;--t-num:24px;--t-hero:34px;--t-h2:28px;--t-h1:44px;
/* spacing scale */
--s-1:4px;--s-2:8px;--s-3:12px;--s-4:16px;--s-5:24px;--s-6:32px;
--gutter:var(--s-6);--card-pad:var(--s-5);--card-r:16px;--row-r:12px;--gap:var(--s-4);
--sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--head:'Unbounded',sans-serif;
--top:60px;--bottom:0px;--rail:196px}
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){
--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#E04A14;--ember-hi:#F2541B;--molten:#B8731F;--bone:#16161A;--ash:#6B6B70;--ink-2:#3C3C42;--ember-ink:#FFFFFF;
--ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--qr-bg:#FFFFFF}}
:root[data-theme="light"]{
--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#E04A14;--ember-hi:#F2541B;--molten:#B8731F;--bone:#16161A;--ash:#6B6B70;--ink-2:#3C3C42;--ember-ink:#FFFFFF;
--ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--qr-bg:#FFFFFF}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:var(--t-md);line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none;font-variant-numeric:tabular-nums}
.mono,code,pre{font-family:var(--mono);font-variant-numeric:tabular-nums}
.dim{color:var(--ash)}
h1,h2,h3{font-family:var(--head);margin:0;line-height:1.1;text-wrap:balance}
h1{font-weight:900;font-size:var(--t-h1);letter-spacing:-.01em}
h2{font-weight:700;font-size:var(--t-h2)}
h3{font-weight:700;font-size:var(--t-xl)}
p{margin:0}
a{color:inherit}
button{font:inherit;color:inherit}
input,textarea,select{font-variant-numeric:tabular-nums}
.eyebrow{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.16em;text-transform:uppercase;color:var(--ash);display:inline-flex;align-items:center;gap:var(--s-2);white-space:nowrap}
.eyebrow.ember{color:var(--ember)}
[hidden]{display:none !important}
::selection{background:rgba(242,84,27,.45)}
/* buttons */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:var(--s-2);min-height:40px;padding:8px 18px;border-radius:10px;font-weight:600;font-size:var(--t-md);border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;transition:transform .15s ease,background .15s ease,border-color .15s ease,opacity .15s ease,color .15s ease;white-space:nowrap}
.btn:hover{transform:translateY(-1px);border-color:var(--ash)}
.btn:active{transform:none}
.btn:disabled{opacity:.4;cursor:default;transform:none}
.btn.primary{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)}
.btn.primary:hover{background:var(--ember-hi);border-color:var(--ember-hi)}
.btn.big{min-height:52px;padding:12px 28px;font-size:var(--t-xl)}
.btn.small{min-height:34px;padding:6px 14px;font-size:var(--t-base);border-radius:8px}
.btn.tiny{min-height:26px;padding:2px 10px;font-size:var(--t-sm);border-radius:7px;font-family:var(--mono);font-weight:500}
.btn.ghost{border-color:transparent;color:var(--ink-2)}
.btn.ghost:hover{border-color:var(--line-2);color:var(--bone)}
.btn.danger:hover{color:var(--ember);border-color:var(--ember)}
.btn.fp svg{flex:0 0 auto}
:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
@media (prefers-reduced-motion:reduce){.btn{transition:none}}
/* the rail */
.rail{position:fixed;top:0;left:0;bottom:0;width:var(--rail);background:var(--rail-bg);border-right:1px solid var(--line);display:flex;flex-direction:column;z-index:22;padding:14px 12px 14px;-webkit-app-region:drag}
.rail button{-webkit-app-region:no-drag}
.rail-brand{display:flex;align-items:center;gap:10px;padding:6px 10px 18px;min-width:0}
body.mac .rail-brand{padding-top:30px}
.rail-brand .word{font-family:var(--head);font-weight:900;font-size:17px;letter-spacing:.06em}
.rail-nav{display:flex;flex-direction:column;gap:3px}
.nav{position:relative;display:flex;align-items:center;gap:12px;width:100%;min-height:42px;padding:8px 12px;border:1px solid transparent;border-radius:11px;background:transparent;color:var(--ink-2);font-weight:500;font-size:var(--t-md);text-align:left;cursor:pointer;transition:background .15s ease,color .15s ease,border-color .15s ease}
.nav svg{width:19px;height:19px;flex:0 0 19px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round;color:var(--ash);transition:color .15s ease}
.nav:hover{background:var(--hover);color:var(--bone)}
.nav:hover svg{color:var(--ink-2)}
.nav.on{background:var(--ember-12);border-color:var(--ember-40);color:var(--bone);font-weight:600}
.nav.on svg{color:var(--ember)}
.nav.on::before{content:"";position:absolute;left:-13px;top:10px;bottom:10px;width:3px;border-radius:0 3px 3px 0;background:var(--ember)}
.nav.small{min-height:36px;font-size:var(--t-base);color:var(--ash)}
.nav.small svg{width:16px;height:16px;flex-basis:16px}
.nav.danger:hover{color:var(--ember)}
.nav.danger:hover svg{color:var(--ember)}
.nav-dot{position:absolute;right:12px;top:50%;width:7px;height:7px;margin-top:-3px;border-radius:50%;background:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)}
.rail-foot{margin-top:auto;display:flex;flex-direction:column;gap:2px;padding-top:12px;border-top:1px solid var(--line)}
.rail-status{font-size:var(--t-xs);color:var(--ash);padding:0 12px 10px;line-height:1.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rail-status b{color:var(--ink-2);font-weight:500}
.rail-version{font-size:var(--t-xs);color:var(--ash);padding:8px 12px 0;letter-spacing:.06em}
/* top bar */
.top{position:fixed;top:0;left:0;right:0;height:var(--top);display:flex;align-items:center;justify-content:space-between;gap:var(--s-4);padding:0 var(--gutter);background:var(--top-bg);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--line);z-index:20;-webkit-app-region:drag}
.top button,.top .pill{-webkit-app-region:no-drag}
body.mac:not(.has-rail) .top{padding-left:92px}
body.has-rail .top{left:var(--rail)}
.brand{display:flex;align-items:center;gap:10px;min-width:0}
.brand .word{font-family:var(--head);font-weight:900;font-size:20px;letter-spacing:.06em}
.brand .miner{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.22em;color:var(--ash);margin-left:var(--s-1);padding-top:3px}
.page-title{display:flex;align-items:baseline;gap:12px;min-width:0}
.page-title h1{font-size:19px;font-weight:700;letter-spacing:0;white-space:nowrap}
.page-sub{font-size:var(--t-base);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.top-right{display:flex;align-items:center;gap:var(--s-3);flex:0 0 auto;min-width:0}
.narrow-only{display:none}
.pill{display:inline-flex;align-items:center;gap:var(--s-2);font-family:var(--mono);font-size:var(--t-sm);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:6px 12px 6px 10px;background:var(--graphite);white-space:nowrap;font-variant-numeric:tabular-nums;min-width:112px;justify-content:center}
.pill.on{color:var(--molten);border-color:var(--molten-40)}
.pill.warn{color:var(--ember)}
.dot{width:8px;height:8px;border-radius:50%;background:var(--ash);display:inline-block;flex:0 0 8px}
.on .dot,.dot.live{background:var(--molten);animation:pulse 2s ease-in-out infinite}
.warn .dot,.dot.bad{background:var(--ember);animation:none}
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,179,92,.5)}50%{box-shadow:0 0 0 7px rgba(255,179,92,0)}}
@media (prefers-reduced-motion:reduce){.on .dot,.dot.live{animation:none}}
/* the status strip under the top bar (the update line): takes no room while empty */
.notices{position:fixed;top:var(--top);left:0;right:0;z-index:19}
body.has-rail .notices{left:var(--rail)}
.notice{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2) var(--s-4);padding:8px calc(var(--gutter) - 6px) 8px var(--gutter);background:var(--molten-10);border-bottom:1px solid var(--molten-40);font-size:var(--t-base);line-height:1.4;color:var(--bone)}
.notice.urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600}
.notice-text{flex:1 1 320px;min-width:0}
.notice-actions{display:flex;align-items:center;gap:var(--s-2);flex:0 0 auto}
.notice-actions:empty{display:none}
.notice-close{flex:0 0 auto;width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--ash);font-size:20px;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}
.notice-close:hover{color:var(--bone);border-color:var(--line-2)}
.notice.urgent .notice-close{color:#fff}
.notice .prog{flex-basis:100%;height:3px;background:rgba(127,127,127,.2);border-radius:2px;overflow:hidden;margin-top:-3px}
.notice .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear}
/* a question in place of a dialog: the quit strip over the page, the inline asks inside a card */
.ask-wrap{position:fixed;left:0;right:0;bottom:0;z-index:36;display:flex;justify-content:center;padding:0 var(--s-4) var(--s-4);pointer-events:none}
body.has-rail .ask-wrap{left:var(--rail)}
.ask{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;background:var(--graphite);border:1px solid var(--ember-40);border-radius:14px;padding:12px 16px;box-shadow:0 14px 40px var(--shadow);pointer-events:auto;max-width:720px;animation:rise .2s ease}
.ask-text{flex:1 1 260px;font-size:var(--t-md);min-width:0;line-height:1.45}
.ask.inline{box-shadow:none;background:var(--ember-12);margin-top:var(--s-3);animation:none;max-width:none}
.ask-foot{flex-basis:100%;font-size:var(--t-sm);color:var(--ink-2);min-height:0}
.ask-foot:empty{display:none}
.ask .short-pw{width:200px;flex:0 0 200px;margin-top:0}
/* screens and pages */
main{position:absolute;top:var(--top);bottom:var(--bottom);left:0;right:0;overflow:auto;padding:0 var(--gutter);overscroll-behavior:contain;transition:top .15s ease}
@media (prefers-reduced-motion:reduce){main{transition:none}}
body.has-rail main{left:var(--rail)}
.screen{display:none;max-width:1080px;margin:0 auto;animation:rise .45s ease}
body[data-phase="welcome"] #screen-welcome,body[data-phase="create"] #screen-create,body[data-phase="import"] #screen-import,body[data-phase="unlock"] #screen-unlock,body[data-phase="home"] #screen-home{display:block}
@keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion:reduce){.screen,.page{animation:none}}
#screen-home{padding:22px 0 32px}
.page{display:flex;flex-direction:column;gap:var(--gap);animation:rise .3s ease}
/* welcome */
.hero{min-height:calc(100vh - var(--top));display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:var(--s-4);padding:var(--s-6) 0 48px}
.mark-wrap{position:relative;width:120px;height:120px;border-radius:26px;background:#0C0C0E;display:flex;align-items:center;justify-content:center;margin-bottom:6px;box-shadow:0 20px 50px rgba(242,84,27,.25)}
.mark-wrap::before{content:"";position:absolute;inset:-40px;border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.28) 0,rgba(242,84,27,0) 65%);animation:breathe 4s ease-in-out infinite;z-index:-1}
@keyframes breathe{0%,100%{opacity:.7;transform:scale(1)}50%{opacity:1;transform:scale(1.08)}}
@media (prefers-reduced-motion:reduce){.mark-wrap::before{animation:none}}
.lead{font-size:var(--t-xl);color:var(--ink-2);max-width:54ch}
.three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s-3);width:100%;max-width:860px;margin-top:10px;text-align:left}
.tile{background:var(--graphite);border:1px solid var(--line);border-radius:14px;padding:18px 20px;display:flex;flex-direction:column;gap:6px;min-width:0}
.tile .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.14em;color:var(--ember)}
.tile .t{font-family:var(--head);font-weight:700;font-size:var(--t-lg);line-height:1.25}
.tile .s{font-size:var(--t-base);color:var(--ash);line-height:1.45}
.cta{display:flex;flex-wrap:wrap;gap:var(--s-3);align-items:center;justify-content:center;margin-top:10px}
.seedline{font-size:var(--t-sm);color:var(--ash);letter-spacing:.04em}
/* steps (create, import) */
.step{max-width:720px;margin:0 auto;padding:56px 0 48px;display:flex;flex-direction:column;gap:var(--s-4)}
.step .sub{font-size:var(--t-xl);color:var(--ink-2);max-width:60ch}
.step .cta{justify-content:flex-start;margin-top:var(--s-2)}
.note{font-size:var(--t-base);color:var(--ash);line-height:1.5}
.note.small{font-size:var(--t-sm);word-break:break-all}
.help{font-size:var(--t-base);color:var(--ash);line-height:1.5;max-width:64ch}
.line-text{font-size:var(--t-md);color:var(--ink-2);line-height:1.5}
.line-text.ok{color:var(--molten)}
.line-text.bad{color:var(--ember)}
.err{font-size:var(--t-base);color:var(--ember);line-height:1.5}
.err:empty{display:none}
.top-gap{margin-top:var(--s-4)}
.indent{margin-left:52px}
.field{display:flex;flex-direction:column;gap:var(--s-2);margin-top:6px}
.field .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
.field input,.field textarea,.addr-input{font:inherit;color:var(--bone);background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px;min-height:42px;font-size:var(--t-md);user-select:text;-webkit-user-select:text}
.field textarea{font-family:var(--mono);font-size:var(--t-md);line-height:1.5;resize:vertical}
.field input.mono,.addr-input.mono{font-family:var(--mono)}
.field input:focus,.field textarea:focus,.addr-input:focus,.select:focus{outline:none;border-color:var(--ember)}
.addr-input{width:100%;min-width:0}
.row .addr-input{flex:1 1 180px;width:auto}
.box{display:flex;align-items:center;gap:10px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px;font-size:var(--t-base);word-break:break-all;user-select:text;-webkit-user-select:text}
.box span{flex:1;min-width:0}
.box.key{color:var(--molten)}
.words{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin:8px 0 0;padding:0;list-style:none;counter-reset:w}
.words li{counter-increment:w;background:var(--graphite);border:1px solid var(--line);border-radius:10px;padding:8px 10px;font-family:var(--mono);font-size:var(--t-md);display:flex;gap:8px;align-items:baseline;user-select:text;-webkit-user-select:text}
.words li::before{content:counter(w);color:var(--ash);font-size:var(--t-xs);min-width:18px;text-align:right}
.words.small{grid-template-columns:repeat(6,minmax(0,1fr))}
.words.small li{font-size:var(--t-sm);padding:5px 8px;background:var(--obsidian)}
.checks{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
.checks label{display:flex;flex-direction:column;gap:6px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash)}
.checks input{font:inherit;font-family:var(--mono);font-size:var(--t-lg);color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px;user-select:text;-webkit-user-select:text}
.checks input:focus{outline:none;border-color:var(--ember)}
/* cards, rows, disclosures, switches, the segmented control, the kv */
.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0}
.card-head{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);margin-bottom:var(--s-3);flex-wrap:wrap}
.row{display:flex;gap:10px;align-items:center;min-width:0}
.row.wrap{flex-wrap:wrap}
.lead-row{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--s-4)}
.lead-text{min-width:0;display:flex;flex-direction:column;gap:6px}
.lead-text h3{font-size:var(--t-xl)}
.disclose{display:flex;align-items:center;justify-content:space-between;gap:var(--s-3);width:100%;border:0;background:transparent;padding:0;cursor:pointer;text-align:left;color:var(--bone);font-size:var(--t-md);font-weight:500;min-height:32px}
.disclose .chev{color:var(--ash);flex:0 0 auto;margin-right:4px}
.disclose-right{display:flex;align-items:center;gap:var(--s-3);min-width:0}
.disclose-right .dim{font-size:var(--t-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;max-width:52ch}
.chev{width:9px;height:9px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg) translateY(-2px);transition:transform .15s ease;display:inline-block}
.open .chev,[aria-expanded="true"] .chev{transform:rotate(225deg) translateY(-2px)}
.card .disclose+.disclose,.card .details+.disclose,.card .srow+.disclose,.card .switch+.disclose,.card .err+.disclose{margin-top:var(--s-3);padding-top:var(--s-3);border-top:1px solid var(--line)}
.details{padding-top:var(--s-3);display:flex;flex-direction:column;gap:var(--s-3)}
.srow{display:flex;align-items:center;justify-content:space-between;gap:var(--s-4);padding:9px 0;min-width:0}
.srow+.srow,.switch+.srow,.srow+.switch{border-top:1px solid var(--line)}
.sw-text{min-width:0}
.sw-text b{font-weight:600}
.sw-text .dim{font-size:var(--t-base)}
.switch{display:flex;align-items:flex-start;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:9px 0;line-height:1.4}
.switch+.switch{border-top:1px solid var(--line)}
.switch input{position:absolute;opacity:0;width:0;height:0}
.switch .track{width:40px;height:22px;border-radius:999px;background:var(--line-2);position:relative;flex:0 0 40px;transition:background .15s ease;margin-top:1px}
.switch .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#F4F1EC;transition:transform .15s ease}
.switch input:checked+.track{background:var(--ember)}
.switch input:checked+.track::after{transform:translateX(18px)}
.switch input:focus-visible+.track{outline:2px solid var(--ember);outline-offset:3px}
.switch input:disabled+.track{opacity:.4}
.seg{display:inline-flex;background:var(--obsidian);border:1px solid var(--line);border-radius:10px;padding:3px;gap:2px}
.seg-b{border:0;background:transparent;color:var(--ash);font-size:var(--t-base);font-weight:500;padding:6px 14px;border-radius:8px;cursor:pointer;transition:background .15s ease,color .15s ease;white-space:nowrap}
.seg-b:hover{color:var(--bone)}
.seg-b.on{background:var(--graphite);color:var(--bone);box-shadow:0 1px 3px rgba(0,0,0,.25);font-weight:600}
.seg-b:disabled{opacity:.4;cursor:default}
.select{font:inherit;font-size:var(--t-md);color:var(--bone);background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:7px 12px;min-height:38px;cursor:pointer}
.kv{display:flex;flex-direction:column}
.kv>div{display:grid;grid-template-columns:140px auto 1fr;align-items:baseline;gap:var(--s-3);padding:7px 0;border-bottom:1px solid var(--line)}
.kv>div:last-child{border-bottom:0}
.kv .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.1em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
.kv .v{font-size:var(--t-md);font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;user-select:text;-webkit-user-select:text}
.kv .v.ok{color:var(--molten)}
.kv .v.warn{color:var(--ember)}
.kv .v.dim{color:var(--ash)}
.kv .m{font-size:var(--t-sm);color:var(--ash);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.card.adv{padding:0}
.card.adv summary{list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:var(--card-pad)}
.card.adv summary::-webkit-details-marker{display:none}
.card.adv summary::after{content:"+";font-family:var(--mono);color:var(--ash);font-size:18px;margin-left:auto}
.card.adv[open] summary::after{content:"\2212"}
.card.adv summary .eyebrow{margin-left:0}
.card.adv>:not(summary){margin-left:var(--card-pad);margin-right:var(--card-pad)}
.card.adv>:last-child{margin-bottom:var(--card-pad)}
.card.adv>.note+.note{margin-top:6px}
.empty{color:var(--ash);font-size:var(--t-base);padding:10px 0}
/* Home: the balance first, its money line, the address, the two buttons */
.hero-card{display:flex;flex-direction:column;gap:var(--s-3)}
.bal-row{display:flex;align-items:baseline;gap:var(--s-4);flex-wrap:wrap;min-width:0}
.bal{display:flex;align-items:baseline;gap:12px;min-width:0}
.bal .v{font-family:var(--head);font-weight:900;font-size:var(--t-h1);letter-spacing:-.01em;line-height:1;white-space:nowrap;user-select:text;-webkit-user-select:text}
.bal .unit{font-size:var(--t-md);color:var(--ash);letter-spacing:.12em}
.bal-line{color:var(--ash);font-size:var(--t-md)}
.bal-line:empty{display:none}
.money-line{font-size:var(--t-md);color:var(--ash);margin-top:-4px}
.money-line b{color:var(--bone);font-family:var(--head);font-weight:700;font-size:var(--t-num);margin-right:6px}
.addr-big{display:flex;align-items:center;gap:12px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:12px;padding:14px 16px;font-size:var(--t-lg);word-break:break-all;user-select:text;-webkit-user-select:text}
.addr-big span{flex:1;min-width:0;color:var(--molten)}
.actions{margin-top:var(--s-1)}
.actions .note{margin-left:var(--s-2)}
.node-line{display:inline-flex;align-items:center;gap:10px;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.node-card.bad .node-line{color:var(--ember)}
/* the history row: kind and who, the amount, the state word with its reason, the chevron; the details under */
.hist{display:flex;flex-direction:column;gap:var(--s-2)}
.hrow{border:1px solid var(--line);border-radius:var(--row-r);background:var(--row);min-width:0}
.hrow.open{border-color:var(--line-2)}
.hr-main{display:grid;grid-template-columns:26px minmax(160px,1.3fr) auto minmax(180px,1fr) 30px;align-items:center;gap:var(--s-4);padding:11px 14px;cursor:pointer}
.hr-main .glyph{width:26px;height:26px;border-radius:8px;border:1px solid var(--line-2);display:flex;align-items:center;justify-content:center;color:var(--ash);font-family:var(--mono);font-size:var(--t-sm)}
.hrow.in .glyph{color:var(--molten);border-color:var(--molten-40)}
.hr-who{min-width:0;display:flex;flex-direction:column;gap:2px}
.hr-who .kind{font-weight:600;font-size:var(--t-md);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.hr-who .sub{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.hr-amt{font-family:var(--head);font-weight:700;font-size:var(--t-lg);white-space:nowrap;text-align:right;justify-self:end}
.hrow.in .hr-amt{color:var(--molten)}
.hr-amt .unit{font-family:var(--mono);font-size:10px;color:var(--ash);font-weight:500;letter-spacing:.08em;margin-left:4px}
.hr-state{min-width:0;display:flex;flex-direction:column;gap:2px}
.hr-state .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap}
.hr-state .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block;flex:0 0 7px}
.hr-state .st.ink{color:var(--ink-2)}
.hr-state .st.ok{color:var(--molten)}
.hr-state .st.bad{color:var(--ember)}
.hr-state .why{font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.chev-btn{width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;color:var(--ash);justify-self:end}
.chev-btn:hover{border-color:var(--line-2);color:var(--bone)}
.hr-details{padding:12px 14px 14px 56px;border-top:1px solid var(--line);user-select:text;-webkit-user-select:text}
.hr-details .kv>div{grid-template-columns:110px 1fr}
.hr-details .kv .v{white-space:normal;word-break:break-all}
.hrow.sent-row .hr-main{cursor:default;grid-template-columns:26px minmax(160px,1.3fr) auto minmax(180px,1fr)}
/* Send: the fee row and the pending row */
.send-form{display:flex;flex-direction:column;gap:var(--s-3)}
.unit-word{font-size:var(--t-sm);color:var(--ash);letter-spacing:.1em}
.fee-row{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;padding:8px 0 0}
.fee-row .k{font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
.send-form[data-locked="1"] input{opacity:.6;pointer-events:none}
.sent{display:flex;flex-direction:column;gap:var(--s-3);margin-top:var(--s-3)}
.sent .cta{justify-content:flex-start;margin-top:0}
/* Receive: the QR beside its line */
.qr-row{display:flex;gap:var(--s-5);align-items:flex-start;flex-wrap:wrap;margin-top:var(--s-4)}
.qr{width:240px;height:240px;background:var(--qr-bg);border-radius:14px;padding:10px;flex:0 0 240px}
.qr svg{width:100%;height:100%;display:block}
.qr-row .help{flex:1 1 240px;padding-top:var(--s-2)}
/* the update card (update-card.js, app.js renderUpdateCard; the miner carries the same block) */
.upd-wrap{position:fixed;inset:0;z-index:35;background:var(--scrim);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px;animation:fade .25s ease}
@keyframes fade{from{opacity:0}to{opacity:1}}
.upd-card{position:relative;width:100%;max-width:500px;max-height:calc(100vh - 48px);overflow:auto;background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:34px 32px 28px;display:flex;flex-direction:column;align-items:center;text-align:center;gap:var(--s-2);box-shadow:0 30px 80px var(--shadow),0 0 0 1px rgba(242,84,27,.08);animation:rise .3s ease;outline:none}
.upd-card::before{content:"";position:absolute;left:50%;top:-80px;width:360px;height:260px;transform:translateX(-50%);border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.22) 0,rgba(242,84,27,0) 62%);pointer-events:none}
.upd-mark{position:relative;width:96px;height:96px;display:flex;align-items:center;justify-content:center;margin-bottom:var(--s-3)}
.upd-mark img{position:relative;display:block;filter:drop-shadow(0 6px 18px rgba(242,84,27,.35))}
.upd-ring{position:absolute;inset:0;transform:rotate(-90deg)}
.upd-ring .track{fill:none;stroke:var(--line-2);stroke-width:3}
.upd-ring .arc{fill:none;stroke:var(--ember);stroke-width:3;stroke-linecap:round;stroke-dasharray:276.5;stroke-dashoffset:276.5;transition:stroke-dashoffset .4s linear,stroke .3s ease}
.upd-mark.none .track{stroke:var(--line)}
.upd-mark.full .arc{stroke:var(--molten);stroke-dashoffset:0}
.upd-mark.busy .arc{stroke-dasharray:69 207.5;stroke-dashoffset:0;animation:spin 1.1s linear infinite;transform-origin:50% 50%}
@keyframes spin{to{transform:rotate(360deg)}}
.upd-mark.failed .arc{stroke:var(--ember);stroke-dashoffset:0;opacity:.55}
.upd-pct{position:absolute;left:50%;bottom:-11px;transform:translateX(-50%);font-size:var(--t-xs);font-weight:500;letter-spacing:.06em;color:var(--molten);background:var(--obsidian);border:1px solid var(--line-2);border-radius:999px;padding:2px 8px;font-variant-numeric:tabular-nums}
.upd-name{font-size:24px;font-weight:700;letter-spacing:-.01em;position:relative}
.upd-line{font-size:var(--t-xl);color:var(--ink-2);line-height:1.4;position:relative}
.upd-cause{font-size:var(--t-sm);color:var(--ember);line-height:1.5;max-width:40ch;word-break:break-word}
.upd-notes{list-style:none;margin:var(--s-2) auto 0;padding:0;width:max-content;max-width:100%;display:flex;flex-direction:column;gap:6px;align-items:flex-start;font-size:var(--t-md);color:var(--bone);line-height:1.4}
.upd-notes:empty{display:none}
.upd-notes li{display:flex;align-items:baseline;gap:10px;text-align:left}
.upd-notes li::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--ember);flex:0 0 6px;position:relative;top:-2px}
.upd-more{font:inherit;font-size:var(--t-sm);font-family:var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);background:transparent;border:0;padding:4px 8px;cursor:pointer;border-radius:6px;margin-top:2px}
.upd-more:hover{color:var(--bone)}
.upd-all{list-style:none;margin:0;padding:10px 14px;width:100%;max-height:150px;overflow:auto;text-align:left;font-size:var(--t-base);color:var(--ink-2);line-height:1.5;background:var(--obsidian);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;gap:4px;user-select:text;-webkit-user-select:text}
.upd-meta{font-size:var(--t-sm);color:var(--ash);letter-spacing:.04em;margin-top:var(--s-2);min-height:18px;line-height:1.5;max-width:44ch}
.upd-meta:empty{display:none}
.upd-actions{display:flex;gap:var(--s-3);align-items:center;justify-content:center;flex-wrap:wrap;margin-top:var(--s-4);position:relative}
.upd-actions:empty{display:none}
.upd-actions .btn.primary{min-width:160px}
@media (prefers-reduced-motion:reduce){.upd-wrap,.upd-card{animation:none}.upd-ring .arc{transition:none}.upd-mark.busy .arc{animation:none;stroke-dasharray:207.5 69}}
@media (max-height:620px){.upd-card{padding:24px 24px 20px}.upd-mark{width:72px;height:72px;margin-bottom:var(--s-2)}.upd-mark img{width:32px;height:32px}.upd-name{font-size:20px}}
/* the lock screen (0.1.4, kept; ui/lock-screen.js, index.html #screen-unlock): the mark on the ember glow, the name,
the short address, one primary control. It lies over the main area (the header stays), enters in ENTER_MS (250 ms)
and the home screen leaves beneath it in the same 250 ms. The glow breathes slowly; reduced motion stops it. */
#screen-unlock{position:absolute;inset:0;z-index:5;max-width:none;margin:0;padding:0 var(--gutter);background:var(--obsidian);overflow:auto;animation:lockin .25s ease both}
body.locking #screen-unlock{display:block}
body.locking #screen-home{animation:lockout .25s ease both;pointer-events:none}
@keyframes lockin{from{opacity:0;transform:scale(1.015)}to{opacity:1;transform:none}}
@keyframes lockout{to{opacity:0;transform:scale(.985);filter:blur(4px)}}
.lock-body{min-height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:10px;padding:28px 0 36px}
.lock-coin{position:relative;width:180px;height:180px;margin-bottom:22px;flex:0 0 auto;display:flex;align-items:center;justify-content:center}
.lock-coin::before{content:"";position:absolute;inset:-190px;border-radius:50%;background:radial-gradient(circle,rgba(255,179,92,.10) 0,rgba(242,84,27,.05) 40%,rgba(242,84,27,0) 68%);pointer-events:none}
.lock-glow{position:absolute;inset:-96px;border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.38) 0,rgba(242,84,27,.14) 36%,rgba(242,84,27,0) 66%);animation:lockbreathe 6s ease-in-out infinite;pointer-events:none}
@keyframes lockbreathe{0%,100%{opacity:.75;transform:scale(1)}50%{opacity:1;transform:scale(1.06)}}
.lock-mark{width:148px;height:148px;border-radius:32px;margin:0;box-shadow:0 18px 48px rgba(242,84,27,.45)}
.lock-mark::before{display:none}
.lock-title{font-family:var(--head);font-weight:700;font-size:30px;letter-spacing:-.01em;line-height:1.1;margin-top:4px}
.lock-address{font-size:var(--t-md);color:var(--ash);letter-spacing:.06em;margin-top:2px}
.lock-controls{display:flex;flex-direction:column;align-items:center;gap:10px;margin-top:22px;min-height:120px}
.lock-controls.touch{min-height:200px;justify-content:flex-start}
.lock-touch{display:flex;flex-direction:column;align-items:center;gap:10px}
.lock-btn{min-width:272px;gap:10px}
.lock-line{min-height:20px;font-family:var(--mono);font-size:var(--t-sm);letter-spacing:.04em;color:var(--ash);text-align:center;max-width:60ch;line-height:1.5;text-wrap:balance}
.lock-line .bio-state{display:inline}
.lock-line .fp-glyph{display:inline-block;vertical-align:-3px;margin-right:6px}
.lock-link{font:inherit;font-size:var(--t-base);color:var(--ash);background:transparent;border:0;cursor:pointer;padding:6px 10px;border-radius:6px;text-decoration:underline;text-underline-offset:4px;text-decoration-color:var(--line-2);transition:color .15s ease}
.lock-link:hover{color:var(--bone);text-decoration-color:var(--ash)}
.unlock{display:flex;gap:10px;align-items:center;margin-top:6px}
.unlock input{font:inherit;color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;min-width:280px;min-height:52px;user-select:text;-webkit-user-select:text}
.unlock input:focus{outline:none;border-color:var(--ember)}
.lock-form{margin-top:0;animation:rise .2s ease}
.lock-form input{min-width:260px}
.lock-err{min-height:0}
.lock-foot{margin-top:18px;opacity:.85}
@media (max-height:720px){.lock-coin{width:140px;height:140px;margin-bottom:14px}.lock-mark{width:112px;height:112px;border-radius:26px}.lock-glow{inset:-70px}.lock-coin::before{inset:-150px}.lock-title{font-size:26px}.lock-controls{margin-top:14px}.lock-body{padding:16px 0 24px}}
@media (prefers-reduced-motion:reduce){#screen-unlock,body.locking #screen-home,.lock-form{animation:none}.lock-glow{animation:none}}
/* Touch ID lines */
.bio-state{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);letter-spacing:.04em;color:var(--ember);min-height:18px}
.bio-state.ok{color:var(--molten)}
.bio-state.wait{color:var(--ash)}
.fp-glyph{flex:0 0 auto;color:var(--ember)}
#send-go .fp-ico[hidden]{display:none}
.toast{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:var(--t-base);z-index:40;box-shadow:0 10px 30px var(--shadow);max-width:min(90vw,520px);text-align:center}
body.has-rail .toast{left:calc(50% + var(--rail) / 2)}
/* narrow windows: 1000 the rail folds to icons; 900 the row's reason goes under the word; 720 a bottom tab bar */
@media (max-width:1000px){
:root{--rail:76px;--gutter:var(--s-5)}
.rail{padding:12px 8px}
.rail-brand{justify-content:center;padding:6px 0 14px}
.rail-brand .word{display:none}
.nav{flex-direction:column;gap:4px;padding:8px 4px;font-size:10px;letter-spacing:.06em;text-transform:uppercase;font-family:var(--mono);font-weight:500;text-align:center;min-height:52px;justify-content:center}
.nav.on{font-weight:500}
.nav.on::before{left:-9px}
.nav.small{min-height:44px}
.nav-dot{right:8px;top:8px;margin:0}
.rail-status{display:none}
.rail-version{text-align:center;padding:8px 0 0;font-size:10px;white-space:nowrap}
.page-sub{display:none}
.kv>div{grid-template-columns:120px auto 1fr}
.hr-main{grid-template-columns:26px minmax(140px,1fr) auto 30px}
.hr-state{grid-column:2 / 4;flex-direction:row;align-items:baseline;gap:var(--s-3)}
}
@media (max-width:860px){
.three{grid-template-columns:1fr}
h1{font-size:40px}
.bal .v{font-size:var(--t-hero)}
.words{grid-template-columns:repeat(3,minmax(0,1fr))}
.words.small{grid-template-columns:repeat(4,minmax(0,1fr))}
.disclose-right .dim{max-width:30ch}
}
@media (max-width:720px){
:root{--rail:0px;--gutter:var(--s-4);--bottom:64px}
body.has-rail .rail{top:auto;bottom:0;left:0;right:0;width:auto;height:64px;flex-direction:row;align-items:center;border-right:0;border-top:1px solid var(--line);padding:0 var(--s-2)}
.rail-brand{display:none}
.rail-nav{flex-direction:row;flex:1;gap:0;justify-content:space-around}
.nav{min-height:56px;padding:6px 2px;font-size:10px;border-radius:10px;flex:1;max-width:120px}
.nav.on::before{display:none}
.rail-foot{display:none}
body.has-rail .narrow-only{display:inline-flex}
body.has-rail .top,body.has-rail .notices,body.has-rail main,body.has-rail .ask-wrap{left:0}
body.has-rail main{bottom:var(--bottom)}
body.has-rail .toast{left:50%;bottom:calc(var(--bottom) + 12px)}
.ask-wrap{bottom:var(--bottom)}
.top{padding:0 var(--s-4)}
.page-title h1{font-size:17px}
.pill{min-width:0}
.bal-row{flex-direction:column;gap:4px;align-items:flex-start}
.bal .v{font-size:var(--t-h2)}
.actions .btn.big{flex:1 1 40%}
.actions .note{flex-basis:100%;margin-left:0}
.addr-big{font-size:var(--t-base)}
.hr-main{grid-template-columns:26px 1fr 30px;gap:var(--s-2) var(--s-3);padding:12px}
.hr-amt{grid-column:1 / 3;grid-row:2;justify-self:start;text-align:left}
.hr-state{grid-column:1 / -1;grid-row:3;flex-direction:column;align-items:flex-start;gap:2px}
.hr-state .why{white-space:normal}
.hr-details{padding-left:12px;padding-right:12px}
.hrow.sent-row .hr-main{grid-template-columns:26px 1fr}
.kv>div{grid-template-columns:96px 1fr;gap:4px var(--s-3)}
.kv .m{grid-column:1 / -1;white-space:normal}
.disclose-right .dim{display:none}
.node-line{white-space:normal;line-height:1.4}
.lead-row{flex-direction:column}
.srow{flex-direction:column;align-items:flex-start;gap:var(--s-2)}
.indent{margin-left:0}
.seg{width:100%}
.seg-b{flex:1;padding:6px 8px}
.step{padding:32px 0}
.words{grid-template-columns:repeat(2,minmax(0,1fr))}
.words.small{grid-template-columns:repeat(3,minmax(0,1fr))}
.checks{grid-template-columns:1fr}
.qr{width:200px;height:200px;flex-basis:200px}
.ask .short-pw{flex:1 1 160px;width:auto}
.lock-btn{min-width:0;width:100%}
.unlock input,.lock-form input{min-width:0;flex:1}
.unlock{width:100%}
}
/* short windows (the 620 px floor): tighter paddings, so the hero and the rows fit */
@media (max-height:700px){
:root{--card-pad:18px;--gap:var(--s-3)}
#screen-home{padding:16px 0 20px}
.hero{gap:var(--s-3);padding:var(--s-5) 0 var(--s-6)}
.mark-wrap{width:88px;height:88px}
.mark-wrap img{width:56px;height:56px}
h1{font-size:40px}
.lead{font-size:var(--t-lg)}
.step{padding:32px 0 32px}
}
/* the page bridge (0.1.6): a website's request as a card over the window; the Settings card's site rows */
.bridge-card{max-width:540px}
.bridge-amount{font-family:var(--head);font-weight:700;font-size:28px;color:var(--ember);margin:4px 0 8px;letter-spacing:-.01em}
.bridge-message{white-space:pre-wrap;word-break:break-word;max-height:220px;overflow:auto;background:var(--obsidian);border:1px solid var(--line);border-radius:10px;padding:10px 12px;font-size:12px;line-height:1.5;color:var(--ink-2);margin:6px 0 10px;text-align:left}
.bridge-done{margin-top:12px;text-align:left}
.bridge-done .kv{margin-top:8px}
#bridge-origin{margin-top:6px}
.sites{display:flex;flex-direction:column}
.sites .srow .sw-text b{font-weight:600}
.sites .srow .sw-text .dim{display:block;margin-top:2px}
.lock-line.bridge-waiting{color:var(--molten);margin-top:4px}

File diff suppressed because it is too large Load diff

View file

@ -1,433 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Igneum Wallet</title>
<meta name="color-scheme" content="dark light">
<link rel="icon" href="mark.svg" type="image/svg+xml">
<link rel="stylesheet" href="app.css">
</head>
<body data-phase="welcome" data-page="home">
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><symbol id="i-fp" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4.8 7.7A9.2 9.2 0 0 1 12 4.1c1.5 0 2.9.3 4.1.9"/><path d="M5.2 16.6A12.5 12.5 0 0 1 4.6 12a7.4 7.4 0 0 1 14.8 0v4"/><path d="M8.5 20a9 9 0 0 1-1.3-4.7V12a4.8 4.8 0 0 1 9.6 0v1.5"/><path d="M12 11a1 1 0 0 1 1 1v2a6 6 0 0 1-1 3.3"/><path d="M15.6 20a10 10 0 0 0 .9-2.6"/></g></symbol></svg>
<!-- the rail: five sections (wallet-ui-3: Home, Send, Receive, History, Settings), Lock and Quit in the foot. Under
720 px it is a bottom tab bar. The welcome, create, import and lock screens have no rail. -->
<aside class="rail" id="rail" hidden>
<div class="rail-brand">
<img src="mark.svg" width="28" height="28" alt="">
<span class="word">IGNEUM</span>
</div>
<nav class="rail-nav" id="rail-nav" aria-label="Sections">
<button class="nav on" data-page="home" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 11 12 3l9 8"/><path d="M5 10v10h14V10"/></svg><span>Home</span></button>
<button class="nav" data-page="send"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5"/><path d="m5 12 7-7 7 7"/></svg><span>Send</span></button>
<button class="nav" data-page="receive"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14"/><path d="m19 12-7 7-7-7"/></svg><span>Receive</span></button>
<button class="nav" data-page="history"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span>History</span></button>
<button class="nav" data-page="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></svg><span>Settings</span><i class="nav-dot" id="nav-updates-dot" hidden></i></button>
</nav>
<div class="rail-foot">
<div class="rail-status mono" id="rail-status"></div>
<button class="nav small" id="btn-lock"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg><span>Lock</span></button>
<button class="nav small danger" id="btn-quit"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v9"/><path d="M6.5 6.5a8 8 0 1 0 11 0"/></svg><span>Quit</span></button>
<div class="rail-version mono" id="foot-version"></div>
</div>
</aside>
<header class="top">
<div class="brand" id="top-brand">
<img src="mark.svg" width="30" height="30" alt="">
<span class="word">IGNEUM</span><span class="miner">WALLET</span>
</div>
<div class="page-title" id="page-title" hidden>
<h1 id="page-title-text">Home</h1>
<span class="page-sub" id="page-sub"></span>
</div>
<div class="top-right">
<div class="pill" id="pill"><span class="dot"></span><span id="pill-text">starting</span></div>
<button class="btn small ghost narrow-only" id="btn-lock-top" hidden>Lock</button>
</div>
</header>
<!-- the status strip: the update line, one notice at a time -->
<div class="notices" id="notices" hidden>
<div class="notice" id="notice" role="status" aria-live="polite">
<span class="notice-text" id="notice-text"></span>
<span class="notice-actions" id="notice-actions"></span>
<button class="notice-close" id="notice-close" title="Close" aria-label="Close">&times;</button>
<span class="prog" id="notice-prog" hidden><i></i></span>
</div>
</div>
<!-- the quit question, in place of a dialog -->
<div class="ask-wrap" id="ask-quit" hidden>
<div class="ask">
<span class="ask-text">Quit Igneum Wallet? The bundled node stops with it.</span>
<button class="btn small primary" id="ask-quit-yes">Quit</button>
<button class="btn small ghost" id="ask-quit-no">Cancel</button>
</div>
</div>
<main id="main">
<!-- welcome -->
<section class="screen" id="screen-welcome">
<div class="hero">
<div class="mark-wrap"><img src="mark.svg" width="72" height="72" alt=""></div>
<div class="eyebrow ember" id="welcome-eyebrow">devnet v4 &middot; nothing is bought or sold</div>
<h1>Igneum Wallet</h1>
<p class="lead">Your IGN and your key, on this machine. The key is made here and never leaves this app.</p>
<div class="three">
<div class="tile"><div class="k">01</div><div class="t">Your key stays here</div><div class="s">Sealed with a password you choose. Signing happens inside the app.</div></div>
<div class="tile"><div class="k">02</div><div class="t">Final means verified</div><div class="s">A payment is final when this wallet has checked the network's certificate itself.</div></div>
<div class="tile"><div class="k">03</div><div class="t">Works with the miner</div><div class="s">Uses the miner app's node when it runs here. Imports the miner's key in one tap.</div></div>
</div>
<div class="cta">
<button class="btn primary big" id="go-create">Create a wallet</button>
<button class="btn big" id="go-import">Import</button>
</div>
<p class="seedline mono">Nobody from Igneum will ever ask for your words or your key.</p>
</div>
</section>
<!-- create -->
<section class="screen" id="screen-create">
<div class="step" id="create-1">
<div class="eyebrow">step 1 of 3</div>
<h2>Choose a password</h2>
<p class="sub">It locks the key on this machine. Nobody can reset it for you. At least 8 characters.</p>
<label class="field"><span class="k">Password</span><input type="password" id="create-pw" autocomplete="new-password"></label>
<label class="field"><span class="k">Again</span><input type="password" id="create-pw2" autocomplete="new-password"></label>
<div class="err" id="create-err"></div>
<div class="cta"><button class="btn primary big" id="create-next">Make my words</button><button class="btn ghost" id="create-back">Back</button></div>
</div>
<div class="step" id="create-2" hidden>
<div class="eyebrow">step 2 of 3</div>
<h2>Write these 24 words down</h2>
<p class="sub">They are the wallet. Anyone with them has your IGN. They are shown once.</p>
<ol class="words" id="words"></ol>
<div class="field"><div class="k">your address</div><div class="box mono"><span id="create-address"></span><button class="btn tiny" data-copy="create-address">Copy</button></div></div>
<div class="cta"><button class="btn primary big" id="create-written">I wrote them down</button></div>
</div>
<div class="step" id="create-3" hidden>
<div class="eyebrow">step 3 of 3</div>
<h2>Type three of them</h2>
<p class="sub">So the paper is right before the words are gone from the screen.</p>
<div class="checks" id="checks"></div>
<div class="err" id="confirm-err"></div>
<div class="cta"><button class="btn primary big" id="create-confirm">Open my wallet</button><button class="btn ghost" id="create-again">Show the words again</button></div>
</div>
</section>
<!-- import -->
<section class="screen" id="screen-import">
<div class="step">
<div class="eyebrow">import</div>
<h2>Bring a key here</h2>
<p class="sub">Words from any wallet, a raw private key, or the key the miner app made on this machine.</p>
<div class="seg" id="import-mode" role="radiogroup" aria-label="What to import">
<button class="seg-b on" data-mode="seed" role="radio" aria-checked="true">24 words</button>
<button class="seg-b" data-mode="key" role="radio" aria-checked="false">Private key</button>
<button class="seg-b" data-mode="miner" id="seg-miner" role="radio" aria-checked="false">Miner's key</button>
</div>
<label class="field" id="import-data-field"><span class="k" id="import-data-label">The words, in order</span><textarea id="import-data" rows="4" spellcheck="false" autocomplete="off"></textarea></label>
<p class="note" id="import-miner-note" hidden>The miner app's key file on this machine will be read. Rewards keep going to the same address.</p>
<label class="field"><span class="k">New password for this machine</span><input type="password" id="import-pw" autocomplete="new-password"></label>
<label class="field"><span class="k">Again</span><input type="password" id="import-pw2" autocomplete="new-password"></label>
<div class="err" id="import-err"></div>
<div class="cta"><button class="btn primary big" id="import-go">Import</button><button class="btn ghost" id="import-back">Back</button></div>
</div>
</section>
<!-- the lock screen (lock-screen.js, app.js onLockScreen; 0.1.4, kept): the mark on the ember glow, the name, the
short address, one primary control. -->
<section class="screen lock" id="screen-unlock">
<div class="lock-body">
<div class="lock-coin"><span class="lock-glow" aria-hidden="true"></span><div class="mark-wrap lock-mark"><img src="mark.svg" width="88" height="88" alt=""></div></div>
<h1 class="lock-title">Igneum Wallet</h1>
<p class="lock-address mono" id="unlock-address"></p>
<p class="lock-line bridge-waiting" id="unlock-bridge" hidden></p>
<div class="lock-controls">
<div class="lock-touch" id="unlock-bio" hidden>
<button class="btn primary big fp lock-btn" id="unlock-touch" type="button"><svg width="22" height="22" aria-hidden="true"><use href="#i-fp"></use></svg><span id="unlock-touch-text">Unlock with Touch ID</span></button>
<div class="lock-line" id="unlock-bio-note" aria-live="polite"></div>
<button class="lock-link" id="unlock-use-pw" type="button">Use password</button>
</div>
<form id="unlock-form" class="unlock lock-form" hidden>
<input type="password" id="unlock-pw" placeholder="Password" autocomplete="current-password" aria-label="Password">
<button class="btn primary big" type="submit" id="unlock-submit">Unlock</button>
</form>
<div class="err lock-err" id="unlock-err"></div>
</div>
<p class="seedline lock-foot">Forgot it? Only the 24 words or the key open this wallet again.</p>
</div>
</section>
<!-- home: five pages behind the rail -->
<section class="screen" id="screen-home">
<!-- Home -->
<section class="page" id="page-home" data-page="home">
<div class="card hero-card">
<div class="bal-row">
<div class="bal" id="h-bal"><span class="v" id="h-balance">0</span><span class="unit mono">IGN</span></div>
<p class="line-text bal-line" id="h-balance-line"></p>
</div>
<p class="money-line" id="h-money"></p>
<div class="addr-big mono"><span id="home-address">not set</span><button class="btn small" data-copy="home-address">Copy</button></div>
<div class="row wrap actions">
<button class="btn primary big" id="go-send">Send</button>
<button class="btn big" id="go-receive">Receive</button>
<span class="note" id="backup-note" hidden>Your words are not written down yet. <a href="#" id="backup-link">Back up now</a>.</span>
</div>
</div>
<div class="card node-card" id="node-card">
<button class="disclose" id="node-toggle" aria-expanded="false" aria-controls="node-details">
<span class="node-line"><i class="dot" id="node-dot"></i><span id="node-line-text">Node starting</span></span>
<span class="disclose-right"><span class="dim" id="node-sub"></span><span class="chev" aria-hidden="true"></span></span>
</button>
<div class="details" id="node-details" hidden>
<div class="kv" id="node-kv"></div>
</div>
</div>
<div class="card">
<div class="card-head"><h3>Recent</h3><button class="btn small ghost" id="recent-all">All history</button></div>
<div class="hist" id="recent"></div>
</div>
</section>
<!-- Send -->
<section class="page" id="page-send" data-page="send" hidden>
<div class="card">
<div class="card-head"><h3>Send IGN</h3><span class="eyebrow" id="send-eyebrow"></span></div>
<div class="send-form" id="send-form">
<label class="field"><span class="k">To</span><input type="text" id="send-to" class="mono" placeholder="0x" spellcheck="false" autocomplete="off"></label>
<label class="field"><span class="k">Amount</span><div class="row"><input type="text" id="send-amount" class="mono" placeholder="0.0" inputmode="decimal" autocomplete="off"><span class="unit-word mono">IGN</span><span class="note" id="send-balance"></span></div></label>
<div class="fee-row">
<span class="k mono">fee</span>
<span class="line-text" id="fee-line">shown when you review</span>
<button class="btn tiny ghost" id="fee-toggle" aria-expanded="false" aria-controls="fee-details" hidden>Details</button>
</div>
<p class="help" id="fee-details" hidden></p>
<div class="err" id="send-err"></div>
<div class="cta" id="send-cta"><button class="btn primary big" id="send-quote">Review</button></div>
</div>
<div class="ask inline" id="ask-send" hidden>
<span class="ask-text" id="ask-send-text"></span>
<button class="btn small primary" id="send-go"><svg class="fp-ico" width="16" height="16" hidden><use href="#i-fp"></use></svg><span id="send-go-text">Send now</span></button>
<button class="btn small ghost" id="ask-send-no">Cancel</button>
<span class="ask-foot" id="send-bio-line"></span>
<span class="ask-foot err" id="confirm-send-err"></span>
</div>
<div class="sent" id="send-done" hidden>
<div class="hrow sent-row" id="sent-row"></div>
<div class="box mono"><span id="sent-hash"></span><button class="btn tiny" data-copy="sent-hash">Copy</button></div>
<div class="cta"><button class="btn small" id="sent-view">View in History</button><button class="btn small ghost" id="sent-again">Send another</button></div>
</div>
</div>
</section>
<!-- Receive -->
<section class="page" id="page-receive" data-page="receive" hidden>
<div class="card">
<div class="card-head"><h3>Your address</h3></div>
<div class="addr-big mono"><span id="receive-address">not set</span><button class="btn small" data-copy="receive-address">Copy</button></div>
<div class="qr-row">
<div class="qr" id="qr"></div>
<p class="help">Only IGN on the Igneum network. Rewards from the miner app land here when it pays this address. The code is drawn on this machine.</p>
</div>
</div>
</section>
<!-- History -->
<section class="page" id="page-history" data-page="history" hidden>
<div class="card">
<div class="card-head"><h3>History</h3><span class="eyebrow" id="hist-eyebrow"></span></div>
<div class="hist" id="history"></div>
</div>
</section>
<!-- Settings -->
<section class="page" id="page-settings" data-page="settings" hidden>
<div class="card">
<div class="card-head"><h3>Security</h3></div>
<div class="srow" id="bio-row">
<div class="sw-text"><b id="bio-title">Touch ID</b> <span class="dim" id="bio-sentence"></span></div>
<div class="row"><button class="btn small" id="bio-toggle-btn">Turn on</button></div>
</div>
<div class="details indent" id="bio-enrol" hidden>
<div class="row wrap"><input type="password" class="addr-input" id="bio-pw" placeholder="Your password" autocomplete="current-password"><button class="btn small primary fp" id="bio-enrol-go"><svg width="16" height="16"><use href="#i-fp"></use></svg><span id="bio-enrol-text">Turn on Touch ID</span></button><button class="btn small ghost" id="bio-enrol-cancel">Cancel</button></div>
</div>
<p class="note indent" id="bio-off-note" hidden></p>
<div class="err indent" id="bio-err"></div>
<label class="switch" id="ask-on-open-row"><input type="checkbox" id="ask-on-open"><span class="track"></span><span class="sw-text"><b id="ask-on-open-text">Ask for Touch ID when the wallet opens</b> <span class="dim">Once, when you open the app. Never after an idle lock.</span></span></label>
<div class="srow" id="idle-row">
<div class="sw-text"><b>Lock when idle</b> <span class="dim" id="idle-lock-note"></span></div>
<select class="select" id="idle-lock" aria-label="Lock when idle">
<option value="1">after 1 minute</option>
<option value="5">after 5 minutes</option>
<option value="15">after 15 minutes</option>
<option value="60">after 1 hour</option>
<option value="0">never</option>
</select>
</div>
<button class="disclose" id="pw-toggle" aria-expanded="false" aria-controls="pw-details"><span>Change password</span><span class="chev" aria-hidden="true"></span></button>
<div class="details" id="pw-details" hidden>
<div class="row wrap"><input type="password" class="addr-input" id="pw-old" placeholder="Current" autocomplete="current-password"><input type="password" class="addr-input" id="pw-new" placeholder="New, 8 or more" autocomplete="new-password"><button class="btn small" id="pw-change">Change</button></div>
<p class="help">Touch ID is turned off by a password change; turn it on again above.</p>
<div class="err" id="pw-err"></div>
</div>
</div>
<div class="card">
<div class="card-head"><h3>Websites</h3><span class="eyebrow" id="sites-eyebrow">page bridge</span></div>
<label class="switch" id="bridge-on-row"><input type="checkbox" id="bridge-on"><span class="track"></span><span class="sw-text"><b>Let websites connect</b> <span class="dim" id="bridge-status"></span></span></label>
<div class="sites" id="sites"></div>
<p class="note" id="sites-note">A site asks once; you answer in this window. Each site sees your address and can ask you to sign or send; every request is shown here first. Disconnect a site any time.</p>
</div>
<div class="card">
<div class="card-head"><h3>Backup</h3><span class="eyebrow" id="backup-eyebrow"></span></div>
<button class="disclose" id="backup-toggle" aria-expanded="false" aria-controls="backup-details"><span>Show my words</span><span class="chev" aria-hidden="true"></span></button>
<div class="details" id="backup-details" hidden>
<div class="ask inline" id="ask-backup">
<span class="ask-text">Show the 24 words and the key on screen? Anyone who sees them has your IGN.</span>
<button class="btn small primary fp" id="backup-touch" hidden><svg width="16" height="16"><use href="#i-fp"></use></svg><span>Show with Touch ID</span></button>
<button class="btn small" id="backup-use-pw">Use password</button>
<button class="btn small ghost" id="backup-cancel">Cancel</button>
</div>
<div class="row wrap" id="backup-pw-row" hidden><input type="password" class="addr-input" id="backup-pw" placeholder="Your password" autocomplete="current-password"><button class="btn small primary" id="backup-show">Show</button></div>
<div class="err" id="backup-err"></div>
<div id="backup-out" hidden>
<ol class="words small" id="backup-words"></ol>
<div class="field"><div class="k">private key</div><div class="box mono key"><span id="backup-key"></span><button class="btn tiny" data-copy="backup-key">Copy</button><button class="btn tiny ghost" id="backup-hide">Hide</button></div></div>
</div>
</div>
<button class="disclose" id="export-toggle" aria-expanded="false" aria-controls="export-details"><span>Export the key for MetaMask</span><span class="chev" aria-hidden="true"></span></button>
<div class="details" id="export-details" hidden>
<p class="help">A copied key can be stolen from the clipboard, a screenshot or a notes app. Paste it straight into MetaMask and clear the clipboard.</p>
<div class="ask inline" id="ask-export">
<span class="ask-text">Show the private key on screen? Anyone who sees it has your IGN.</span>
<button class="btn small primary fp" id="export-touch" hidden><svg width="16" height="16"><use href="#i-fp"></use></svg><span>Show with Touch ID</span></button>
<button class="btn small" id="export-use-pw">Use password</button>
<button class="btn small ghost" id="export-cancel">Cancel</button>
</div>
<div class="row wrap" id="export-pw-row" hidden><input type="password" class="addr-input" id="export-pw" placeholder="Your password" autocomplete="current-password"><button class="btn small primary" id="export-show">Show</button></div>
<div class="err" id="export-err"></div>
<div class="box mono key" id="export-out" hidden><span id="export-key"></span><button class="btn tiny" data-copy="export-key">Copy</button><button class="btn tiny ghost" id="export-hide">Hide</button></div>
</div>
<div class="srow top-gap">
<div class="sw-text"><b>MetaMask network</b> <span class="dim mono" id="net-line"></span></div>
<div class="row"><button class="btn small" id="net-add">Add to MetaMask</button><button class="btn small ghost" id="net-copy">Copy settings</button></div>
</div>
</div>
<div class="card">
<div class="card-head"><h3>This machine</h3></div>
<label class="switch"><input type="checkbox" id="start-login"><span class="track"></span><span class="sw-text"><b>Start at login</b> <span class="dim">Opens when you sign in. The wallet locks itself when idle.</span></span></label>
<div class="field">
<div class="k">appearance</div>
<div class="seg" id="theme-seg" role="radiogroup" aria-label="Appearance">
<button class="seg-b" data-theme="system" role="radio" aria-checked="true">System</button>
<button class="seg-b" data-theme="light" role="radio" aria-checked="false">Light</button>
<button class="seg-b" data-theme="dark" role="radio" aria-checked="false">Dark</button>
</div>
</div>
</div>
<div class="card">
<div class="lead-row">
<div class="lead-text">
<h3 id="s-version">Igneum Wallet</h3>
<p class="help" id="s-update-note">Not checked yet.</p>
</div>
<div class="row">
<button class="btn small primary" id="s-install" hidden>Install now</button>
<button class="btn small" id="s-update">Check</button>
</div>
</div>
<label class="switch"><input type="checkbox" id="auto-update"><span class="track"></span><span class="sw-text"><b>Install updates by itself</b> <span class="dim">Downloads in the background and installs when nothing is being sent.</span></span></label>
</div>
<div class="card">
<div class="card-head"><h3>Node</h3><span class="eyebrow" id="node-eyebrow"></span></div>
<div class="field">
<div class="k">endpoint in use</div>
<div class="box mono"><span id="node-endpoint">none yet</span><button class="btn tiny" data-copy="node-endpoint">Copy</button></div>
</div>
<p class="line-text" id="node-source-line"></p>
<p class="help">The wallet picks the miner's node when it runs on this machine, else its own bundled node, else the public RPC. Finality is verified only with a node here.</p>
<p class="note mono small" id="node-ids"></p>
</div>
<details class="card adv" id="s-advanced">
<summary><h3>Advanced</h3><span class="eyebrow">this machine</span></summary>
<p class="note mono small" id="s-mid"></p>
<p class="note mono small" id="s-dirs"></p>
<p class="note" id="s-miner-file"></p>
<div class="row wrap top-gap"><button class="btn small danger" id="remove-start">Remove this wallet from this machine</button></div>
<div class="ask inline" id="ask-remove" hidden>
<span class="ask-text">Remove this wallet from this machine? The vault file is deleted. Only your 24 words or the key bring it back.</span>
<input type="password" class="addr-input short-pw" id="remove-pw" placeholder="Your password" autocomplete="current-password" aria-label="Password">
<button class="btn small primary" id="remove-go">Remove</button>
<button class="btn small ghost" id="ask-remove-no">Cancel</button>
<span class="ask-foot err" id="remove-err"></span>
</div>
</details>
</section>
</section>
</main>
<!-- the update card (update-card.js, app.js renderUpdateCard): one update, centred; Later, Escape or the backdrop
leaves the strip above -->
<!-- the page bridge (0.1.6): a website's request, answered here and nowhere else -->
<div class="upd-wrap bridge-wrap" id="bridge" hidden>
<div class="upd-card bridge-card" id="bridge-card" role="dialog" aria-modal="true" aria-labelledby="bridge-title" tabindex="-1">
<div class="eyebrow ember" id="bridge-eyebrow">a website asks</div>
<h2 class="upd-name" id="bridge-title"></h2>
<p class="bridge-amount mono" id="bridge-amount" hidden></p>
<pre class="bridge-message mono" id="bridge-message" hidden></pre>
<ul class="upd-notes" id="bridge-lines"></ul>
<p class="upd-meta mono" id="bridge-origin"></p>
<div class="upd-actions" id="bridge-actions">
<button class="btn primary big" id="bridge-yes"><svg class="fp-ico" width="16" height="16" hidden><use href="#i-fp"></use></svg><span id="bridge-yes-text">Connect</span></button>
<button class="btn ghost" id="bridge-no">Decline</button>
</div>
<p class="ask-foot" id="bridge-bio-line"></p>
<p class="err" id="bridge-err"></p>
<div class="bridge-done" id="bridge-done" hidden>
<p class="upd-line" id="bridge-done-line"></p>
<div class="box mono" id="bridge-hash-box" hidden><span id="bridge-hash"></span><button class="btn tiny" data-copy="bridge-hash">Copy</button></div>
<div class="kv" id="bridge-final"></div>
<div class="upd-actions"><button class="btn ghost" id="bridge-close">Done</button></div>
</div>
</div>
</div>
<div class="upd-wrap" id="upd" hidden>
<div class="upd-card" id="upd-card" role="dialog" aria-modal="true" aria-labelledby="upd-name" aria-describedby="upd-line" tabindex="-1">
<div class="upd-mark" id="upd-mark">
<svg class="upd-ring" viewBox="0 0 96 96" aria-hidden="true"><circle class="track" cx="48" cy="48" r="44"></circle><circle class="arc" id="upd-arc" cx="48" cy="48" r="44"></circle></svg>
<img src="mark.svg" width="40" height="40" alt="">
<span class="upd-pct mono" id="upd-pct" hidden></span>
</div>
<div class="eyebrow ember">update</div>
<h2 class="upd-name" id="upd-name"></h2>
<p class="upd-line" id="upd-line"></p>
<p class="upd-cause mono" id="upd-cause" hidden></p>
<ul class="upd-notes" id="upd-notes"></ul>
<button class="upd-more" id="upd-more" type="button" aria-expanded="false" hidden>What changed</button>
<ul class="upd-all" id="upd-all" hidden></ul>
<p class="upd-meta mono" id="upd-meta"></p>
<div class="upd-actions" id="upd-actions"></div>
</div>
</div>
<div class="toast" id="toast" hidden></div>
<script src="update-card.js"></script>
<script src="lock-screen.js"></script>
<script src="app.js"></script>
</body>
</html>

View file

@ -1,78 +0,0 @@
/* The lock screen (pure; lock-screen.test.mjs loads this file): what the screen shows for a wallet state, the line
under the button after the host answered, and when the system Touch ID (or Windows Hello) sheet may be raised
without a tap. 5 October 2026, for 0.1.4.
The sheet appears when the person taps the button or presses Return or Space on it. One exception: the first
arrival after the person opened the app may raise it once, AUTO_DELAY_MS after the lock screen is fully drawn,
when the setting "Ask for Touch ID when the wallet opens" is on. Never on an idle lock, on a hand lock, when the
window comes back, after a cancelled sheet, or when the updater relaunched the app. */
const LockScreen = (function () {
'use strict';
/** after the lock screen is fully drawn */
const AUTO_DELAY_MS = 600;
/** the transition from the home screen to the lock screen, and the lock screen's own entry */
const ENTER_MS = 250;
/** the idle lock's choices in Settings, minutes; 0 is never */
const IDLE_CHOICES = [1, 5, 15, 60, 0];
const IDLE_DEFAULT_MIN = 5;
function idleLabel(min) {
const m = Number(min) || 0;
if (m <= 0) return 'never';
if (m === 60) return '1 hour';
if (m % 60 === 0) return (m / 60) + ' hours';
return m === 1 ? '1 minute' : m + ' minutes';
}
function shortAddress(a) {
return a ? a.slice(0, 8) + '…' + a.slice(-6) : '';
}
/** What the lock screen shows: the fingerprint button when Touch ID or Windows Hello is enrolled and the app
window is the host (a browser tab cannot raise the sheet); else the password field is the primary control. */
function layout(s, hostHere) {
const b = (s && s.biometric) || {};
const touch = !!(b.enrolled && hostHere);
return { touch: touch, passwordPrimary: !touch, address: shortAddress(s && s.display) };
}
/** The line under the button after the host answered: {text, cls, password}. cls is '' (ember), 'ok' or
'wait'; password says the password field should be revealed (the sheet cannot unlock this time). */
function line(r, name) {
if (!r) return { text: '', cls: '', password: false };
if (r.ok) return { text: name + ' confirmed, unlocking', cls: 'ok', password: false };
const c = r.code;
if (c === 'cancelled') return { text: name + ' cancelled, tap to try again', cls: '', password: false };
if (c === 'failed') return { text: name + ' did not match, tap to try again', cls: '', password: false };
if (c === 'timeout') return { text: name + ' did not answer, tap to try again', cls: '', password: false };
if (c === 'fallback') return { text: 'Enter your password', cls: 'wait', password: true };
if (c === 'locked') return { text: name + ' is locked, use your password', cls: '', password: true };
if (c === 'invalidated') return { text: name + ' was turned off (a fingerprint changed): use your password, then turn it on again in Settings', cls: '', password: true };
if (c === 'not_set_up' || c === 'unavailable' || c === 'not_enrolled') return { text: r.message || (name + ' is not set up on this Mac'), cls: '', password: true };
if (c === 'nohost') return { text: name + ' needs the Igneum Wallet app window', cls: '', password: true };
if (c === 'engine') return { text: r.message || ('the wallet did not unlock'), cls: '', password: true };
return { text: r.message || (name + ' did not succeed, tap to try again'), cls: '', password: false };
}
/** Whether the sheet may be raised now without a tap. ctx: reason (arrival | lock | focus | cancel), phase,
touch (the button is on screen), askOnOpen (the setting), hidden (the window is not visible), busy (a sheet is
up), firstPhase (the first phase this page saw: 'unlock' means the app opened locked), updatedFrom (set on
the first run after an update: the updater opened the app, not the person). mem.used: the one chance went. */
function autoPrompt(ctx, mem) {
const c = ctx || {}, m = mem || {};
const no = function (why) { return { prompt: false, why: why, delay: 0 }; };
if (c.phase !== 'unlock') return no('not-locked');
if (!c.touch) return no('no-touch');
if (m.used) return no('once');
if (c.reason !== 'arrival') return no(c.reason || 'not-arrival');
if (c.firstPhase !== 'unlock') return no('not-arrival');
if (c.updatedFrom) return no('updater');
if (!c.askOnOpen) return no('setting-off');
if (c.hidden) return no('hidden');
if (c.busy) return no('busy');
return { prompt: true, why: 'arrival', delay: AUTO_DELAY_MS };
}
return { AUTO_DELAY_MS: AUTO_DELAY_MS, ENTER_MS: ENTER_MS, IDLE_CHOICES: IDLE_CHOICES, IDLE_DEFAULT_MIN: IDLE_DEFAULT_MIN, idleLabel: idleLabel, shortAddress: shortAddress, layout: layout, line: line, autoPrompt: autoPrompt };
})();
if (typeof module === 'object' && module && module.exports) module.exports = LockScreen;

View file

@ -1,93 +0,0 @@
// node --test app/igneum-wallet/ui/lock-screen.test.mjs (no dependencies)
// Loads ui/lock-screen.js (plain browser JS, run with `module` defined and no `document`) and checks what the lock
// screen shows, the line under the button after the host answered, and when the system sheet may be raised
// without a tap: once, on the first arrival after the person opened the app, with the setting on; never on an
// idle lock, a hand lock, the window coming back, a cancelled sheet, or the updater's relaunch.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'lock-screen.js'), 'utf8');
const mod = { exports: {} };
new Function('module', src)(mod);
const L = mod.exports;
const { layout, line, autoPrompt, idleLabel, shortAddress } = L;
const ADDR = '0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf';
const st = (over) => ({ phase: 'unlock', display: ADDR, biometric: { enrolled: true, available: true, kind: 'touchid' }, settings: { ask_on_open: true, idle_lock_min: 5 }, update: { updated_from: '' }, ...over });
const arrival = (over) => ({ reason: 'arrival', phase: 'unlock', touch: true, askOnOpen: true, hidden: false, busy: false, firstPhase: 'unlock', updatedFrom: '', ...over });
test('what the screen shows: the button when enrolled in the app window, else the password field first', () => {
const a = layout(st(), true);
assert.equal(a.touch, true); assert.equal(a.passwordPrimary, false); assert.equal(a.address, '0x7E5F45…395Bdf');
// enrolled, but the page is open in a browser tab: no host to raise the sheet
const b = layout(st(), false);
assert.equal(b.touch, false); assert.equal(b.passwordPrimary, true);
// not enrolled in the app window
const c = layout(st({ biometric: { enrolled: false, available: true } }), true);
assert.equal(c.touch, false); assert.equal(c.passwordPrimary, true);
assert.deepEqual(layout(null, true), { touch: false, passwordPrimary: true, address: '' });
assert.equal(shortAddress(''), '');
});
test('the line under the button: cancel and no-match keep the button, fallback and lockout reveal the password', () => {
assert.deepEqual(line({ ok: true }, 'Touch ID'), { text: 'Touch ID confirmed, unlocking', cls: 'ok', password: false });
assert.deepEqual(line({ ok: false, code: 'cancelled' }, 'Touch ID'), { text: 'Touch ID cancelled, tap to try again', cls: '', password: false });
assert.deepEqual(line({ ok: false, code: 'failed' }, 'Windows Hello'), { text: 'Windows Hello did not match, tap to try again', cls: '', password: false });
assert.deepEqual(line({ ok: false, code: 'timeout' }, 'Touch ID'), { text: 'Touch ID did not answer, tap to try again', cls: '', password: false });
assert.deepEqual(line({ ok: false, code: 'fallback' }, 'Touch ID'), { text: 'Enter your password', cls: 'wait', password: true });
assert.equal(line({ ok: false, code: 'locked' }, 'Touch ID').password, true);
assert.equal(line({ ok: false, code: 'invalidated' }, 'Touch ID').password, true);
assert.equal(line({ ok: false, code: 'nohost' }, 'Touch ID').text, 'Touch ID needs the Igneum Wallet app window');
assert.equal(line({ ok: false, code: 'not_set_up', message: 'Touch ID is not set up on this Mac. Add a fingerprint in System Settings > Touch ID & Password.' }, 'Touch ID').password, true);
assert.equal(line({ ok: false, code: 'engine', message: 'the engine did not unlock' }, 'Touch ID').text, 'the engine did not unlock');
assert.deepEqual(line({ ok: false, code: 'weird' }, 'Touch ID'), { text: 'Touch ID did not succeed, tap to try again', cls: '', password: false });
assert.deepEqual(line(null, 'Touch ID'), { text: '', cls: '', password: false });
// never a full stop at the end, never a modal: one line in our words
for (const c of ['cancelled', 'failed', 'timeout', 'fallback', 'locked', 'nohost']) assert.ok(!line({ ok: false, code: c }, 'Touch ID').text.endsWith('.'), c);
});
test('the one automatic prompt: first arrival after the person opened the app, setting on, 600 ms after drawn', () => {
const mem = { used: false };
assert.deepEqual(autoPrompt(arrival(), mem), { prompt: true, why: 'arrival', delay: 600 });
assert.equal(L.AUTO_DELAY_MS, 600); assert.equal(L.ENTER_MS, 250);
// once per launch
assert.equal(autoPrompt(arrival(), { used: true }).why, 'once');
// the setting off
assert.equal(autoPrompt(arrival({ askOnOpen: false }), mem).why, 'setting-off');
// the window is not visible at arrival (start at login, the window behind): nothing
assert.equal(autoPrompt(arrival({ hidden: true }), mem).why, 'hidden');
// a sheet is already up
assert.equal(autoPrompt(arrival({ busy: true }), mem).why, 'busy');
// not enrolled, or a browser tab: no button, no sheet
assert.equal(autoPrompt(arrival({ touch: false }), mem).why, 'no-touch');
// not locked
assert.equal(autoPrompt(arrival({ phase: 'home' }), mem).why, 'not-locked');
});
test('never on an idle lock, a hand lock, the window coming back, a cancelled sheet, or the updater relaunch', () => {
const mem = { used: false };
// the page opened on the home screen (the wallet was unlocked at arrival) and locked later: idle or by hand
assert.equal(autoPrompt(arrival({ reason: 'lock', firstPhase: 'home' }), mem).why, 'lock');
assert.equal(autoPrompt(arrival({ reason: 'lock' }), mem).why, 'lock');
// the window came back from the menu bar or the Dock
assert.equal(autoPrompt(arrival({ reason: 'focus' }), mem).why, 'focus');
// after a cancelled sheet the page waits for a tap
assert.equal(autoPrompt(arrival({ reason: 'cancel' }), mem).why, 'cancel');
// the first phase the page saw was not the lock screen (welcome, home): a later lock is not an arrival
assert.equal(autoPrompt(arrival({ firstPhase: 'home' }), mem).why, 'not-arrival');
assert.equal(autoPrompt(arrival({ firstPhase: 'welcome' }), mem).why, 'not-arrival');
// the updater opened the app (first run after an update), not the person
assert.equal(autoPrompt(arrival({ updatedFrom: '0.1.3' }), mem).why, 'updater');
// nothing in the context is read as a prompt
assert.equal(autoPrompt(null, null).prompt, false);
});
test('the idle lock choices and their labels', () => {
assert.deepEqual(L.IDLE_CHOICES, [1, 5, 15, 60, 0]);
assert.equal(L.IDLE_DEFAULT_MIN, 5);
assert.deepEqual(L.IDLE_CHOICES.map(idleLabel), ['1 minute', '5 minutes', '15 minutes', '1 hour', 'never']);
assert.equal(idleLabel('15'), '15 minutes'); assert.equal(idleLabel(undefined), 'never'); assert.equal(idleLabel(120), '2 hours');
});

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"/><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"/></svg>

Before

Width:  |  Height:  |  Size: 214 B

View file

@ -1,104 +0,0 @@
/* The update card (pure; update-card.test.mjs loads this file): the same card, rules and words as the miner's
(app/igneum-app/ui/app.js, UpdateCard), with the wallet's name and the wallet's reasons to wait.
A centred card over the window for one update: the mark with a progress ring, "Igneum Wallet 0.1.3", one line
(is available, is downloading, is ready to install, Installing, did not install), up to three lines of release
notes with the rest behind "What changed", the size, Install now and Later.
model() turns state.update into what the card says. decide() says whether it shows now, given the window
(ctx) and what the card already did (mem: open, later, seen):
deferred while the send screen is open, while Touch ID (or Windows Hello) is on screen, while a wallet is
being created or imported, while the app quits
later Later, Escape or the backdrop hides this version at this stage; the banner keeps it
back a newer version, or the download ready while automatic updates are off (with them on it installs
by itself, so a dismissed download stays dismissed); an open card follows its update to the end
installing and failed show only on an open card (Install now was pressed here); the banner carries the rest */
var UpdateCard = (function () {
'use strict';
var NAME = 'Igneum Wallet', LINE_MAX = 70, LINES = 3;
var INSTALL = { act: 'install', label: 'Install now', primary: true }, LATER = { act: 'later', label: 'Later' };
var RETRY = { act: 'retry', label: 'Try again', primary: true }, OPEN = { act: 'open', label: 'Open the download', primary: true };
function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; }
function firstLine(t, max) { t = String(t || '').split('\n')[0].trim(); max = max || 160; return t.length > max ? t.slice(0, max - 1).trim() + '…' : t; }
// the manifest's notes as sentences: split on line breaks, then after . ! ? ; (no lookbehind: the Mac WebView)
function sentences(text) {
var out = [], parts = String(text || '').split(/\n+/);
for (var i = 0; i < parts.length; i++) {
var p = parts[i], at = 0;
for (var j = 0; j < p.length; j++) {
var ch = p.charAt(j), next = p.charAt(j + 1);
if ((ch === '.' || ch === '!' || ch === '?' || ch === ';') && (next === ' ' || next === '')) { push(p.slice(at, j + 1)); at = j + 1; }
}
push(p.slice(at));
}
function push(s) { s = s.trim().replace(/[;:,]+$/, ''); if (s) out.push(cap(s)); }
return out;
}
// at most LINES lines, each under LINE_MAX characters (cut at a word); more = something is left for "What changed"
function splitNotes(text) {
var all = sentences(text), lines = [], more = all.length > LINES;
for (var i = 0; i < all.length && lines.length < LINES; i++) {
var s = all[i].replace(/\.$/, '');
if (s.length >= LINE_MAX) { var cut = s.lastIndexOf(' ', LINE_MAX - 2); s = s.slice(0, cut > 20 ? cut : LINE_MAX - 2).replace(/[,;:]$/, '') + '…'; more = true; }
lines.push(s);
}
return { lines: lines, more: more, all: all };
}
function size(bytes) { if (!(bytes > 0)) return ''; return bytes < 1e6 ? (bytes / 1e6).toFixed(1) + ' MB' : Math.round(bytes / 1e6) + ' MB'; }
function card(stage, u, over) {
var ver = u.version || '';
var m = { stage: stage, version: ver, key: 'update:' + ver + ':' + (stage === 'available' || stage === 'downloading' || stage === 'staging' ? 'pending' : stage), name: NAME + ' ' + ver, line: '', note: '', cause: '', size: size(u.size), pct: -1, ring: 'none', actions: [], dismissable: true, auto: !!u.auto };
var n = splitNotes(u.notes); m.lines = n.lines; m.more = n.more; m.all = n.all;
if (over) for (var k in over) m[k] = over[k];
return m;
}
// u = state.update; s = { version, quitting }
function model(u, s) {
if (!u || !u.version) return null;
s = s || {};
var pct = u.progress > 0 ? Math.min(100, Math.round(u.progress * 100)) : 0;
if (u.status === 'error') {
if (/no update manifest configured/.test(u.error || '')) return null;
return card('failed', u, { line: u.rolled_back ? 'did not stay up and was rolled back.' : 'did not install.', cause: firstLine(u.error, 120), ring: 'failed', actions: [RETRY, LATER], lines: [], more: false });
}
if (u.applying || u.status === 'applying' || (u.status === 'ready' && u.wait === 'installing now')) {
return card('installing', u, { line: 'Installing. The app restarts itself.', note: s.quitting ? 'A moment.' : 'Your key stays where it is.', ring: 'busy', dismissable: false, lines: [], more: false });
}
var m = null;
switch (u.status) {
case 'available': m = card('available', u, { line: 'is available.', actions: [INSTALL, LATER] }); break;
case 'downloading': m = card('downloading', u, { line: 'is downloading.', pct: pct, ring: 'progress', actions: [INSTALL, LATER] }); break;
case 'staging': m = card('staging', u, { line: 'is being checked.', pct: 100, ring: 'progress', actions: [INSTALL, LATER] }); break;
case 'ready':
var why = /failed to install before/.test(u.wait || '') ? 'It failed to install before.' : u.auto ? 'It installs by itself when nothing is being sent.' : '';
m = card('ready', u, { line: 'is ready to install.', note: why, ring: 'full', actions: [INSTALL, LATER] }); break;
case 'deferred': m = card('deferred', u, { line: 'is waiting for permission.', note: 'It installs the next time someone is at this PC.', ring: 'full', actions: [INSTALL, LATER] }); break;
case 'manual': m = card('manual', u, { line: 'is downloaded.', note: 'Open the disk image and drag the app over the old one.', ring: 'full', actions: [OPEN, LATER] }); break;
}
if (!m) return null;
if (u.urgent && u.urgent_text) { m.note = u.urgent_text; m.dismissable = false; m.actions = m.actions.filter(function (a) { return a.act !== 'later'; }); }
return m;
}
// why the card may not open now: ctx = { phase, view, bioBusy, bioLine, bioName, quitting }
function blocked(ctx) {
ctx = ctx || {};
if (ctx.quitting) return 'the app is quitting';
if (ctx.phase === 'create' || ctx.phase === 'import') return 'a wallet is being ' + (ctx.phase === 'create' ? 'created' : 'imported');
if (ctx.view === 'send') return 'the send screen is open';
if (ctx.bioBusy || ctx.bioLine) return (ctx.bioName || 'Touch ID') + ' is on screen';
return '';
}
// mem = { open: the card is up, later: the key Later was pressed on, seen: the version the card was shown for }
function decide(m, ctx, mem) {
mem = mem || {};
if (!m) return { show: false, why: 'none' };
var b = blocked(ctx);
if (b && m.stage !== 'installing') return { show: false, why: 'deferred', reason: b };
if (m.stage === 'installing' || m.stage === 'failed') return mem.open ? { show: true, why: 'open' } : { show: false, why: 'strip' };
if (!m.dismissable) return { show: true, why: 'urgent' };
if (mem.later === m.key) return { show: false, why: 'later' };
if (mem.open) return { show: true, why: 'open' };
if (m.stage === 'ready' && m.auto && mem.seen === m.version) return { show: false, why: 'auto' };
return { show: true, why: m.stage === 'ready' ? 'ready' : 'new' };
}
return { NAME: NAME, LINE_MAX: LINE_MAX, LINES: LINES, sentences: sentences, splitNotes: splitNotes, size: size, model: model, blocked: blocked, decide: decide };
})();
if (typeof module === 'object' && module && module.exports) module.exports = UpdateCard;

View file

@ -1,97 +0,0 @@
// node --test app/igneum-wallet/ui/update-card.test.mjs (no dependencies)
// Loads ui/update-card.js (plain browser JS, run with `module` defined and no `document`) and checks what the card
// says for each update state, the release-note lines, and when it shows or waits (a send, Touch ID, a wallet flow).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'update-card.js'), 'utf8');
const mod = { exports: {} };
new Function('module', src)(mod);
const C = mod.exports;
const { model, decide, blocked, splitNotes, sentences, size } = C;
const NOTES = 'The update card: one centred card with Install now and Later. Touch ID confirms every send on the Mac; Windows Hello is written but untested. The coin and the chain line sit on the balance card. The version shows in the header and in Settings.';
const upd = (over) => ({ status: 'ready', version: '0.1.3', url: '', notes: NOTES, file: '', error: '', checked_at: 0, available: true, downloaded: true, ready: true, applying: false, progress: 1, size: 19_479_807, auto: true, wait: '', urgent: false, urgent_text: '', unsupported: false, min_supported: '', updated_from: '', rolled_back: '', ...over });
const quiet = { phase: 'home', view: 'overview', bioBusy: false, bioLine: false, bioName: 'Touch ID', quitting: false };
test('release notes: sentences, three lines under 70 characters, the rest behind What changed', () => {
assert.deepEqual(sentences('one. two! three? four; five'), ['One.', 'Two!', 'Three?', 'Four', 'Five']);
assert.deepEqual(sentences('v0.1.3 ships. 24 words.'), ['V0.1.3 ships.', '24 words.']);
const n = splitNotes(NOTES);
assert.equal(n.lines.length, 3);
for (const l of n.lines) assert.ok(l.length < C.LINE_MAX, `${l.length}: ${l}`);
assert.deepEqual(n.lines, ['The update card: one centred card with Install now and Later', 'Touch ID confirms every send on the Mac', 'Windows Hello is written but untested']);
assert.equal(n.more, true); assert.equal(n.all.length, 5);
assert.deepEqual(splitNotes('coin on the home screen, updates install by themselves'), { lines: ['Coin on the home screen, updates install by themselves'], more: false, all: ['Coin on the home screen, updates install by themselves'] });
assert.deepEqual(splitNotes('').lines, []);
assert.equal(size(19_479_807), '19 MB'); assert.equal(size(0), '');
});
test('what the card says: available, downloading with the ring, ready, installing, failed, manual, waiting', () => {
const a = model(upd({ status: 'available', downloaded: false, ready: false, progress: 0 }), {});
assert.equal(a.name, 'Igneum Wallet 0.1.3'); assert.equal(a.line, 'is available.'); assert.equal(a.key, 'update:0.1.3:pending');
assert.equal(a.size, '19 MB'); assert.deepEqual(a.actions.map((x) => x.label), ['Install now', 'Later']);
const d = model(upd({ status: 'downloading', downloaded: false, ready: false, progress: 0.43 }), {});
assert.equal(d.line, 'is downloading.'); assert.equal(d.pct, 43); assert.equal(d.ring, 'progress'); assert.equal(d.key, a.key);
const r = model(upd(), {});
assert.equal(r.line, 'is ready to install.'); assert.equal(r.note, 'It installs by itself when nothing is being sent.'); assert.equal(r.key, 'update:0.1.3:ready');
assert.equal(model(upd({ auto: false, wait: 'waiting for Install now (automatic updates are off)' }), {}).note, '');
assert.equal(model(upd({ wait: 'a send is in flight; installing after it' }), {}).note, 'It installs by itself when nothing is being sent.');
const i = model(upd({ status: 'applying', applying: true }), {});
assert.equal(i.stage, 'installing'); assert.equal(i.line, 'Installing. The app restarts itself.'); assert.deepEqual(i.actions, []); assert.equal(i.dismissable, false);
assert.equal(model(upd({ wait: 'installing now' }), {}).stage, 'installing');
const f = model(upd({ status: 'error', error: 'sha256 mismatch: the file is not what the manifest signed\nsecond line', ready: false }), {});
assert.equal(f.line, 'did not install.'); assert.equal(f.cause, 'sha256 mismatch: the file is not what the manifest signed');
assert.deepEqual(f.actions.map((x) => x.label), ['Try again', 'Later']);
assert.equal(model(upd({ status: 'error', error: 'did not stay up', rolled_back: '0.1.3: did not stay up' }), {}).line, 'did not stay up and was rolled back.');
assert.equal(model(upd({ status: 'manual', ready: false }), {}).actions[0].label, 'Open the download');
assert.equal(model(upd({ status: 'deferred' }), {}).line, 'is waiting for permission.');
const u = model(upd({ status: 'downloading', progress: 0.2, urgent: true, urgent_text: 'This version is no longer supported. Installing 0.1.3 now.' }), {});
assert.equal(u.dismissable, false); assert.deepEqual(u.actions.map((x) => x.label), ['Install now']);
assert.equal(model(upd({ status: 'current', available: false }), {}), null);
assert.equal(model(upd({ status: 'off', version: '' }), {}), null);
assert.equal(model(upd({ status: 'error', error: 'no update manifest configured in this build' }), {}), null);
assert.equal(model(null, {}), null);
});
test('deferred: the send screen, Touch ID on screen, a wallet being created or imported, quitting', () => {
assert.equal(blocked(quiet), '');
assert.equal(blocked({ ...quiet, view: 'send' }), 'the send screen is open');
assert.equal(blocked({ ...quiet, bioBusy: true }), 'Touch ID is on screen');
assert.equal(blocked({ ...quiet, bioLine: true, bioName: 'Windows Hello' }), 'Windows Hello is on screen');
assert.equal(blocked({ ...quiet, phase: 'create' }), 'a wallet is being created');
assert.equal(blocked({ ...quiet, phase: 'import' }), 'a wallet is being imported');
assert.equal(blocked({ ...quiet, phase: 'unlock' }), '');
assert.equal(blocked({ ...quiet, quitting: true }), 'the app is quitting');
const a = model(upd({ status: 'available' }), {});
assert.deepEqual(decide(a, { ...quiet, view: 'send' }, {}), { show: false, why: 'deferred', reason: 'the send screen is open' });
assert.deepEqual(decide(a, { ...quiet, bioBusy: true }, {}), { show: false, why: 'deferred', reason: 'Touch ID is on screen' });
// the block lifts: the card comes (it was queued, not dismissed)
assert.equal(decide(a, quiet, { open: false, later: '', seen: '' }).show, true);
assert.equal(decide(model(upd({ status: 'applying', applying: true }), {}), { ...quiet, view: 'send' }, { open: true }).show, true);
});
test('Later: hides this version at this stage; back for a newer version or a ready download with auto off', () => {
const pend = model(upd({ status: 'downloading', progress: 0.5, ready: false }), {});
const ready = model(upd(), {});
const readyOff = model(upd({ auto: false }), {});
assert.deepEqual(decide(pend, quiet, {}), { show: true, why: 'new' });
const later = { open: false, later: pend.key, seen: '0.1.3' };
assert.deepEqual(decide(pend, quiet, later), { show: false, why: 'later' });
assert.deepEqual(decide(ready, quiet, later), { show: false, why: 'auto' });
assert.deepEqual(decide(readyOff, quiet, later), { show: true, why: 'ready' });
assert.deepEqual(decide(readyOff, quiet, { open: false, later: readyOff.key, seen: '0.1.3' }), { show: false, why: 'later' });
assert.deepEqual(decide(model(upd({ status: 'available', version: '0.1.4' }), {}), quiet, later), { show: true, why: 'new' });
assert.deepEqual(decide(ready, quiet, { open: false, later: '', seen: '' }), { show: true, why: 'ready' });
const open = { open: true, later: '', seen: '0.1.3' };
assert.equal(decide(pend, quiet, open).why, 'open'); assert.equal(decide(ready, quiet, open).why, 'open');
assert.equal(decide(model(upd({ status: 'applying', applying: true }), {}), quiet, open).show, true);
assert.equal(decide(model(upd({ status: 'error', error: 'x' }), {}), quiet, open).show, true);
assert.deepEqual(decide(model(upd({ status: 'applying', applying: true }), {}), quiet, {}), { show: false, why: 'strip' });
assert.deepEqual(decide(model(upd({ status: 'error', error: 'x' }), {}), quiet, {}), { show: false, why: 'strip' });
const urgent = model(upd({ status: 'downloading', progress: 0.2, urgent: true, urgent_text: 'Unsupported.' }), {});
assert.deepEqual(decide(urgent, quiet, { later: urgent.key }), { show: true, why: 'urgent' });
});

View file

@ -1,210 +0,0 @@
// node --test app/igneum-wallet/ui/view.test.mjs (no dependencies)
// Loads the View block of app.js (plain browser JS: the file is run with `module` defined and no `document`, so only
// the pure block executes) and checks the words each page shows for a given state (wallet-ui-3).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js'), 'utf8');
const mod = { exports: {} };
new Function('module', src)(mod);
const V = mod.exports.View;
const ME = '0x7e5f4552091a69125d5dfcb7b8c2659029395bdf', B = '0x2b5ad5c4795c026514f8317c7a215e218dccd6cf';
const NOW = 1_791_000_000;
const node = (over) => ({ source: 'miner', state: 'ok', chain_id: 7762959, chain: 'igneum-devnet-20', block: 140286, evm: 'http://127.0.0.1:26790', grpc: '127.0.0.1:26610', synced: true, message: "using the miner app's node on this machine", finality_active: true, latest_locked: 4674, ...over });
const fin = (over) => ({ verified_index: 4674, verified_hash: '0xf1', chain_number: 140246, signers: 11, voters: 12, fraction_total: 0.912, fraction_active: 0.98, weights_exact: true, verified_at: NOW - 14, message: 'checkpoint 4674 verified here', ...over });
const st = (over) => ({ version: '0.1.5', phase: 'home', balance: '31.6321', balance_known: true, scanning: false, scanned_to: 140286, node: node(), finality: fin(), settings: { network: 'devnet', start_at_login: false, auto_update: true, idle_lock_min: 5, ask_on_open: true }, update: { status: 'current', version: '0.1.5', checked_at: NOW - 780 }, now: NOW, ...over });
const entry = (over) => ({ hash: '0x' + 'c3d4'.repeat(16), kind: 'received', block: 140262, block_hash: '0xabcd', from: B, to: ME, value: '250000000000000000', fee: '25380000000000', ok: true, time: NOW - 140, finality: 'in_block', checkpoint: null, note: '', ...over });
test('the five pages and their order', () => {
assert.deepEqual(V.PAGES.map((p) => p.id), ['home', 'send', 'receive', 'history', 'settings']);
assert.equal(V.page('history').title, 'History');
assert.equal(V.page('nonsense').id, 'home');
});
test('numbers: IGN from wei, short addresses, times', () => {
assert.equal(V.ign('1500000000000000000'), '1.5');
assert.equal(V.ign('250000000000000000', 6), '0.25');
assert.equal(V.ign('10140000000000000000', 4), '10.14');
assert.equal(V.ign('0'), '0');
assert.equal(V.ign('nonsense'), '0');
assert.equal(V.ign('76140000000000', 9), '0.00007614');
assert.equal(V.shortHex('0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf'), '0x7E5F45\u20265Bdf');
assert.equal(V.shortHex(''), '');
assert.equal(V.withCommas(140286), '140,286');
assert.equal(V.ago(14), '14 s ago'); assert.equal(V.ago(780), '13 min ago'); assert.equal(V.ago(7200), '2 h ago');
assert.match(V.timeWord(NOW - 140, NOW), /^\d\d:\d\d$/);
assert.match(V.timeWord(NOW - 86400 * 3, NOW), /^\d+ \w+ \d\d:\d\d$/);
});
test('the balance: the number only when known, else one line that says why (never a 0)', () => {
assert.deepEqual(V.balanceLine(st()), { known: true, text: '' });
assert.deepEqual(V.balanceLine(st({ balance_known: false })), { known: false, text: 'reading the balance' });
assert.deepEqual(V.balanceLine(st({ balance_known: false, scanning: true, scanned_to: 61200, node: node({ block: 140270 }) })), { known: false, text: 'reading the chain, 61,200 of 140,270 blocks' });
assert.deepEqual(V.balanceLine(st({ balance_known: false, node: node({ state: 'starting' }) })), { known: false, text: 'waiting for the node to start' });
assert.deepEqual(V.balanceLine(st({ balance_known: false, node: node({ state: 'off', source: 'none' }) })), { known: false, text: 'waiting for a node' });
});
test('money first when a price exists; else the one grey line, devnet or not', () => {
assert.deepEqual(V.moneyLine(st()), { money: '', text: 'no market price on devnet: coins have no value' });
assert.deepEqual(V.moneyLine(st({ settings: { network: 'testnet' } })), { money: '', text: 'no market price yet' });
assert.deepEqual(V.moneyLine(st({ price_gbp_per_ign: 0.5 })), { money: '\u00a315.82', text: "at today's price" });
// a price with an unknown balance is no money line
assert.equal(V.moneyLine(st({ price_gbp_per_ign: 0.5, balance_known: false })).money, '');
});
test('the node line: the state word, the source in plain words, the block, the verification', () => {
assert.deepEqual(V.nodeLine(st()), { text: "Node synced \u00b7 the miner app's node \u00b7 block 140,286 \u00b7 verified to checkpoint 4,674", sub: '', tone: 'ok' });
const pub = st({ node: node({ source: 'public', grpc: '', finality_active: false, latest_locked: 0, message: 'using the public RPC; no node here, so finality cannot be verified' }), finality: fin({ verified_index: 0, chain_number: null, message: 'no node here' }) });
assert.equal(V.nodeLine(pub).text, 'Node synced \u00b7 the public RPC \u00b7 block 140,286 \u00b7 certificates cannot be verified without a node');
const own = st({ node: node({ source: 'own', state: 'starting', block: 0, message: 'the bundled node is starting; the chain syncs from the seed' }), finality: fin({ verified_index: 0 }) });
assert.deepEqual(V.nodeLine(own), { text: 'Node starting \u00b7 the bundled node', sub: 'the bundled node is starting; the chain syncs from the seed', tone: '' });
const none = st({ node: node({ source: 'none', state: 'off', block: 0, message: 'no node: install Igneum Miner, or wait for the bundled node' }), finality: fin({ verified_index: 0 }) });
assert.deepEqual(V.nodeLine(none), { text: 'Node not found', sub: 'no node: install Igneum Miner, or wait for the bundled node', tone: 'bad' });
const lost = V.nodeLine(st({ node: node({ state: 'lost', message: 'history: connect 127.0.0.1:26790: Connection refused (os error 61)' }) }));
assert.equal(lost.tone, 'bad'); assert.equal(lost.sub, 'the node stopped answering; trying again');
assert.equal(V.nodeLine(st({ finality: fin({ verified_index: 0 }) })).text, "Node synced \u00b7 the miner app's node \u00b7 block 140,286 \u00b7 waiting for the first certificate");
assert.equal(V.sourceWords('external'), 'the node named by the environment');
});
test('the node details: every old card row with a one-line meaning, no n/a', () => {
const rows = V.nodeDetails(st(), NOW);
const keys = rows.map((r) => r[0]);
assert.deepEqual(keys, ['source', 'chain', 'block', 'endpoint', 'finality on the node', 'verified here', 'signed by', 'checkpoint height', 'weights', 'checked']);
assert.equal(rows[5][1], 'checkpoint 4,674'); assert.equal(rows[5][3], 'ok');
assert.equal(rows[6][1], '11 of 12 voters'); assert.match(rows[6][2], /^91\.2% of all weight/);
assert.equal(rows[9][1], '14 s ago');
for (const r of rows) assert.ok(!/n\/a/.test(r[1]) && r[1] !== '', r[0]);
const none = V.nodeDetails(st({ node: node({ source: 'public', evm: 'https://rpc.example', finality_active: false }), finality: fin({ verified_index: 0, message: 'no node here' }) }), NOW);
assert.equal(none.find((r) => r[0] === 'finality on the node')[1], 'not checkable without a node');
assert.deepEqual(none.find((r) => r[0] === 'verified here').slice(1), ['nothing yet', 'no node here', 'dim']);
});
test('the pill: the wallet\'s own words', () => {
assert.deepEqual(V.pillWords(st()), { text: 'synced', cls: 'on' });
assert.deepEqual(V.pillWords(st({ node: node({ source: 'public' }) })), { text: 'public rpc', cls: 'on' });
assert.deepEqual(V.pillWords(st({ node: node({ state: 'starting' }) })), { text: 'starting', cls: '' });
assert.deepEqual(V.pillWords(st({ node: node({ state: 'off', source: 'none' }) })), { text: 'no node', cls: 'warn' });
assert.deepEqual(V.pillWords(st({ node: node({ state: 'lost' }) })), { text: 'node lost', cls: 'warn' });
assert.deepEqual(V.pillWords(st({ phase: 'unlock' })), { text: 'locked', cls: '' });
assert.deepEqual(V.pillWords(st({ quitting: true })), { text: 'stopping', cls: '' });
});
test('one state word per row: the wallet\'s verified final wins, failed from the receipt, else the node\'s word, else the label', () => {
const e = entry();
assert.deepEqual(V.stateWord(entry({ finality: 'final', checkpoint: 4673, block: 139900 }), 'executed'), { word: 'finalised', tone: 'ok', why: 'under checkpoint 4,673, verified here' });
assert.deepEqual(V.stateWord(entry({ finality: 'failed', ok: false }), 'executed'), { word: 'failed', tone: 'bad', why: 'the execution failed; the fee was still paid' });
assert.deepEqual(V.stateWord(e, 'pending'), { word: 'pending', tone: '', why: 'waiting for a block' });
assert.deepEqual(V.stateWord(e, 'included'), { word: 'included', tone: '', why: 'in block 140,262, not executed yet' });
assert.deepEqual(V.stateWord(e, 'executed'), { word: 'executed', tone: 'ink', why: 'in block 140,262' });
assert.deepEqual(V.stateWord(e, 'proven'), { word: 'proven', tone: 'ink', why: 'in block 140,262, proof paid' });
// the node says finalised but this wallet has not verified the certificate: the word, not the molten tone
assert.deepEqual(V.stateWord(e, 'finalised'), { word: 'finalised', tone: 'ink', why: 'under a locked checkpoint, not yet verified here' });
// section 9 of docs/spec/finality-guarantees.md (8 October 2026): a block under a locked checkpoint is finalised even while
// the network's finality is paused; the pause is the network's state word, never a transaction's. Known-failed first: the
// node's "finality not active" word was shown on the row.
assert.deepEqual(V.stateWord(e, 'finality not active'), { word: 'finalised', tone: 'ink', why: 'under a locked checkpoint, not yet verified here; finality is paused on the network above it' });
// review B F04 (the founder, 8 October 2026): a recovery lock is labelled "recovery", never plain "final": the node's word
// for a transaction under one (the node lane's lockKind) reads on the row as finalised by a recovery lock. Known-failed
// first: the word fell through to the label.
assert.deepEqual(V.stateWord(e, 'finalised by a recovery lock'), { word: 'finalised (recovery lock)', tone: 'ink', why: 'under a recovery lock: the surviving majority’s lock after a long pause, not a certified checkpoint; not yet verified here' });
assert.equal(V.stateWord(e, 'unknown').word, 'pending');
// no node word yet (the public RPC, or not asked): the wallet's label
assert.deepEqual(V.stateWord(e, ''), { word: 'included', tone: '', why: 'in block 140,262' });
assert.deepEqual(V.stateWord(entry({ finality: 'pending', block: 0 }), ''), { word: 'pending', tone: '', why: 'waiting for a block' });
assert.equal(V.stateWord(entry({ finality: 'pending', block: 0 }), 'included').why, 'in a block, not executed yet');
// a reward has no transaction hash: executed in its block, finalised under a verified checkpoint
const r = entry({ hash: 'reward-140201-0', kind: 'reward', from: '', block: 140201 });
assert.deepEqual(V.stateWord(r, ''), { word: 'executed', tone: 'ink', why: 'in block 140,201' });
assert.equal(V.stateWord(entry({ hash: 'proving-1-0', kind: 'proving', finality: 'final', checkpoint: 9 }), '').word, 'finalised');
// never a stronger word than the chain's: the words the row can show are exactly the node's plus failed
const words = new Set(['pending', 'included', 'executed', 'proven', 'finalised', 'finalised (recovery lock)', 'failed']);
for (const w of ['pending', 'included', 'executed', 'proven', 'finalised', 'finality not active', 'finalised by a recovery lock', 'unknown', '']) assert.ok(words.has(V.stateWord(e, w).word), w);
});
test('the row model: kind word, who, sign, amount, the rows that need the node\'s word', () => {
const m = V.rowModel(entry(), 'included', NOW);
assert.equal(m.kind, 'Received'); assert.equal(m.who, 'from 0x2b5ad5\u2026d6cf'); assert.equal(m.amount, '+0.25'); assert.equal(m.in, true); assert.equal(m.synthetic, false); assert.equal(m.nodeWord, 'included');
const s = V.rowModel(entry({ kind: 'sent', from: ME, to: B, value: '1500000000000000000' }), '', NOW);
assert.equal(s.kind, 'Sent'); assert.equal(s.who, 'to 0x2b5ad5\u2026d6cf'); assert.equal(s.amount, '\u22121.5'); assert.equal(s.in, false);
assert.equal(V.rowModel(entry({ kind: 'self', from: ME, to: ME }), '', NOW).who, 'to yourself');
assert.equal(V.rowModel(entry({ hash: 'reward-1-0', kind: 'reward' }), '', NOW).who, 'block reward');
assert.equal(V.rowModel(entry({ hash: 'proving-1-0', kind: 'proving' }), '', NOW).kind, 'Proving payout');
const list = [entry({ hash: '0x01', finality: 'pending' }), entry({ hash: '0x02' }), entry({ hash: 'reward-1-0', kind: 'reward' }), entry({ hash: '0x03', finality: 'final', checkpoint: 1 }), entry({ hash: '0x04', finality: 'failed' }), entry({ hash: '0x05' })];
assert.deepEqual(V.needsNodeWord(list, 12), ['0x01', '0x02', '0x05']);
assert.deepEqual(V.needsNodeWord(list, 2), ['0x01', '0x02']);
});
test('send: the fee line, the gas words behind Details, the question in place', () => {
const q = { to: B, display_to: '0x2B5AD5c4795c026514f8317c7a215E218DCCD6cF', value_ign: '1.5', fee_max_ign: '0.00007614', total_max_ign: '1.50007614', gas: 25380, base_fee_gwei: '1', tip_gwei: '1', max_fee: '3000000000' };
assert.deepEqual(V.feeWords(null), { line: 'shown when you review', detail: '' });
const f = V.feeWords(q);
assert.equal(f.line, 'at most 0.00007614 IGN');
assert.equal(f.detail, 'Gas 25,380 at a base fee of 1 gwei (burned) plus a 1 gwei tip (to the miner), capped at 3 gwei; what is not used comes back.');
assert.equal(V.gwei('1500000000'), '1.5 gwei'); assert.equal(V.gwei('25000000000'), '25 gwei'); assert.equal(V.gwei('250000'), '0.00025 gwei'.replace('0.00025', '0'));
assert.deepEqual(V.sendAsk(q, true, 'Touch ID'), { text: 'Send 1.5 IGN to 0x2B5AD5\u2026D6cF? Fee at most 0.00007614 IGN; 1.50007614 IGN leaves the wallet at most.', button: 'Confirm with Touch ID' });
assert.equal(V.sendAsk(q, false).button, 'Send now');
assert.equal(V.sendAsk(q, true, 'Windows Hello').button, 'Confirm with Windows Hello');
});
test('settings: the Touch ID sentence, the idle sentence, the update card note', () => {
assert.equal(V.bioSentence({ enrolled: true }, 'Touch ID', true), 'Touch ID is on. It unlocks the wallet, confirms each send and shows the backup; the password still works everywhere.');
assert.equal(V.bioSentence({ enrolled: false, available: true }, 'Touch ID', false), 'Touch ID needs the Igneum Wallet app window; this page is open in a browser.');
assert.equal(V.bioSentence({ enrolled: false, available: false }, 'Touch ID', true), 'Touch ID is not set up on this Mac.');
assert.equal(V.bioSentence({ enrolled: false, available: false, message: 'Windows Hello is not configured.' }, 'Windows Hello', true), 'Windows Hello is not configured.');
assert.equal(V.bioSentence({ enrolled: false, available: true }, 'Windows Hello', true), 'Unlock, confirm each send and show the backup with Windows Hello. The password still works everywhere.');
assert.equal(V.idleSentence(5, 'Touch ID'), 'The key is cleared after 5 minutes without you; Touch ID or the password opens it again.');
assert.equal(V.idleSentence(0, 'Touch ID'), 'The wallet stays open until you lock it.');
assert.equal(V.updateNote({ status: 'current', version: '0.1.5', checked_at: NOW - 780 }, NOW), 'Up to date, checked 13 min ago.');
assert.equal(V.updateNote({ status: 'unknown', version: '' }, NOW), 'Not checked yet.');
assert.equal(V.updateNote({ status: 'ready', version: '0.1.6', wait: 'installs as soon as nothing is being sent' }, NOW), 'Igneum Wallet 0.1.6 is ready. Installs as soon as nothing is being sent.');
assert.equal(V.updateNote({ status: 'current', version: '0.1.5', updated_from: '0.1.4', checked_at: NOW - 60 }, NOW), 'Updated from 0.1.4. Up to date, checked 1 min ago.');
assert.equal(V.updateNote({ status: 'off', version: '' }, NOW), 'Updates are off in this build.');
assert.equal(V.updateLine({ status: 'downloading', version: '0.1.6', size: 20080717, progress: 0.43 }).text, 'Downloading Igneum Wallet 0.1.6 (20 MB): 43%');
assert.equal(V.updateLine({ status: 'current', version: '0.1.5' }), null);
});
// the page bridge (0.1.6, 8 October 2026): what the window says for a page's request (connect, a transaction, a message,
// typed data), the Settings card's site rows, the lock-screen line while a page waits. Known-failed first on 0.1.5:
// View.bridgeWords is not a function.
test('the page bridge: the words for each request kind, the site rows, the waiting line', () => {
const connect = V.bridgeWords({ id: '1-ab', kind: 'connect', origin: 'https://igneum.network', created_at: 1 });
assert.equal(connect.title, 'igneum.network wants to connect');
assert.equal(connect.lines[0], 'It will see your address and may ask you to sign or send. Nothing leaves without your word here.');
assert.equal(connect.yes, 'Connect'); assert.equal(connect.no, 'Decline');
const send = V.bridgeWords({ id: '2-cd', kind: 'send', origin: 'https://igneum.network', to: '0x9a6fa842c4e58a87aef1f3ad15233d99283002b7', to_display: '0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7', value: '1000000000000000000', value_ign: '1', data_len: 36, selector: '0x38ed1739', gas: 184000, fee_max_ign: '0.000368', total_max_ign: '1.000368', confirm_needed: false });
assert.equal(send.title, 'igneum.network asks you to send');
assert.equal(send.amount, '1 IGN');
assert.equal(send.lines[0], 'To 0x9a6fA8…02B7, a contract call (36 bytes, 0x38ed1739).');
assert.equal(send.lines[1], 'Fee up to 0.000368 IGN (184,000 gas). Total up to 1.000368 IGN.');
assert.equal(send.yes, 'Send'); assert.equal(send.no, 'Decline');
const plain = V.bridgeWords({ id: '3', kind: 'send', origin: 'https://igneum.network', to_display: '0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7', value_ign: '0.5', data_len: 0, selector: '', gas: 21000, fee_max_ign: '0.000042', total_max_ign: '0.500042' });
assert.equal(plain.lines[0], 'To 0x9a6fA8…02B7, a plain transfer.');
const unpriced = V.bridgeWords({ id: '4', kind: 'send', origin: 'https://igneum.network', to_display: '0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7', value: '0', data_len: 4, selector: '0xd0e30db0' });
assert.equal(unpriced.amount, '0 IGN'); assert.equal(unpriced.lines[1], 'Pricing the fee with the node.');
const touch = V.bridgeWords({ id: '5', kind: 'send', origin: 'https://igneum.network', to_display: '0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7', value_ign: '1', data_len: 0, selector: '', gas: 21000, fee_max_ign: '0.00004', total_max_ign: '1.00004', confirm_needed: true }, 'Touch ID');
assert.equal(touch.yes, 'Send with Touch ID');
const sign = V.bridgeWords({ id: '6', kind: 'sign', origin: 'https://igneum.network', text: 'Sign in to Igneum Swap\nnonce: 42', bytes: 31 });
assert.equal(sign.title, 'igneum.network asks you to sign a message');
assert.equal(sign.message, 'Sign in to Igneum Swap\nnonce: 42');
assert.equal(sign.lines[0], 'Signing proves this wallet is yours. It moves no coins.');
assert.equal(sign.yes, 'Sign');
const bin = V.bridgeWords({ id: '7', kind: 'sign', origin: 'https://igneum.network', text: null, bytes: 32, hex: '0x0102' });
assert.equal(bin.message, '32 bytes: 0x0102');
const typed = V.bridgeWords({ id: '8', kind: 'typed', origin: 'https://igneum.network', domain: 'Igneum Swap', primary_type: 'Permit', message: '{\n "owner": "0x1"\n}' });
assert.equal(typed.title, 'igneum.network asks you to sign typed data');
assert.equal(typed.lines[0], 'Permit for Igneum Swap. Read it before you sign: a permit can let a contract spend tokens.');
assert.equal(typed.message, '{\n "owner": "0x1"\n}');
// the lock-screen line and the site rows
assert.equal(V.bridgeWaiting([connect, send].map((x) => ({ origin: 'https://igneum.network' }))), 'igneum.network is waiting: unlock to answer it');
assert.equal(V.bridgeWaiting([]), '');
const rows = V.siteRows([{ origin: 'https://igneum.network', approved_at: 1_800_000_000 - 3600, last_seen: 1_800_000_000 - 30 }], 1_800_000_000);
assert.equal(rows[0].host, 'igneum.network'); assert.equal(rows[0].line, 'connected 1 h ago · last call 30 s ago');
assert.equal(V.siteRows([], 1).length, 0);
assert.equal(V.bridgeStatus({ on: true, listening: true, port: 26811, sites: [] }), 'On. Websites you approve can connect through port 26811 on this machine only.');
assert.equal(V.bridgeStatus({ on: true, listening: false, port: 26811, reason: 'port 26811 is not free: in use', sites: [] }), 'Not listening: port 26811 is not free: in use. Quit whatever holds the port and open the wallet again.');
assert.equal(V.bridgeStatus({ on: false, listening: true, port: 26811, sites: [] }), 'Off. No website can connect; the switch turns it on.');
});

View file

@ -1,328 +0,0 @@
// Touch ID for the Igneum window hosts (IgneumWallet.swift, IgneumMiner.swift), compiled into each with
// swiftc ... IgneumWallet.swift Biometric.swift -framework Cocoa -framework WebKit -framework LocalAuthentication
// 5 October 2026.
//
// The page in the WKWebView posts {id, op, ...} to the "biometric" script message handler; the host answers with
// window.__igneumBiometric(id, {ok, code, message, ...}). Ops: status, enrol, unlock (wallet), confirm.
//
// What is stored, and where. The packaging signs the apps ad hoc, and under an ad hoc signature macOS refuses any
// Keychain item with a biometric access control (errSecMissingEntitlement, -34018, on the login keychain and the
// data-protection keychain alike: keychain-access-groups needs a team signature). A Secure Enclave key with the same
// control is allowed, so the secret is sealed to one instead:
// - enrol: a P-256 key is made in the Secure Enclave with SecAccessControl(.privateKeyUsage, .biometryCurrentSet);
// an ephemeral P-256 key agrees a shared secret with its public half (no prompt), HKDF-SHA256 makes an AES-GCM key
// and the secret (the wallet's password; a fixed marker for the miner) is sealed. The file the engine named
// (<data root>/<app>/biometric.json, user-only permissions) holds the Secure Enclave key's wrapped form, the
// ephemeral public key and the box. The wrapped key is useless off this Mac's Secure Enclave.
// - unlock or confirm: the host evaluates LAPolicy.deviceOwnerAuthenticationWithBiometrics (no fallback button: the
// wallet's own password is the fallback, in the window), then uses the Secure Enclave key under that context.
// .biometryCurrentSet means a fingerprint added or removed in System Settings makes the key unusable: the host
// reports "invalidated" and the window asks for the password and a fresh enrolment.
// The secret never goes through the page: on unlock the host posts it to the engine on 127.0.0.1 with the engine's
// URL token; the engine's host token (X-Igneum-Host, read from the engine's stdout) marks the calls only the host
// may make (the confirmations, taking the parked password at enrolment).
import Foundation
import LocalAuthentication
import CryptoKit
import Security
final class Biometric {
let product: String
/// the prompt's fallback button: "Use password" for the wallet (the window then asks for it), "" for the miner
let fallbackTitle: String
/// the enrolment prompt's line: "Turn on Touch ID for your wallet" / "... for the miner"
let enrolReason: String
private(set) var engineURL = ""
private(set) var hostToken = ""
private(set) var file: URL?
private let queue = DispatchQueue(label: "network.igneum.biometric")
private let salt = Data("igneum-biometric-v1".utf8)
private let minerMarker = "igneum-biometric-marker-v1"
init(product: String, fallbackTitle: String, enrolReason: String) {
self.product = product
self.fallbackTitle = fallbackTitle
self.enrolReason = enrolReason
}
/// From the engine's HOST line. Reports availability to the engine at once.
func configure(engineURL: String, hostToken: String, file: String) {
self.engineURL = engineURL
self.hostToken = hostToken
self.file = file.isEmpty ? nil : URL(fileURLWithPath: file)
let s = status()
queue.async {
_ = self.post("api/biometric/status", ["available": s.available, "kind": "touchid", "message": s.message])
}
}
// ---- availability ----
func status() -> (available: Bool, message: String) {
let ctx = LAContext()
var err: NSError?
if ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &err) {
if ctx.biometryType != .touchID { return (false, "This Mac has no Touch ID sensor.") }
return (true, "")
}
return (false, Biometric.text(for: err, op: "status").message)
}
// ---- the ops, one at a time on the queue ----
func handle(_ msg: [String: Any], reply: @escaping ([String: Any]) -> Void) {
let op = msg["op"] as? String ?? ""
queue.async {
let r: [String: Any]
switch op {
case "status":
let s = self.status()
r = ["ok": true, "available": s.available, "kind": "touchid", "message": s.message, "enrolled": self.sealedFileExists()]
case "enrol": r = self.enrol(token: msg["token"] as? String)
case "unlock": r = self.unlock()
case "confirm": r = self.confirm(nonce: msg["nonce"] as? String ?? "")
default: r = ["ok": false, "code": "bad_op", "message": "unknown op \(op)"]
}
if op != "status" {
_ = self.post("api/biometric/report", ["op": op, "ok": r["ok"] as? Bool ?? false, "code": r["code"] as? String ?? "", "message": r["message"] as? String ?? ""])
}
DispatchQueue.main.async { reply(r) }
}
}
private func enrol(token: String?) -> [String: Any] {
guard let file = file else { return fail("unavailable", "The engine has not named the sealed file yet.") }
let s = status()
if !s.available { return fail("unavailable", s.message) }
let ctx = context()
if let e = evaluate(ctx, reason: enrolReason) { return e }
// the secret: the wallet's password from the engine (one-time token), or the miner's marker
var secret: Data
if let t = token, !t.isEmpty {
let r = post("api/biometric/enrol/take", ["token": t])
guard r.ok, let p = r.json["secret"] as? String else { return fail("engine", r.json["error"] as? String ?? "the engine did not hand over the password") }
secret = Data(p.utf8)
} else {
secret = Data(minerMarker.utf8)
}
defer { secret.resetBytes(in: 0..<secret.count) }
do {
var acErr: Unmanaged<CFError>?
guard let ac = SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, [.privateKeyUsage, .biometryCurrentSet], &acErr) else {
return fail("secure_enclave", "The access control could not be made: \(acErr?.takeRetainedValue().localizedDescription ?? "unknown")")
}
let key = try SecureEnclave.P256.KeyAgreement.PrivateKey(accessControl: ac, authenticationContext: ctx)
let eph = P256.KeyAgreement.PrivateKey()
let shared = try eph.sharedSecretFromKeyAgreement(with: key.publicKey)
let sym = shared.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data(product.utf8), outputByteCount: 32)
let box = try AES.GCM.seal(secret, using: sym)
guard let combined = box.combined else { return fail("seal", "The box has no combined form.") }
let doc: [String: Any] = ["version": 1, "kind": "touchid", "product": product, "created": Int(Date().timeIntervalSince1970),
"key": key.dataRepresentation.base64EncodedString(), "eph": eph.publicKey.rawRepresentation.base64EncodedString(), "box": combined.base64EncodedString()]
let data = try JSONSerialization.data(withJSONObject: doc, options: [.sortedKeys])
try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: file, options: [.atomic])
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: file.path)
} catch {
return fail("secure_enclave", "The Secure Enclave refused: \(error.localizedDescription)")
}
let r = post("api/biometric/enrolled", ["kind": "touchid"])
if !r.ok { return fail("engine", r.json["error"] as? String ?? "the engine did not record the enrolment") }
return ["ok": true]
}
/// Wallet: Touch ID, then the password out of the box and into the engine; never to the page.
private func unlock() -> [String: Any] {
guard sealedFileExists() else { return fail("not_enrolled", "Touch ID is not turned on for this wallet.") }
let ctx = context()
if let e = evaluate(ctx, reason: "Unlock your wallet") { return e }
switch open(ctx) {
case .failure(let e): return e.dict
case .success(var secret):
defer { secret.resetBytes(in: 0..<secret.count) }
guard let p = String(data: secret, encoding: .utf8) else { return fail("seal", "The sealed password did not decode.") }
let r = post("api/unlock", ["password": p])
if !r.ok { return fail("engine", r.json["error"] as? String ?? "the engine did not unlock") }
return ["ok": true]
}
}
/// The engine's challenge: its reason on the prompt, then the confirmation with the host token. The Secure
/// Enclave key is exercised too, so a changed fingerprint set is caught here as well.
private func confirm(nonce: String) -> [String: Any] {
guard !nonce.isEmpty else { return fail("bad_nonce", "No challenge.") }
let c = get("api/biometric/challenge?nonce=\(nonce)")
guard c.ok, let reason = c.json["reason"] as? String else { return fail("engine", c.json["error"] as? String ?? "the engine does not know this challenge") }
let ctx = context()
if let e = evaluate(ctx, reason: reason) { return e }
if sealedFileExists() {
if case .failure(let e) = agree(ctx) { return e.dict }
}
let r = post("api/biometric/confirm", ["nonce": nonce])
if !r.ok { return fail("engine", r.json["error"] as? String ?? "the engine refused the confirmation") }
return ["ok": true]
}
// ---- Touch ID ----
func context() -> LAContext {
let ctx = LAContext()
// the policy is biometrics only, so the fallback button never reaches the device password: "Use password"
// (the wallet) returns LAError.userFallback and the window asks for the wallet's own password; the miner
// has no password, so no button
ctx.localizedFallbackTitle = fallbackTitle
ctx.localizedCancelTitle = "Cancel"
return ctx
}
/// nil on success, else the reply for the page.
private func evaluate(_ ctx: LAContext, reason: String) -> [String: Any]? {
var err: NSError?
guard ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &err) else {
let t = Biometric.text(for: err, op: "evaluate")
return fail(t.code, t.message)
}
// macOS composes the sentence itself: "Igneum Wallet is trying to <reason>." (the bundled app's name and icon
// are the title), so the line starts lowercase here; the engine's canonical lines keep their capital for
// Windows Hello, which shows the line on its own
let line = String(reason.prefix(80))
let shown = line.prefix(1).lowercased() + line.dropFirst()
let sem = DispatchSemaphore(value: 0)
var ok = false
var failure: Error?
ctx.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: shown) { success, error in
ok = success
failure = error
sem.signal()
}
sem.wait()
if ok { return nil }
let t = Biometric.text(for: failure as NSError?, op: "evaluate")
return fail(t.code, t.message)
}
static func text(for err: NSError?, op: String) -> (code: String, message: String) {
guard let e = err else { return ("failed", "Touch ID did not succeed.") }
if e.domain == LAErrorDomain, let la = LAError.Code(rawValue: e.code) {
switch la {
case .userCancel, .systemCancel, .appCancel: return ("cancelled", "Touch ID was cancelled.")
case .authenticationFailed: return ("failed", "Touch ID did not match.")
case .biometryLockout: return ("locked", "Touch ID is locked after too many tries. Use the password; Touch ID comes back after the Mac is unlocked with its password.")
case .biometryNotEnrolled: return ("not_set_up", "Touch ID is not set up on this Mac. Add a fingerprint in System Settings > Touch ID & Password.")
case .biometryNotAvailable, .passcodeNotSet: return ("unavailable", "Touch ID is not available on this Mac.")
case .userFallback: return ("fallback", "Enter your password.")
default: break
}
}
return ("failed", "Touch ID did not succeed: \(e.localizedDescription)")
}
// ---- the sealed file ----
func sealedFileExists() -> Bool {
guard let f = file else { return false }
return FileManager.default.fileExists(atPath: f.path)
}
private struct Sealed {
let key: SecureEnclave.P256.KeyAgreement.PrivateKey
let eph: P256.KeyAgreement.PublicKey
let box: AES.GCM.SealedBox
}
private func load(_ ctx: LAContext) -> Result<Sealed, Reply> {
guard let f = file, let data = try? Data(contentsOf: f), let doc = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let k = doc["key"] as? String, let e = doc["eph"] as? String, let b = doc["box"] as? String,
let kd = Data(base64Encoded: k), let ed = Data(base64Encoded: e), let bd = Data(base64Encoded: b) else {
return .failure(failure("not_enrolled", "The sealed file is missing or unreadable. Turn Touch ID on again in Settings."))
}
do {
let key = try SecureEnclave.P256.KeyAgreement.PrivateKey(dataRepresentation: kd, authenticationContext: ctx)
let eph = try P256.KeyAgreement.PublicKey(rawRepresentation: ed)
let box = try AES.GCM.SealedBox(combined: bd)
return .success(Sealed(key: key, eph: eph, box: box))
} catch {
return .failure(failure("invalidated", "Touch ID no longer opens this: the sealed key is not usable (\(error.localizedDescription)). Use the password, then turn Touch ID on again in Settings."))
}
}
/// The key agreement under the authenticated context: the Secure Enclave refuses it when the fingerprint set
/// changed since enrolment (.biometryCurrentSet).
private func agree(_ ctx: LAContext) -> Result<SymmetricKey, Reply> {
switch load(ctx) {
case .failure(let e): return .failure(e)
case .success(let s):
do {
let shared = try s.key.sharedSecretFromKeyAgreement(with: s.eph)
return .success(shared.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data(product.utf8), outputByteCount: 32))
} catch {
return .failure(failure("invalidated", "Touch ID no longer opens this: a fingerprint was added or removed since it was turned on. Use the password, then turn Touch ID on again in Settings."))
}
}
}
private func open(_ ctx: LAContext) -> Result<Data, Reply> {
switch load(ctx) {
case .failure(let e): return .failure(e)
case .success(let s):
do {
let shared = try s.key.sharedSecretFromKeyAgreement(with: s.eph)
let sym = shared.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data(product.utf8), outputByteCount: 32)
return .success(try AES.GCM.open(s.box, using: sym))
} catch {
return .failure(failure("invalidated", "Touch ID no longer opens this: a fingerprint was added or removed since it was turned on. Use the password, then turn Touch ID on again in Settings."))
}
}
}
// ---- the engine on 127.0.0.1 ----
/// A reply for the page that is also an Error, so Result can carry it.
struct Reply: Error {
let dict: [String: Any]
}
private func fail(_ code: String, _ message: String) -> [String: Any] {
["ok": false, "code": code, "message": message]
}
private func failure(_ code: String, _ message: String) -> Reply {
Reply(dict: fail(code, message))
}
private struct Answer {
let ok: Bool
let json: [String: Any]
}
private func request(_ path: String, method: String, body: [String: Any]?) -> Answer {
guard !engineURL.isEmpty, let url = URL(string: engineURL + path) else { return Answer(ok: false, json: ["error": "no engine URL"]) }
var req = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
req.httpMethod = method
req.setValue(hostToken, forHTTPHeaderField: "X-Igneum-Host")
if let b = body {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONSerialization.data(withJSONObject: b)
}
let sem = DispatchSemaphore(value: 0)
var out = Answer(ok: false, json: ["error": "no answer from the engine"])
let task = URLSession.shared.dataTask(with: req) { data, resp, error in
defer { sem.signal() }
if let e = error { out = Answer(ok: false, json: ["error": e.localizedDescription]); return }
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
let j = data.flatMap { (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any] } ?? [:]
out = Answer(ok: code == 200 && (j["ok"] as? Bool ?? true), json: j)
}
task.resume()
sem.wait()
return out
}
private func post(_ path: String, _ body: [String: Any]) -> Answer {
request(path, method: "POST", body: body)
}
private func get(_ path: String) -> Answer {
request(path, method: "GET", body: nil)
}
}

View file

@ -1,399 +0,0 @@
// Igneum Wallet for macOS: the window around the wallet engine. A copy of IgneumMiner.swift with the names, the
// bundle and the menu changed (no pause; a Lock item instead). Compiled by packaging/mac/build-wallet-dmg.sh with
// swiftc -O -target arm64-apple-macos11 -o "Igneum Wallet" IgneumWallet.swift Biometric.swift -framework Cocoa -framework WebKit -framework LocalAuthentication
// It starts Contents/MacOS/igneum-wallet --wrapper, reads "URL ...", "HOST {...}" and "STATE {...}" lines from its
// stdout, shows the URL in a WKWebView, keeps a menu-bar item with the state, and on quit writes "quit" to the engine's
// stdin and waits for its "EXIT" line (the bundled node stops first when one runs). Closing the window hides it; the
// app lives on in the menu bar and the Dock. 4 October 2026. Touch ID (Biometric.swift, the "biometric" script
// message handler): 5 October 2026.
//
// Snapshot mode, used to make the design screenshots without a browser:
// "Igneum Wallet" --snapshot <out.png> --url <window url> [--size 1120x780]
import Cocoa
import WebKit
let obsidian = NSColor(srgbRed: 12 / 255, green: 12 / 255, blue: 14 / 255, alpha: 1)
func flameImage(size: CGFloat) -> NSImage {
// the brand mark's flame as a template image for the menu bar
let img = NSImage(size: NSSize(width: size, height: size), flipped: true) { rect in
let s = rect.width / 100
let outer = NSBezierPath()
let pts: [(CGFloat, CGFloat)] = [(50, 4), (74, 34), (67, 58), (80, 54), (61, 96), (39, 96), (20, 54), (33, 58), (26, 34)]
outer.move(to: NSPoint(x: pts[0].0 * s, y: pts[0].1 * s))
for p in pts.dropFirst() { outer.line(to: NSPoint(x: p.0 * s, y: p.1 * s)) }
outer.close()
let inner = NSBezierPath()
let ip: [(CGFloat, CGFloat)] = [(50, 42), (59, 58), (50, 82), (41, 58)]
inner.move(to: NSPoint(x: ip[0].0 * s, y: ip[0].1 * s))
for p in ip.dropFirst() { inner.line(to: NSPoint(x: p.0 * s, y: p.1 * s)) }
inner.close()
outer.append(inner)
outer.windingRule = .evenOdd
NSColor.black.setFill()
outer.fill()
return true
}
img.isTemplate = true
return img
}
/// A clear strip over the top band of the web view: WKWebView swallows window drags, this view lets the window move.
final class DragStrip: NSView {
override var mouseDownCanMoveWindow: Bool { true }
override func hitTest(_ point: NSPoint) -> NSView? { bounds.contains(point) ? self : nil }
}
final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate, WKScriptMessageHandler {
let bio = Biometric(product: "Igneum Wallet", fallbackTitle: "Use password", enrolReason: "Turn on Touch ID for your wallet")
var enginePort = ""
var window: NSWindow!
var web: WKWebView!
var engine: Process?
var engineIn: FileHandle?
var status: NSStatusItem!
var menuOpen = NSMenuItem(title: "Open Igneum Wallet", action: #selector(showWindow), keyEquivalent: "")
var menuPause = NSMenuItem(title: "Lock", action: #selector(lockWallet), keyEquivalent: "")
var menuNode = NSMenuItem(title: "Node: looking", action: nil, keyEquivalent: "")
var menuBlocks = NSMenuItem(title: "Balance: locked", action: nil, keyEquivalent: "")
var url: URL?
var paused = false
var exited = false
var quitting = false
var buffer = Data()
var placeholder: NSTextField!
// snapshot mode
var snapshotPath: String?
var snapshotURL: String?
var snapshotSize = NSSize(width: 1120, height: 780)
func applicationDidFinishLaunching(_ note: Notification) {
NSApp.setActivationPolicy(snapshotPath == nil ? .regular : .accessory)
buildMenu()
buildWindow()
if let p = snapshotPath, let u = snapshotURL, let url = URL(string: u) {
self.url = url
enginePort = url.port.map(String.init) ?? ""
// design screenshots and tests: IGNEUM_HOST_LINE (the engine's HOST json) wires the Touch ID bridge to a
// scratch engine, IGNEUM_PROBE runs JS in the page, IGNEUM_SNAPSHOT_DELAY (s) waits before the capture
if let h = ProcessInfo.processInfo.environment["IGNEUM_HOST_LINE"], let d = h.data(using: .utf8), let o = try? JSONSerialization.jsonObject(with: d) as? [String: Any] {
bio.configure(engineURL: u.replacingOccurrences(of: "?host=mac", with: ""), hostToken: o["token"] as? String ?? "", file: o["biometric_file"] as? String ?? "")
}
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
web.load(URLRequest(url: url))
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { self.snapshot(to: p) }
return
}
buildStatusItem()
startEngine()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
func buildMenu() {
let main = NSMenu()
let appItem = NSMenuItem(); main.addItem(appItem)
let appMenu = NSMenu()
appMenu.addItem(withTitle: "About Igneum Wallet", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
appMenu.addItem(.separator())
appMenu.addItem(withTitle: "Hide Igneum Wallet", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
appMenu.addItem(.separator())
appMenu.addItem(withTitle: "Quit Igneum Wallet", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
appItem.submenu = appMenu
let editItem = NSMenuItem(); main.addItem(editItem)
let edit = NSMenu(title: "Edit")
edit.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
editItem.submenu = edit
let winItem = NSMenuItem(); main.addItem(winItem)
let win = NSMenu(title: "Window")
win.addItem(withTitle: "Minimize", action: #selector(NSWindow.miniaturize(_:)), keyEquivalent: "m")
win.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
winItem.submenu = win
NSApp.mainMenu = main
}
func buildWindow() {
let size = snapshotPath == nil ? NSSize(width: 1120, height: 780) : snapshotSize
window = NSWindow(contentRect: NSRect(origin: .zero, size: size), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false)
window.title = "Igneum Wallet"
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.isMovableByWindowBackground = true
window.backgroundColor = obsidian
window.minSize = NSSize(width: 900, height: 620)
window.center()
window.delegate = self
window.isReleasedWhenClosed = false
let conf = WKWebViewConfiguration()
conf.userContentController.add(self, name: "biometric") // the page's Touch ID bridge (Biometric.swift)
web = WKWebView(frame: window.contentView!.bounds, configuration: conf)
web.autoresizingMask = [.width, .height]
web.navigationDelegate = self
web.uiDelegate = self
web.setValue(false, forKey: "drawsBackground")
web.customUserAgent = "IgneumWallet/0.1.0 (Macintosh)"
window.contentView?.addSubview(web)
// the brand band is draggable; the pill and the settings button on the right stay clickable
let strip = DragStrip(frame: NSRect(x: 0, y: size.height - 60, width: size.width - 300, height: 60))
strip.autoresizingMask = [.width, .minYMargin]
window.contentView?.addSubview(strip)
placeholder = NSTextField(labelWithString: "Starting the engine")
placeholder.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .medium)
placeholder.textColor = NSColor(srgbRed: 154 / 255, green: 154 / 255, blue: 158 / 255, alpha: 1)
placeholder.alignment = .center
placeholder.frame = NSRect(x: 0, y: 18, width: size.width, height: 20)
placeholder.autoresizingMask = [.width, .maxYMargin]
placeholder.isHidden = snapshotPath != nil
window.contentView?.addSubview(placeholder)
}
func buildStatusItem() {
status = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
status.button?.image = flameImage(size: 18)
status.button?.imagePosition = .imageLeading
status.button?.title = ""
let menu = NSMenu()
menu.addItem(menuOpen)
menu.addItem(menuPause)
menu.addItem(.separator())
menuNode.isEnabled = false
menuBlocks.isEnabled = false
menu.addItem(menuNode)
menu.addItem(menuBlocks)
menu.addItem(.separator())
menu.addItem(withTitle: "Quit Igneum Wallet", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "")
menu.autoenablesItems = false
menuOpen.isEnabled = true
menuPause.isEnabled = true
status.menu = menu
}
// ---- the engine ----
func startEngine() {
let exe = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/igneum-wallet")
guard FileManager.default.isExecutableFile(atPath: exe.path) else {
fail("igneum-wallet is missing from the app bundle. Copy Igneum Wallet from the disk image again.")
return
}
let p = Process()
p.executableURL = exe
p.arguments = ["--wrapper"]
let out = Pipe(), inp = Pipe()
p.standardOutput = out
p.standardInput = inp
p.standardError = FileHandle.standardError
engineIn = inp.fileHandleForWriting
out.fileHandleForReading.readabilityHandler = { h in
let d = h.availableData
if d.isEmpty { return }
DispatchQueue.main.async { self.feed(d) }
}
p.terminationHandler = { _ in
DispatchQueue.main.async { self.engineEnded() }
}
do { try p.run() } catch {
fail("The engine could not start: \(error.localizedDescription)")
return
}
engine = p
}
func feed(_ d: Data) {
buffer.append(d)
while let nl = buffer.firstIndex(of: 10) {
let lineData = buffer.subdata(in: 0..<nl)
buffer.removeSubrange(0...nl)
guard let line = String(data: lineData, encoding: .utf8) else { continue }
if line.hasPrefix("URL ") {
let u = String(line.dropFirst(4)).trimmingCharacters(in: .whitespaces)
if let url = URL(string: u + "?host=mac") {
self.url = url
enginePort = url.port.map(String.init) ?? ""
placeholder.isHidden = true
web.load(URLRequest(url: url))
}
} else if line.hasPrefix("HOST ") {
// the host token and the sealed file's path: the page never sees this line
if let d = String(line.dropFirst(5)).data(using: .utf8), let o = try? JSONSerialization.jsonObject(with: d) as? [String: Any] {
bio.configure(engineURL: self.url?.absoluteString.replacingOccurrences(of: "?host=mac", with: "") ?? "", hostToken: o["token"] as? String ?? "", file: o["biometric_file"] as? String ?? "")
}
} else if line.hasPrefix("STATE ") {
applyState(String(line.dropFirst(6)))
} else if line.hasPrefix("FATAL ") {
fail(String(line.dropFirst(6)))
} else if line == "EXIT" {
exited = true
if quitting { NSApp.reply(toApplicationShouldTerminate: true) }
}
}
}
func applyState(_ json: String) {
guard let data = json.data(using: .utf8), let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
let node = o["node"] as? String ?? ""
let source = o["source"] as? String ?? ""
let block = o["block"] as? Double ?? 0
let unlocked = o["unlocked"] as? Bool ?? false
let balance = o["balance"] as? String ?? ""
let phase = o["phase"] as? String ?? ""
var title = ""
if o["quitting"] as? Bool == true { title = "stopping" }
else if phase != "home" { title = "" }
else if unlocked && !balance.isEmpty { title = "\(balance) IGN" }
else if !unlocked { title = "locked" }
status.button?.title = title.isEmpty ? "" : " " + title
menuPause.title = unlocked ? "Lock" : "Locked"
menuPause.isEnabled = unlocked
let nf = NumberFormatter(); nf.numberStyle = .decimal
menuNode.title = "Node: \(source) \(node), block \(nf.string(from: NSNumber(value: block)) ?? "0")"
menuBlocks.title = unlocked ? "Balance: \(balance) IGN" : "Balance: locked"
}
func engineEnded() {
exited = true
if quitting { NSApp.reply(toApplicationShouldTerminate: true); return }
placeholder.stringValue = "The engine stopped. Quit and open Igneum Wallet again."
placeholder.isHidden = false
status?.button?.title = " stopped"
}
func fail(_ text: String) {
placeholder.stringValue = text
placeholder.isHidden = false
let a = NSAlert()
a.messageText = "Igneum Wallet"
a.informativeText = text
a.runModal()
}
@objc func showWindow() {
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
@objc func lockWallet() {
send("lock")
}
func send(_ cmd: String) {
if let d = (cmd + "\n").data(using: .utf8) { engineIn?.write(d) }
}
// ---- quit: miners first, then the node ----
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
if snapshotPath != nil { return .terminateNow }
guard let e = engine, e.isRunning, !exited else { return .terminateNow }
quitting = true
status?.button?.title = " stopping"
web.evaluateJavaScript("document.getElementById('pill-text').textContent='stopping'", completionHandler: nil)
send("quit")
// 45 s is the engine's own worst case (30 s for the bundled node to close its database); then force
DispatchQueue.main.asyncAfter(deadline: .now() + 45) {
if self.engine?.isRunning == true { self.engine?.terminate() }
NSApp.reply(toApplicationShouldTerminate: true)
}
return .terminateLater
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
showWindow()
return true
}
func windowShouldClose(_ sender: NSWindow) -> Bool {
// the window hides; the miner keeps running in the menu bar
window.orderOut(nil)
return false
}
// ---- Touch ID: the page posts {id, op, ...}; the answer goes back as window.__igneumBiometric(id, {...}) ----
func userContentController(_ ucc: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.name == "biometric", let body = message.body as? [String: Any], let id = body["id"] as? Int else { return }
// only the engine's own page, same origin as the URL the engine printed
let origin = message.frameInfo.securityOrigin
guard origin.host == "127.0.0.1", String(origin.port) == enginePort else { return }
bio.handle(body) { result in
guard let d = try? JSONSerialization.data(withJSONObject: result), let j = String(data: d, encoding: .utf8) else { return }
self.web.evaluateJavaScript("window.__igneumBiometric && window.__igneumBiometric(\(id), \(j))", completionHandler: nil)
}
}
// ---- links: anything off 127.0.0.1 opens in the default browser ----
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if let u = action.request.url, let scheme = u.scheme, scheme.hasPrefix("http"), u.host != "127.0.0.1" {
NSWorkspace.shared.open(u)
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
let a = NSAlert()
a.messageText = "Igneum Wallet"
a.informativeText = message
a.addButton(withTitle: "Quit")
a.addButton(withTitle: "Cancel")
completionHandler(a.runModal() == .alertFirstButtonReturn)
}
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
let a = NSAlert(); a.messageText = "Igneum Wallet"; a.informativeText = message; a.runModal(); completionHandler()
}
// ---- snapshot ----
func snapshot(to path: String) {
if let js = ProcessInfo.processInfo.environment["IGNEUM_PROBE"] {
web.evaluateJavaScript(js) { r, e in print("probe:", r ?? "nil", e?.localizedDescription ?? "") }
}
let delay = Double(ProcessInfo.processInfo.environment["IGNEUM_SNAPSHOT_DELAY"] ?? "") ?? 0
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { self.capture(to: path) }
}
func capture(to path: String) {
let conf = WKSnapshotConfiguration()
conf.rect = web.bounds
web.takeSnapshot(with: conf) { image, error in
defer { NSApp.terminate(nil) }
guard let img = image, let tiff = img.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), let png = rep.representation(using: .png, properties: [:]) else {
FileHandle.standardError.write("snapshot failed: \(error?.localizedDescription ?? "no image")\n".data(using: .utf8)!)
return
}
do { try png.write(to: URL(fileURLWithPath: path)); print("wrote \(path)") } catch { print("could not write \(path): \(error)") }
}
}
}
// The entry point. With Biometric.swift compiled alongside, top-level statements are not allowed here (only in a
// main.swift), so the launch lives in a @main type.
@main
struct Main {
static func main() {
let app = NSApplication.shared
let delegate = App()
let args = Array(CommandLine.arguments.dropFirst())
var i = 0
while i < args.count {
switch args[i] {
case "--snapshot": if i + 1 < args.count { delegate.snapshotPath = args[i + 1]; i += 1 }
case "--url": if i + 1 < args.count { delegate.snapshotURL = args[i + 1]; i += 1 }
case "--size":
if i + 1 < args.count {
let parts = args[i + 1].split(separator: "x").compactMap { Double($0) }
if parts.count == 2 { delegate.snapshotSize = NSSize(width: parts[0], height: parts[1]) }
i += 1
}
default: break
}
i += 1
}
app.delegate = delegate
app.run()
}
}

View file

@ -1,61 +0,0 @@
@echo off
rem Builds "Igneum Wallet.exe" (the WebView2 window host) on a Windows PC. Double-click this file.
rem Needs Visual Studio with the MSVC v143 x64 component (cl.exe, rc.exe), the Windows SDK's C++/WinRT headers
rem (Windows Hello, biometric.h) and the internet on the first run (the WebView2 SDK comes from NuGet). The output lands in dist\ and, when the extracted payload folder
rem (igneum-windows-app, with igneum-wallet.exe) is next to this folder or its parent, is copied into it, so
rem packaging\windows\BUILD-INSTALLER.bat ships it. Without this exe the installer still works: the Start Menu entry
rem runs igneum-wallet.exe --launch, which opens the dashboard in the default browser.
setlocal EnableDelayedExpansion
cd /d "%~dp0"
set "SDKVER=1.0.2903.40"
set "SDKURL=https://www.nuget.org/api/v2/package/Microsoft.Web.WebView2/%SDKVER%"
if not exist build mkdir build
if not exist dist mkdir dist
rem ---- 1. the MSVC environment ----
set "VCVARS="
for %%d in ("C:\Program Files\Microsoft Visual Studio" "C:\Program Files (x86)\Microsoft Visual Studio") do (
for /f "delims=" %%f in ('dir /s /b "%%~d\vcvarsall.bat" 2^>nul') do set "VCVARS=%%f"
)
if "%VCVARS%"=="" (
echo Visual Studio with the MSVC v143 x64 component was not found ^(no vcvarsall.bat under Program Files\Microsoft Visual Studio^).
pause
exit /b 1
)
echo [build] MSVC: "%VCVARS%"
call "%VCVARS%" x64 >nul 2>&1
where cl.exe >nul 2>nul || (echo cl.exe is not on PATH after vcvarsall; open a "x64 Native Tools" prompt and run this file from there. & pause & exit /b 1)
rem ---- 2. the WebView2 SDK (NuGet package, a zip) ----
if not exist "build\webview2\build\native\include\WebView2.h" (
echo [build] downloading the WebView2 SDK %SDKVER%
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ProgressPreference='SilentlyContinue'; [Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '%SDKURL%' -OutFile 'build\webview2.zip' -UseBasicParsing; if (Test-Path 'build\webview2') { Remove-Item -Recurse -Force 'build\webview2' }; Expand-Archive -Path 'build\webview2.zip' -DestinationPath 'build\webview2' -Force"
if not exist "build\webview2\build\native\include\WebView2.h" (echo the SDK did not unpack; see build\webview2 & pause & exit /b 1)
)
rem ---- 3. the art: brand\icons in the repo layout, or an art\ folder next to this file ----
set "ART="
if exist "..\..\brand\icons\igneum.ico" set "ART=..\..\brand\icons"
if exist "art\igneum.ico" set "ART=art"
if exist "..\brand\icons\igneum.ico" set "ART=..\brand\icons"
if "%ART%"=="" (echo igneum.ico was not found ^(brand\icons or an art\ folder^). & pause & exit /b 1)
rem ---- 4. compile ----
echo [build] rc
rc.exe /nologo /i "%ART%" /fo build\host.res wallet-host.rc || (pause & exit /b 1)
echo [build] cl
cl.exe /nologo /O2 /MT /EHsc /W3 /std:c++17 /DUNICODE /D_UNICODE /I "build\webview2\build\native\include" /Fo"build\\" wallet-host.cpp build\host.res ^
/link /SUBSYSTEM:WINDOWS /OUT:"dist\Igneum Wallet.exe" "build\webview2\build\native\x64\WebView2LoaderStatic.lib" ^
user32.lib shell32.lib ole32.lib advapi32.lib gdi32.lib version.lib shlwapi.lib windowsapp.lib crypt32.lib winhttp.lib || (pause & exit /b 1)
echo [build] done: dist\Igneum Wallet.exe
rem ---- 5. into the payload, when it is here ----
for %%p in ("igneum-windows-app" "..\igneum-windows-app" "..\..\igneum-windows-app" "..\..\packaging\windows\igneum-windows-app") do (
if exist "%%~p\igneum-wallet.exe" (
copy /y "dist\Igneum Wallet.exe" "%%~p\" >nul
echo [build] copied into %%~p
)
)
echo.
echo Next: packaging\windows\BUILD-INSTALLER.bat builds the Setup exe with this host inside.
pause

View file

@ -1,377 +0,0 @@
// Windows Hello for the Igneum window hosts (wallet-host.cpp, host.cpp): the WebView2 side of what
// app/mac/Biometric.swift does with Touch ID. 5 October 2026. UNTESTED on a PC at the time of writing (written on a
// Mac): BUILD-WALLET-APP.bat / BUILD-APP.bat on the GitHub runner are the first compile.
//
// The page posts a JSON string {id, op, nonce?, token?} with window.chrome.webview.postMessage; the host answers with
// PostWebMessageAsJson({id, ok, code, message, ...}) and the page's listener routes it to window.__igneumBiometric.
// Ops: status, enrol, unlock (wallet), confirm.
//
// The prompt is Windows.Security.Credentials.UI.UserConsentVerifier, through IUserConsentVerifierInterop so a Win32
// window can own it (RequestVerificationForWindowAsync). What is stored: the wallet's password, sealed with DPAPI
// (CryptProtectData, current user, CRYPTPROTECT_UI_FORBIDDEN) only after a Hello success, in the file the engine
// named on its HOST line (%LOCALAPPDATA%\igneum\wallet\biometric.json); the miner seals a fixed marker. DPAPI is as
// strong as the Windows account: anything running as this user can unseal it, which is why the host only unseals
// after Hello verified, and why the threat model in app/igneum-wallet/README.md says what this does and does not
// protect. The secret never goes through the page: the host posts it to the engine on 127.0.0.1 with the engine's
// URL token; X-Igneum-Host (the token from the HOST line) marks the calls only the host may make.
//
// Needs: C++/WinRT headers (the Windows SDK's cppwinrt include, on INCLUDE after vcvarsall), windowsapp.lib,
// crypt32.lib, winhttp.lib. C++17.
#pragma once
#include <windows.h>
#include <wincrypt.h>
#include <winhttp.h>
#include <winrt/base.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Security.Credentials.UI.h>
#include <UserConsentVerifierInterop.h>
#include <string>
#include <vector>
#include <thread>
#include <functional>
namespace igbio {
using winrt::Windows::Security::Credentials::UI::UserConsentVerifier;
using winrt::Windows::Security::Credentials::UI::UserConsentVerifierAvailability;
using winrt::Windows::Security::Credentials::UI::UserConsentVerificationResult;
struct Config {
std::wstring product; // L"Igneum Wallet" | L"Igneum Miner"
std::wstring enrolReason; // L"Turn on Windows Hello for your wallet" | L"... for the miner"
std::wstring engineURL; // http://127.0.0.1:<port>/t/<token>/
std::string hostToken; // from the HOST line
std::wstring file; // the sealed file's path from the HOST line
HWND hwnd = nullptr; // the window that owns the prompt
};
static Config g_cfg;
static const char* MARKER = "igneum-biometric-marker-v1";
// ---- small JSON helpers (flat objects, string values) ----
static std::string jsonEscape(const std::string& s) {
std::string o;
for (unsigned char c : s) {
switch (c) {
case '"': o += "\\\""; break;
case '\\': o += "\\\\"; break;
case '\n': o += "\\n"; break;
case '\r': o += "\\r"; break;
case '\t': o += "\\t"; break;
default:
if (c < 0x20) { char b[8]; sprintf_s(b, "\\u%04x", c); o += b; } else o += (char)c;
}
}
return o;
}
// The value of "key" in a flat JSON object: a string (escapes decoded), a number, or a bool as text.
static std::string jsonGet(const std::string& j, const char* key) {
std::string k = std::string("\"") + key + "\"";
size_t i = j.find(k);
if (i == std::string::npos) return "";
i = j.find(':', i + k.size());
if (i == std::string::npos) return "";
i++;
while (i < j.size() && (j[i] == ' ' || j[i] == '\t')) i++;
if (i < j.size() && j[i] == '"') {
std::string o;
for (i++; i < j.size() && j[i] != '"'; i++) {
if (j[i] == '\\' && i + 1 < j.size()) {
char e = j[++i];
if (e == 'n') o += '\n';
else if (e == 'r') o += '\r';
else if (e == 't') o += '\t';
else if (e == 'u' && i + 4 < j.size()) {
unsigned v = strtoul(j.substr(i + 1, 4).c_str(), nullptr, 16);
i += 4;
if (v < 0x80) o += (char)v;
else if (v < 0x800) { o += (char)(0xC0 | (v >> 6)); o += (char)(0x80 | (v & 0x3F)); }
else { o += (char)(0xE0 | (v >> 12)); o += (char)(0x80 | ((v >> 6) & 0x3F)); o += (char)(0x80 | (v & 0x3F)); }
} else o += e;
} else o += j[i];
}
return o;
}
size_t e = i;
while (e < j.size() && j[e] != ',' && j[e] != '}') e++;
std::string v = j.substr(i, e - i);
while (!v.empty() && (v.back() == ' ' || v.back() == '\r' || v.back() == '\n')) v.pop_back();
return v;
}
static std::wstring widen8(const std::string& s) {
if (s.empty()) return L"";
int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), nullptr, 0);
std::wstring w(n, 0);
MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), &w[0], n);
return w;
}
static std::string narrow8(const std::wstring& w) {
if (w.empty()) return "";
int n = WideCharToMultiByte(CP_UTF8, 0, w.data(), (int)w.size(), nullptr, 0, nullptr, nullptr);
std::string s(n, 0);
WideCharToMultiByte(CP_UTF8, 0, w.data(), (int)w.size(), &s[0], n, nullptr, nullptr);
return s;
}
static std::string b64(const std::vector<BYTE>& d) {
DWORD n = 0;
CryptBinaryToStringA(d.data(), (DWORD)d.size(), CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, nullptr, &n);
std::string o(n, 0);
CryptBinaryToStringA(d.data(), (DWORD)d.size(), CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, &o[0], &n);
while (!o.empty() && o.back() == 0) o.pop_back();
return o;
}
static std::vector<BYTE> unb64(const std::string& s) {
DWORD n = 0;
if (!CryptStringToBinaryA(s.c_str(), (DWORD)s.size(), CRYPT_STRING_BASE64, nullptr, &n, nullptr, nullptr)) return {};
std::vector<BYTE> o(n);
if (!CryptStringToBinaryA(s.c_str(), (DWORD)s.size(), CRYPT_STRING_BASE64, o.data(), &n, nullptr, nullptr)) return {};
o.resize(n);
return o;
}
static std::string reply(bool ok, const std::string& code, const std::string& message) {
return std::string("{\"ok\":") + (ok ? "true" : "false") + ",\"code\":\"" + jsonEscape(code) + "\",\"message\":\"" + jsonEscape(message) + "\"}";
}
// ---- the engine on 127.0.0.1 (WinHTTP) ----
struct Answer { bool ok; int status; std::string body; };
static Answer call(const std::wstring& method, const std::string& path, const std::string& body) {
Answer a = { false, 0, "" };
URL_COMPONENTS uc = { sizeof(uc) };
wchar_t host[64] = {}, upath[1024] = {};
uc.lpszHostName = host; uc.dwHostNameLength = 64;
uc.lpszUrlPath = upath; uc.dwUrlPathLength = 1024;
std::wstring full = g_cfg.engineURL + widen8(path);
if (!WinHttpCrackUrl(full.c_str(), 0, 0, &uc)) { a.body = "{\"error\":\"bad engine url\"}"; return a; }
HINTERNET s = WinHttpOpen(L"IgneumHost/1", WINHTTP_ACCESS_TYPE_NO_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
if (!s) { a.body = "{\"error\":\"winhttp\"}"; return a; }
WinHttpSetTimeouts(s, 5000, 5000, 15000, 15000);
HINTERNET c = WinHttpConnect(s, host, uc.nPort, 0);
HINTERNET r = c ? WinHttpOpenRequest(c, method.c_str(), upath, nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, 0) : nullptr;
if (r) {
std::wstring hdr = L"X-Igneum-Host: " + widen8(g_cfg.hostToken) + L"\r\nContent-Type: application/json\r\n";
if (WinHttpSendRequest(r, hdr.c_str(), (DWORD)-1, body.empty() ? WINHTTP_NO_REQUEST_DATA : (LPVOID)body.data(), (DWORD)body.size(), (DWORD)body.size(), 0) && WinHttpReceiveResponse(r, nullptr)) {
DWORD st = 0, n = sizeof(st);
WinHttpQueryHeaders(r, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, &st, &n, WINHTTP_NO_HEADER_INDEX);
a.status = (int)st;
DWORD avail = 0;
while (WinHttpQueryDataAvailable(r, &avail) && avail > 0) {
std::string chunk(avail, 0);
DWORD got = 0;
if (!WinHttpReadData(r, &chunk[0], avail, &got)) break;
a.body.append(chunk, 0, got);
}
a.ok = st == 200 && jsonGet(a.body, "ok") != "false";
} else a.body = "{\"error\":\"no answer from the engine\"}";
}
if (r) WinHttpCloseHandle(r);
if (c) WinHttpCloseHandle(c);
WinHttpCloseHandle(s);
return a;
}
static Answer post(const std::string& path, const std::string& body) { return call(L"POST", path, body); }
static Answer get(const std::string& path) { return call(L"GET", path, ""); }
// ---- Windows Hello ----
static std::string availabilityText(UserConsentVerifierAvailability a, bool* available) {
*available = false;
switch (a) {
case UserConsentVerifierAvailability::Available: *available = true; return "";
case UserConsentVerifierAvailability::DeviceNotPresent: return "Windows Hello has no fingerprint or face sensor on this PC.";
case UserConsentVerifierAvailability::NotConfiguredForUser: return "Windows Hello is not set up on this PC. Set it up in Settings > Accounts > Sign-in options.";
case UserConsentVerifierAvailability::DisabledByPolicy: return "Windows Hello is disabled by policy on this PC.";
case UserConsentVerifierAvailability::DeviceBusy: return "The Windows Hello sensor is busy.";
default: return "Windows Hello is not available on this PC.";
}
}
static std::string status(bool* available) {
try {
auto a = UserConsentVerifier::CheckAvailabilityAsync().get();
return availabilityText(a, available);
} catch (...) {
*available = false;
return "Windows Hello could not be checked on this PC.";
}
}
// The prompt. Returns "" on success, else the reply JSON for the page.
static std::string verify(const std::wstring& reason) {
try {
auto factory = winrt::get_activation_factory<UserConsentVerifier, IUserConsentVerifierInterop>();
winrt::Windows::Foundation::IAsyncOperation<UserConsentVerificationResult> op{ nullptr };
winrt::hstring msg(reason.substr(0, 80));
winrt::check_hresult(factory->RequestVerificationForWindowAsync(g_cfg.hwnd, static_cast<HSTRING>(winrt::get_abi(msg)),
winrt::guid_of<winrt::Windows::Foundation::IAsyncOperation<UserConsentVerificationResult>>(), winrt::put_abi(op)));
auto r = op.get();
switch (r) {
case UserConsentVerificationResult::Verified: return "";
case UserConsentVerificationResult::Canceled: return reply(false, "cancelled", "Windows Hello was cancelled.");
case UserConsentVerificationResult::RetriesExhausted: return reply(false, "locked", "Windows Hello is locked after too many tries. Use the password.");
case UserConsentVerificationResult::NotConfiguredForUser: return reply(false, "not_set_up", "Windows Hello is not set up on this PC. Set it up in Settings > Accounts > Sign-in options.");
case UserConsentVerificationResult::DeviceNotPresent: return reply(false, "unavailable", "Windows Hello has no sensor on this PC.");
case UserConsentVerificationResult::DisabledByPolicy: return reply(false, "unavailable", "Windows Hello is disabled by policy on this PC.");
case UserConsentVerificationResult::DeviceBusy: return reply(false, "failed", "The Windows Hello sensor is busy; try again.");
default: return reply(false, "failed", "Windows Hello did not succeed.");
}
} catch (const winrt::hresult_error& e) {
return reply(false, "failed", "Windows Hello failed: " + narrow8(std::wstring(e.message())));
} catch (...) {
return reply(false, "failed", "Windows Hello failed.");
}
}
// ---- the sealed file (DPAPI, current user) ----
static bool readFile(std::string* out) {
HANDLE h = CreateFileW(g_cfg.file.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (h == INVALID_HANDLE_VALUE) return false;
char buf[8192]; DWORD n = 0;
out->clear();
while (ReadFile(h, buf, sizeof(buf), &n, nullptr) && n > 0) out->append(buf, n);
CloseHandle(h);
return true;
}
static bool writeFile(const std::string& text) {
size_t i = g_cfg.file.find_last_of(L"\\/");
if (i != std::wstring::npos) CreateDirectoryW(g_cfg.file.substr(0, i).c_str(), nullptr);
HANDLE h = CreateFileW(g_cfg.file.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (h == INVALID_HANDLE_VALUE) return false;
DWORD w = 0;
BOOL ok = WriteFile(h, text.data(), (DWORD)text.size(), &w, nullptr);
CloseHandle(h);
return ok && w == text.size();
}
static bool sealedExists() {
DWORD a = GetFileAttributesW(g_cfg.file.c_str());
return a != INVALID_FILE_ATTRIBUTES && !(a & FILE_ATTRIBUTE_DIRECTORY);
}
static bool seal(const std::string& secret, std::string* boxB64) {
DATA_BLOB in = { (DWORD)secret.size(), (BYTE*)secret.data() }, out = {};
std::wstring desc = g_cfg.product + L" biometric";
if (!CryptProtectData(&in, desc.c_str(), nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &out)) return false;
std::vector<BYTE> v(out.pbData, out.pbData + out.cbData);
LocalFree(out.pbData);
*boxB64 = b64(v);
return true;
}
static bool unseal(const std::string& boxB64, std::string* secret) {
std::vector<BYTE> v = unb64(boxB64);
if (v.empty()) return false;
DATA_BLOB in = { (DWORD)v.size(), v.data() }, out = {};
if (!CryptUnprotectData(&in, nullptr, nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &out)) return false;
secret->assign((char*)out.pbData, out.cbData);
SecureZeroMemory(out.pbData, out.cbData);
LocalFree(out.pbData);
return true;
}
// ---- the ops ----
static std::string opStatus() {
bool avail = false;
std::string msg = status(&avail);
return std::string("{\"ok\":true,\"available\":") + (avail ? "true" : "false") + ",\"kind\":\"hello\",\"message\":\"" + jsonEscape(msg) + "\",\"enrolled\":" + (sealedExists() ? "true" : "false") + "}";
}
static std::string opEnrol(const std::string& token) {
if (g_cfg.file.empty()) return reply(false, "unavailable", "The engine has not named the sealed file yet.");
bool avail = false;
std::string msg = status(&avail);
if (!avail) return reply(false, "unavailable", msg);
std::string e = verify(g_cfg.enrolReason);
if (!e.empty()) return e;
std::string secret;
if (!token.empty()) {
Answer a = post("api/biometric/enrol/take", "{\"token\":\"" + jsonEscape(token) + "\"}");
if (!a.ok) return reply(false, "engine", jsonGet(a.body, "error").empty() ? "the engine did not hand over the password" : jsonGet(a.body, "error"));
secret = jsonGet(a.body, "secret");
} else secret = MARKER;
std::string box;
bool ok = seal(secret, &box);
SecureZeroMemory(&secret[0], secret.size());
if (!ok) return reply(false, "seal", "DPAPI could not seal the secret.");
std::string doc = "{\"version\":1,\"kind\":\"hello\",\"product\":\"" + jsonEscape(narrow8(g_cfg.product)) + "\",\"created\":" + std::to_string((long long)(GetTickCount64() / 1000)) + ",\"box\":\"" + box + "\"}";
if (!writeFile(doc)) return reply(false, "file", "The sealed file could not be written.");
Answer a = post("api/biometric/enrolled", "{\"kind\":\"hello\"}");
if (!a.ok) return reply(false, "engine", "the engine did not record the enrolment");
return reply(true, "", "");
}
static std::string opUnlock() {
std::string text;
if (!sealedExists() || !readFile(&text)) return reply(false, "not_enrolled", "Windows Hello is not turned on for this wallet.");
std::string e = verify(L"Unlock your wallet");
if (!e.empty()) return e;
std::string secret;
if (!unseal(jsonGet(text, "box"), &secret)) return reply(false, "invalidated", "Windows Hello no longer opens this wallet: the sealed password could not be read. Use the password, then turn Windows Hello on again in Settings.");
Answer a = post("api/unlock", "{\"password\":\"" + jsonEscape(secret) + "\"}");
SecureZeroMemory(&secret[0], secret.size());
if (!a.ok) return reply(false, "engine", jsonGet(a.body, "error").empty() ? "the engine did not unlock" : jsonGet(a.body, "error"));
return reply(true, "", "");
}
static std::string opConfirm(const std::string& nonce) {
if (nonce.empty()) return reply(false, "bad_nonce", "No challenge.");
Answer c = get("api/biometric/challenge?nonce=" + nonce);
if (!c.ok) return reply(false, "engine", jsonGet(c.body, "error").empty() ? "the engine does not know this challenge" : jsonGet(c.body, "error"));
std::string e = verify(widen8(jsonGet(c.body, "reason")));
if (!e.empty()) return e;
if (sealedExists()) {
// the sealed file must still open under this account (DPAPI; a reset account leaves it unreadable)
std::string text, secret;
if (!readFile(&text) || !unseal(jsonGet(text, "box"), &secret)) return reply(false, "invalidated", "The sealed file could not be read. Turn Windows Hello on again in Settings.");
SecureZeroMemory(&secret[0], secret.size());
}
Answer a = post("api/biometric/confirm", "{\"nonce\":\"" + jsonEscape(nonce) + "\"}");
if (!a.ok) return reply(false, "engine", jsonGet(a.body, "error").empty() ? "the engine refused the confirmation" : jsonGet(a.body, "error"));
return reply(true, "", "");
}
/// The HOST line from the engine: {"token": "...", "biometric_file": "..."}. Posts the availability at once.
static void configure(const std::string& hostLineJson, const std::wstring& engineURL, const std::wstring& product, const std::wstring& enrolReason, HWND hwnd) {
g_cfg.product = product;
g_cfg.enrolReason = enrolReason;
g_cfg.engineURL = engineURL;
g_cfg.hostToken = jsonGet(hostLineJson, "token");
g_cfg.file = widen8(jsonGet(hostLineJson, "biometric_file"));
g_cfg.hwnd = hwnd;
std::thread([] {
winrt::init_apartment(winrt::apartment_type::multi_threaded);
bool avail = false;
std::string msg = status(&avail);
post("api/biometric/status", std::string("{\"available\":") + (avail ? "true" : "false") + ",\"kind\":\"hello\",\"message\":\"" + jsonEscape(msg) + "\"}");
}).detach();
}
/// A message from the page (the JSON string it posted). `answer` receives the reply JSON with the id put back; it is
/// called on a worker thread, so the host marshals it to the UI thread for PostWebMessageAsJson.
static void handle(const std::string& msg, std::function<void(const std::string&)> answer) {
std::string id = jsonGet(msg, "id"), op = jsonGet(msg, "op"), nonce = jsonGet(msg, "nonce"), token = jsonGet(msg, "token");
std::thread([id, op, nonce, token, answer] {
winrt::init_apartment(winrt::apartment_type::multi_threaded);
std::string r;
if (op == "status") r = opStatus();
else if (op == "enrol") r = opEnrol(token);
else if (op == "unlock") r = opUnlock();
else if (op == "confirm") r = opConfirm(nonce);
else r = reply(false, "bad_op", "unknown op " + op);
if (op != "status") {
post("api/biometric/report", "{\"op\":\"" + jsonEscape(op) + "\",\"ok\":" + (jsonGet(r, "ok") == "true" ? "true" : "false") + ",\"code\":\"" + jsonEscape(jsonGet(r, "code")) + "\",\"message\":\"" + jsonEscape(jsonGet(r, "message")) + "\"}");
}
// put the id in front: {"id":N, ...rest}
std::string withId = "{\"id\":" + (id.empty() ? "0" : id) + "," + r.substr(1);
answer(withId);
}).detach();
}
} // namespace igbio

View file

@ -1,486 +0,0 @@
// Igneum Wallet for Windows: the window around the wallet engine. A copy of host.cpp (the miner's window) with the
// names and the tray menu changed: "Lock" instead of "Pause". Built on the PC by BUILD-WALLET-APP.bat (MSVC, the
// WebView2 SDK from NuGet, static loader). It starts igneum-wallet.exe --wrapper next to it, reads "URL ..." /
// "STATE {...}" / "EXIT" from its stdout, shows the URL in a WebView2 control, keeps a tray icon with the state, and on
// quit writes "quit" to the engine's stdin and waits for EXIT. Closing the window hides it to the tray. 4 October 2026.
// Windows Hello (biometric.h, the page's window.chrome.webview.postMessage bridge, the HOST line): 5 October 2026.
//
// Untested on a real PC at the time of writing (written on a Mac): BUILD-WALLET-APP.bat is the first run.
#define WIN32_LEAN_AND_MEAN
#ifndef UNICODE
#define UNICODE
#endif
#ifndef _UNICODE
#define _UNICODE
#endif
#include <windows.h>
#include <shellapi.h>
#include <wrl.h>
#include <string>
#include <vector>
#include <thread>
#include <mutex>
#include "WebView2.h"
#include "wallet-version.h"
#include "biometric.h"
using namespace Microsoft::WRL;
#define WM_ENGINE_LINE (WM_APP + 1)
#define WM_TRAY (WM_APP + 2)
#define WM_BIO_REPLY (WM_APP + 3)
#define ID_TRAY_OPEN 1001
#define ID_TRAY_PAUSE 1002
#define ID_TRAY_QUIT 1003
#define ID_QUIT_TIMER 7
#define IDI_APP 1
static HWND g_hwnd = nullptr;
static HANDLE g_engine = nullptr, g_engineIn = nullptr, g_engineOut = nullptr;
static ComPtr<ICoreWebView2Controller> g_controller;
static ComPtr<ICoreWebView2> g_webview;
static std::wstring g_url, g_status = L"starting the engine";
static std::string g_hostLine; // the engine's HOST {...} line, kept until the window and the URL exist
static bool g_paused = false, g_quitting = false, g_exited = false, g_webviewOk = false, g_hintShown = false;
static NOTIFYICONDATAW g_nid = {};
static std::wstring g_trayTitle = L"Igneum Wallet";
static ULONGLONG g_quitStarted = 0;
static std::wstring widen(const std::string& s) {
if (s.empty()) return L"";
int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), nullptr, 0);
std::wstring w(n, 0);
MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), &w[0], n);
return w;
}
static std::wstring exeDir() {
wchar_t buf[MAX_PATH];
GetModuleFileNameW(nullptr, buf, MAX_PATH);
std::wstring p(buf);
size_t i = p.find_last_of(L"\\/");
return i == std::wstring::npos ? L"." : p.substr(0, i);
}
static void sendEngine(const char* line) {
if (!g_engineIn) return;
DWORD w = 0;
WriteFile(g_engineIn, line, (DWORD)strlen(line), &w, nullptr);
WriteFile(g_engineIn, "\n", 1, &w, nullptr);
}
static void setTray(const std::wstring& tip) {
g_trayTitle = tip;
wcsncpy_s(g_nid.szTip, tip.c_str(), _TRUNCATE);
Shell_NotifyIconW(NIM_MODIFY, &g_nid);
}
static void repaintStatus() {
InvalidateRect(g_hwnd, nullptr, TRUE);
}
// A tiny JSON number/string/bool reader for the STATE line (flat object, known keys).
static std::string jsonField(const std::string& j, const char* key) {
std::string k = std::string("\"") + key + "\":";
size_t i = j.find(k);
if (i == std::string::npos) return "";
i += k.size();
while (i < j.size() && j[i] == ' ') i++;
if (i < j.size() && j[i] == '"') {
size_t e = j.find('"', i + 1);
return e == std::string::npos ? "" : j.substr(i + 1, e - i - 1);
}
size_t e = i;
while (e < j.size() && j[e] != ',' && j[e] != '}') e++;
return j.substr(i, e - i);
}
static void applyState(const std::string& j) {
std::string mining = jsonField(j, "mining"), node = jsonField(j, "node"), phase = jsonField(j, "phase");
g_paused = jsonField(j, "paused") == "true";
double hash = atof(jsonField(j, "hash_total").c_str());
std::string blocks = jsonField(j, "blocks"), accepted = jsonField(j, "accepted_total");
wchar_t tip[128];
if (jsonField(j, "quitting") == "true") swprintf_s(tip, L"Igneum Wallet: stopping");
else if (phase != "dashboard") swprintf_s(tip, L"Igneum Wallet: set up");
else if (mining == "mining") swprintf_s(tip, L"Igneum Wallet: %.1f MH/s, %S blocks found, node %S", hash, accepted.c_str(), node.c_str());
else if (mining == "paused") swprintf_s(tip, L"Igneum Wallet: paused, node %S", node.c_str());
else swprintf_s(tip, L"Igneum Wallet: %S, node %S (%S blocks)", mining.c_str(), node.c_str(), blocks.c_str());
setTray(tip);
}
// The engine asks for an elevated step (the NVIDIA power cap): this process has a UI context, so the UAC prompt shows.
// Runs cmd /c <line> as administrator, waits, and answers on the engine's stdin.
static void runElevated(std::wstring line) {
std::thread([line] {
std::wstring params = L"/c " + line;
wchar_t sysdir[MAX_PATH];
GetSystemDirectoryW(sysdir, MAX_PATH);
std::wstring cmdExe = std::wstring(sysdir) + L"\\cmd.exe"; // the absolute path, never a bare name (R4.3.3)
SHELLEXECUTEINFOW sei = { sizeof(sei) };
sei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI;
sei.lpVerb = L"runas";
sei.lpFile = cmdExe.c_str();
sei.lpParameters = params.c_str();
sei.nShow = SW_HIDE;
if (!ShellExecuteExW(&sei) || !sei.hProcess) {
DWORD err = GetLastError();
sendEngine(err == ERROR_CANCELLED ? "elevated fail: the administrator prompt was cancelled" : "elevated fail: could not start the elevated step");
return;
}
WaitForSingleObject(sei.hProcess, 120000);
DWORD code = 1;
GetExitCodeProcess(sei.hProcess, &code);
CloseHandle(sei.hProcess);
if (code == 0) sendEngine("elevated ok");
else { char buf[64]; sprintf_s(buf, "elevated fail: exit code %lu", code); sendEngine(buf); }
}).detach();
}
static void openInBrowser(const std::wstring& url) {
ShellExecuteW(nullptr, L"open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
}
static void navigate() {
if (g_webview && !g_url.empty()) g_webview->Navigate((g_url + L"?host=windows").c_str());
}
static void initWebView() {
std::wstring data = L"";
wchar_t* local = nullptr;
size_t len = 0;
if (_wdupenv_s(&local, &len, L"LOCALAPPDATA") == 0 && local) { data = std::wstring(local) + L"\\igneum\\webview2"; free(local); }
HRESULT hr = CreateCoreWebView2EnvironmentWithOptions(nullptr, data.empty() ? nullptr : data.c_str(), nullptr,
Callback<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler>([](HRESULT result, ICoreWebView2Environment* env) -> HRESULT {
if (FAILED(result) || !env) {
g_status = L"The WebView2 runtime is not installed. The dashboard opens in your browser; install the runtime from microsoft.com for the app window.";
repaintStatus();
if (!g_url.empty()) { openInBrowser(g_url); g_hintShown = true; }
return S_OK;
}
env->CreateCoreWebView2Controller(g_hwnd, Callback<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler>([](HRESULT result, ICoreWebView2Controller* controller) -> HRESULT {
if (FAILED(result) || !controller) {
g_status = L"The app window could not start WebView2. The dashboard opens in your browser.";
repaintStatus();
if (!g_url.empty()) { openInBrowser(g_url); g_hintShown = true; }
return S_OK;
}
g_controller = controller;
g_controller->get_CoreWebView2(&g_webview);
g_webviewOk = true;
ComPtr<ICoreWebView2Settings> settings;
if (g_webview && SUCCEEDED(g_webview->get_Settings(&settings)) && settings) {
settings->put_AreDefaultContextMenusEnabled(FALSE);
settings->put_IsStatusBarEnabled(FALSE);
settings->put_AreDevToolsEnabled(FALSE);
}
// links off 127.0.0.1 open in the default browser
g_webview->add_NewWindowRequested(Callback<ICoreWebView2NewWindowRequestedEventHandler>([](ICoreWebView2*, ICoreWebView2NewWindowRequestedEventArgs* args) -> HRESULT {
LPWSTR uri = nullptr;
if (SUCCEEDED(args->get_Uri(&uri)) && uri) { openInBrowser(uri); CoTaskMemFree(uri); }
args->put_Handled(TRUE);
return S_OK;
}).Get(), nullptr);
g_webview->add_NavigationStarting(Callback<ICoreWebView2NavigationStartingEventHandler>([](ICoreWebView2*, ICoreWebView2NavigationStartingEventArgs* args) -> HRESULT {
LPWSTR uri = nullptr;
if (SUCCEEDED(args->get_Uri(&uri)) && uri) {
std::wstring u(uri);
CoTaskMemFree(uri);
if (u.rfind(L"http", 0) == 0 && u.find(L"127.0.0.1") == std::wstring::npos) { args->put_Cancel(TRUE); openInBrowser(u); }
}
return S_OK;
}).Get(), nullptr);
// the page's Windows Hello bridge (biometric.h): a JSON string in, a JSON object back on the UI thread
g_webview->add_WebMessageReceived(Callback<ICoreWebView2WebMessageReceivedEventHandler>([](ICoreWebView2*, ICoreWebView2WebMessageReceivedEventArgs* args) -> HRESULT {
LPWSTR src = nullptr;
if (FAILED(args->get_Source(&src)) || !src) return S_OK;
std::wstring origin(src);
CoTaskMemFree(src);
if (origin.rfind(L"http://127.0.0.1:", 0) != 0) return S_OK; // only the engine's own page
LPWSTR text = nullptr;
if (SUCCEEDED(args->TryGetWebMessageAsString(&text)) && text) {
std::string msg = igbio::narrow8(text);
CoTaskMemFree(text);
igbio::handle(msg, [](const std::string& json) { PostMessageW(g_hwnd, WM_BIO_REPLY, 0, (LPARAM) new std::string(json)); });
}
return S_OK;
}).Get(), nullptr);
RECT rc; GetClientRect(g_hwnd, &rc);
g_controller->put_Bounds(rc);
COREWEBVIEW2_COLOR dark = { 255, 12, 12, 14 };
ComPtr<ICoreWebView2Controller2> c2;
if (SUCCEEDED(g_controller.As(&c2)) && c2) c2->put_DefaultBackgroundColor(dark);
g_status = L"";
repaintStatus();
navigate();
return S_OK;
}).Get());
return S_OK;
}).Get());
if (FAILED(hr)) {
g_status = L"The WebView2 runtime is not installed. The dashboard opens in your browser; install the runtime from microsoft.com for the app window.";
repaintStatus();
if (!g_url.empty()) { openInBrowser(g_url); g_hintShown = true; }
}
}
static bool startEngine() {
SECURITY_ATTRIBUTES sa = { sizeof(sa), nullptr, TRUE };
HANDLE outR, outW, inR, inW;
if (!CreatePipe(&outR, &outW, &sa, 0) || !CreatePipe(&inR, &inW, &sa, 0)) return false;
SetHandleInformation(outR, HANDLE_FLAG_INHERIT, 0);
SetHandleInformation(inW, HANDLE_FLAG_INHERIT, 0);
STARTUPINFOW si = { sizeof(si) };
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdOutput = outW;
si.hStdError = GetStdHandle(STD_ERROR_HANDLE);
si.hStdInput = inR;
PROCESS_INFORMATION pi = {};
std::wstring exe = exeDir() + L"\\igneum-wallet.exe";
std::wstring cmd = L"\"" + exe + L"\" --wrapper";
std::vector<wchar_t> buf(cmd.begin(), cmd.end());
buf.push_back(0);
BOOL ok = CreateProcessW(exe.c_str(), buf.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, exeDir().c_str(), &si, &pi);
CloseHandle(outW);
CloseHandle(inR);
if (!ok) { CloseHandle(outR); CloseHandle(inW); return false; }
CloseHandle(pi.hThread);
g_engine = pi.hProcess;
g_engineIn = inW;
g_engineOut = outR;
std::thread([] {
std::string acc;
char chunk[4096];
DWORD n;
while (ReadFile(g_engineOut, chunk, sizeof(chunk), &n, nullptr) && n > 0) {
acc.append(chunk, n);
size_t nl;
while ((nl = acc.find('\n')) != std::string::npos) {
std::string line = acc.substr(0, nl);
acc.erase(0, nl + 1);
if (!line.empty() && line.back() == '\r') line.pop_back();
PostMessageW(g_hwnd, WM_ENGINE_LINE, 0, (LPARAM) new std::string(line));
}
}
PostMessageW(g_hwnd, WM_ENGINE_LINE, 1, 0);
}).detach();
return true;
}
static void showTrayMenu() {
HMENU m = CreatePopupMenu();
AppendMenuW(m, MF_STRING, ID_TRAY_OPEN, L"Open Igneum Wallet");
AppendMenuW(m, MF_STRING, ID_TRAY_PAUSE, g_paused ? L"Lock" : L"Lock");
AppendMenuW(m, MF_SEPARATOR, 0, nullptr);
AppendMenuW(m, MF_STRING | MF_GRAYED, 0, g_trayTitle.c_str());
AppendMenuW(m, MF_SEPARATOR, 0, nullptr);
AppendMenuW(m, MF_STRING, ID_TRAY_QUIT, L"Quit Igneum Wallet");
POINT pt; GetCursorPos(&pt);
SetForegroundWindow(g_hwnd);
TrackPopupMenu(m, TPM_RIGHTBUTTON | TPM_BOTTOMALIGN, pt.x, pt.y, 0, g_hwnd, nullptr);
DestroyMenu(m);
}
static void beginQuit() {
if (g_quitting) return;
g_quitting = true;
g_quitStarted = GetTickCount64();
g_status = L"Stopping: the miner first, then the node";
setTray(L"Igneum Wallet: stopping");
if (g_webview) g_webview->ExecuteScript(L"document.getElementById('pill-text').textContent='stopping'", nullptr);
if (g_engine && !g_exited) {
sendEngine("quit");
SetTimer(g_hwnd, ID_QUIT_TIMER, 500, nullptr);
} else {
DestroyWindow(g_hwnd);
}
}
static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
switch (msg) {
case WM_SIZE:
if (g_controller) { RECT rc; GetClientRect(hwnd, &rc); g_controller->put_Bounds(rc); }
return 0;
case WM_ENGINE_LINE: {
if (wp == 1) {
g_exited = true;
if (g_quitting) { DestroyWindow(hwnd); return 0; }
g_status = L"The engine stopped. Close this window and open Igneum Wallet again.";
setTray(L"Igneum Wallet: stopped");
repaintStatus();
return 0;
}
std::string* line = (std::string*)lp;
if (line->rfind("URL ", 0) == 0) {
g_url = widen(line->substr(4));
if (g_webviewOk) navigate();
else if (!g_webview && g_status.find(L"WebView2") != std::wstring::npos && !g_hintShown) { openInBrowser(g_url); g_hintShown = true; }
} else if (line->rfind("HOST ", 0) == 0) {
// the host token and the sealed file's path; the page never sees this line
g_hostLine = line->substr(5);
igbio::configure(g_hostLine, g_url, L"Igneum Wallet", L"Turn on Windows Hello for your wallet", hwnd);
} else if (line->rfind("STATE ", 0) == 0) {
applyState(line->substr(6));
} else if (line->rfind("ELEVATE ", 0) == 0) {
runElevated(widen(line->substr(8)));
} else if (line->rfind("FATAL ", 0) == 0) {
g_status = widen(line->substr(6));
repaintStatus();
MessageBoxW(hwnd, g_status.c_str(), L"Igneum Wallet", MB_OK | MB_ICONERROR);
} else if (*line == "EXIT") {
g_exited = true;
if (g_quitting) DestroyWindow(hwnd);
}
delete line;
return 0;
}
case WM_BIO_REPLY: {
std::string* json = (std::string*)lp;
if (g_webview) g_webview->PostWebMessageAsJson(igbio::widen8(*json).c_str());
delete json;
return 0;
}
case WM_TIMER:
if (wp == ID_QUIT_TIMER) {
DWORD code = 0;
bool gone = !g_engine || (GetExitCodeProcess(g_engine, &code) && code != STILL_ACTIVE);
if (gone || g_exited || GetTickCount64() - g_quitStarted > 45000) {
if (!gone && g_engine) TerminateProcess(g_engine, 1);
KillTimer(hwnd, ID_QUIT_TIMER);
DestroyWindow(hwnd);
}
}
return 0;
case WM_TRAY:
if (lp == WM_LBUTTONUP || lp == WM_LBUTTONDBLCLK) { ShowWindow(hwnd, SW_SHOW); SetForegroundWindow(hwnd); }
else if (lp == WM_RBUTTONUP || lp == WM_CONTEXTMENU) showTrayMenu();
return 0;
case WM_COMMAND:
switch (LOWORD(wp)) {
case ID_TRAY_OPEN: ShowWindow(hwnd, SW_SHOW); SetForegroundWindow(hwnd); return 0;
case ID_TRAY_PAUSE: sendEngine(g_paused ? "lock" : "lock"); return 0;
case ID_TRAY_QUIT: beginQuit(); return 0;
}
return 0;
case WM_CLOSE:
// hide to the tray; the miner keeps running
ShowWindow(hwnd, SW_HIDE);
if (!g_hintShown) {
g_nid.uFlags |= NIF_INFO;
wcscpy_s(g_nid.szInfoTitle, L"Igneum Wallet keeps mining");
wcscpy_s(g_nid.szInfo, L"The window is in the tray. Right-click the icon to pause or quit.");
g_nid.dwInfoFlags = NIIF_INFO;
Shell_NotifyIconW(NIM_MODIFY, &g_nid);
g_nid.uFlags &= ~NIF_INFO;
g_hintShown = true;
}
return 0;
case WM_PAINT: {
PAINTSTRUCT ps;
HDC dc = BeginPaint(hwnd, &ps);
RECT rc; GetClientRect(hwnd, &rc);
HBRUSH bg = CreateSolidBrush(RGB(12, 12, 14));
FillRect(dc, &rc, bg);
DeleteObject(bg);
if (!g_status.empty()) {
SetBkMode(dc, TRANSPARENT);
SetTextColor(dc, RGB(154, 154, 158));
HFONT f = CreateFontW(-15, 0, 0, 0, FW_NORMAL, 0, 0, 0, DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, 0, L"Segoe UI");
HFONT old = (HFONT)SelectObject(dc, f);
RECT tr = rc; tr.left += 40; tr.right -= 40;
DrawTextW(dc, g_status.c_str(), -1, &tr, DT_CENTER | DT_VCENTER | DT_WORDBREAK | DT_NOPREFIX | (g_webviewOk ? DT_BOTTOM : DT_VCENTER));
SelectObject(dc, old);
DeleteObject(f);
}
EndPaint(hwnd, &ps);
return 0;
}
case WM_DESTROY:
Shell_NotifyIconW(NIM_DELETE, &g_nid);
PostQuitMessage(0);
return 0;
}
return DefWindowProcW(hwnd, msg, wp, lp);
}
// --version and --help print one line and exit, for the CI smoke run and support scripts. A windows-subsystem exe has
// no console of its own: the text goes to the inherited stdout when there is one (a pipe or a file), else to the
// parent's console.
static bool handleCliFlags() {
int argc = 0;
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
if (!argv) return false;
std::wstring text;
for (int i = 1; i < argc; i++) {
std::wstring a = argv[i];
if (a == L"--version" || a == L"-V") text = L"Igneum Wallet " IGNEUM_HOST_VERSION_STR L" (window host)\r\n";
else if (a == L"--help" || a == L"-h" || a == L"/?")
text = L"Igneum Wallet " IGNEUM_HOST_VERSION_STR L" (window host)\r\n"
L"Usage: \"Igneum Wallet.exe\" [--version | --help]\r\n"
L"Without flags it starts igneum-wallet.exe --wrapper next to it and shows the dashboard in a WebView2 window.\r\n";
}
LocalFree(argv);
if (text.empty()) return false;
HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE);
if (out == nullptr || out == INVALID_HANDLE_VALUE) {
if (AttachConsole(ATTACH_PARENT_PROCESS)) out = GetStdHandle(STD_OUTPUT_HANDLE);
}
if (out && out != INVALID_HANDLE_VALUE) {
int n = WideCharToMultiByte(CP_UTF8, 0, text.c_str(), (int)text.size(), nullptr, 0, nullptr, nullptr);
std::string utf8(n, 0);
WideCharToMultiByte(CP_UTF8, 0, text.c_str(), (int)text.size(), &utf8[0], n, nullptr, nullptr);
DWORD written = 0;
WriteFile(out, utf8.data(), (DWORD)utf8.size(), &written, nullptr);
}
return true;
}
int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) {
if (handleCliFlags()) return 0;
HANDLE once = CreateMutexW(nullptr, TRUE, L"Local\\IgneumWalletWindow");
if (GetLastError() == ERROR_ALREADY_EXISTS) {
HWND other = FindWindowW(L"IgneumWalletWindow", nullptr);
if (other) { ShowWindow(other, SW_SHOW); SetForegroundWindow(other); }
return 0;
}
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
WNDCLASSW wc = {};
wc.lpfnWndProc = WndProc;
wc.hInstance = hInst;
wc.lpszClassName = L"IgneumWalletWindow";
wc.hIcon = LoadIconW(hInst, MAKEINTRESOURCEW(IDI_APP));
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
wc.hbrBackground = CreateSolidBrush(RGB(12, 12, 14));
RegisterClassW(&wc);
int w = 1120, h = 820;
int sx = (GetSystemMetrics(SM_CXSCREEN) - w) / 2, sy = (GetSystemMetrics(SM_CYSCREEN) - h) / 2;
g_hwnd = CreateWindowExW(0, wc.lpszClassName, L"Igneum Wallet", WS_OVERLAPPEDWINDOW, sx, sy, w, h, nullptr, nullptr, hInst, nullptr);
ShowWindow(g_hwnd, SW_SHOW);
g_nid.cbSize = sizeof(g_nid);
g_nid.hWnd = g_hwnd;
g_nid.uID = 1;
g_nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
g_nid.uCallbackMessage = WM_TRAY;
g_nid.hIcon = (HICON)LoadImageW(hInst, MAKEINTRESOURCEW(IDI_APP), IMAGE_ICON, 16, 16, LR_DEFAULTCOLOR);
wcscpy_s(g_nid.szTip, L"Igneum Wallet: starting");
Shell_NotifyIconW(NIM_ADD, &g_nid);
if (!startEngine()) {
g_status = L"igneum-wallet.exe is missing next to this program. Run the installer again.";
repaintStatus();
MessageBoxW(g_hwnd, g_status.c_str(), L"Igneum Wallet", MB_OK | MB_ICONERROR);
}
initWebView();
MSG msg;
while (GetMessageW(&msg, nullptr, 0, 0)) {
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
if (g_engine) { CloseHandle(g_engine); }
CloseHandle(once);
CoUninitialize();
return 0;
}

View file

@ -1,36 +0,0 @@
// Resources for Igneum Wallet.exe (the window host): the coin icon and the version block.
// Compiled by BUILD-WALLET-APP.bat with rc.exe; the icon path is relative to brand\icons (passed with /i). The version comes
// from wallet-version.h, shared with wallet-host.cpp.
#include <winver.h>
#include "wallet-version.h"
1 ICON "igneum.ico"
1 VERSIONINFO
FILEVERSION IGNEUM_HOST_VERSION_RC
PRODUCTVERSION IGNEUM_HOST_VERSION_RC
FILEFLAGSMASK 0x3fL
FILEFLAGS 0x0L
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
FILESUBTYPE VFT2_UNKNOWN
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904B0"
BEGIN
VALUE "CompanyName", "Igneum"
VALUE "FileDescription", "Igneum Wallet"
VALUE "FileVersion", IGNEUM_HOST_VERSION_STR
VALUE "InternalName", "Igneum Wallet"
VALUE "LegalCopyright", "Igneum. Nothing is bought or sold."
VALUE "OriginalFilename", "Igneum Wallet.exe"
VALUE "ProductName", "Igneum Wallet"
VALUE "ProductVersion", IGNEUM_HOST_VERSION_STR
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200
END
END

View file

@ -1,7 +0,0 @@
// The version of "Igneum Wallet.exe" (the window host). One place for wallet-host.rc and wallet-host.cpp.
// Keep it equal to app/igneum-wallet/Cargo.toml and the AppVersion default in packaging/windows/Igneum-Wallet.iss.
#ifndef IGNEUM_HOST_VERSION_H
#define IGNEUM_HOST_VERSION_H
#define IGNEUM_HOST_VERSION_STR "0.1.6"
#define IGNEUM_HOST_VERSION_RC 0,1,6,0
#endif

View file

@ -1,6 +1,6 @@
# Igneum brand assets
## The rule (the founder, 4 October 2026)
## The rule (the project lead, 4 October 2026)
One global logo for apps, profile pictures, favicons, everything. The mark sits in a black square. Never in a circle.
Never on another colour. Minimum clear space = 20% of the square. The Mac app icon is the model: a full square, all
@ -41,8 +41,7 @@ polygons). The Windows `.ico` and the favicons render each size from the vector,
| `brand/profile/github-org-512.png` | organisation avatar | github.com/igneum-network (uploaded by hand from the Igneum Chrome profile) |
| `brand/profile/x-banner-1500x500.png` | X header | the mark centred on obsidian |
| `brand/igneum-icon-512.png`, `igneum-icon-1024.png` | the square at 512 and 1024 | the same as the profile files; kept for links that already point here |
| `site/og/<card>.png` (1200 x 630) and `site/og/<card>-square.png` (1200 x 1200), one per row of `CARDS` in `site/og/pages.mjs`: the mark over a soft ember glow, a kicker, one line, a quiet sub line, the route at the foot | share images (WhatsApp, iMessage, Slack, X, LinkedIn); the metas of every page come from `site/og/pages.mjs` through `site/build.mjs`, `?v=` from `OG_VERSION` | every served page; rendered by `node site/og/render.mjs` on a build box from `site/og/card.html` (8 October 2026) |
| `site/og.png`, `og-coin.png`, `og-square.png`, `og-small.png` | the earlier share images, kept only for links already cached by readers; nothing in the site points at them | retired 8 October 2026 |
| `site/og-small.png` (256 square), `og-square.png` (1024), `og-coin.png` and `og.png` (1200 x 630 card: mark left, IGNEUM wordmark, tagline) | share images, `?v=2` in every `og:image` and `twitter:image` | index, live, litepaper (`og-small`); bench, evidence, `build.mjs` (`og`) |
| `brand/profile/github-social-1280x640.png` | repository social preview | github.com/igneum-network/igneum settings (by hand) |
| `brand/profile/vercel-avatar-512.png` | Vercel team or project avatar | by hand |

View file

@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Builds every Igneum icon from the master mark, brand/master/igneum-mark-square.svg (4 October 2026).
The founder's rule (4 October 2026): one global logo for apps, profile pictures, favicons, everything. The mark sits in a black
the project lead's rule (4 October 2026): one global logo for apps, profile pictures, favicons, everything. The mark sits in a black
square (#0C0C0E, the site's obsidian token), centred, no circle, no ring, no border, never on another colour. The Mac app
is the model. The rounded variant (brand/master/igneum-mark-square-rounded.svg, Apple's 824-on-1024 icon grid) is used
only where the shape has to be baked into the file: the DMG volume icon. Tahoe masks the plain square itself.

View file

@ -1,65 +0,0 @@
// Igneum vendor and OS marks (gpu-logos, 7 October 2026): the strings the miner app ships in app/igneum-app/ui/app.js
// (View.MARKS and View.VENDORS), exported verbatim for the site and anything else that names the hardware.
// view.test.mjs fails when this file and app.js drift apart, so edit app.js first and regenerate this file with
// `node brand/marks/regen.mjs` (or copy the strings by hand; the test says which one moved).
//
// Each glyph: a hand-drawn simplified monochrome mark of the vendor's public geometry (never a copied logo file,
// never a raster), 24 x 24 viewBox at 22 px, under 460 bytes, fill or stroke through currentColor so the element's
// colour tints it. Nominative use that names the hardware; the ember accent is for state and never tints a brand.
//
// The treatment in the app (app.css, the block at the end): a 44 x 44 well, radius 12, background the vendor colour
// at .14 alpha (dark) or .10 (light), a 1 px ring in the vendor colour at .45 alpha, the glyph in the full colour;
// hover and focus-within add a 3 px halo of the well colour; nothing animates. The light hex of every vendor reads at
// 3:1 or better on its well over white (nvidia 3.87, amd 5.01, intel 4.29, apple 7.52, gpu 4.65).
//
// Class names in the app: .badge.<vendor> (nvidia | amd | intel | apple | gpu), .badge.mini for a 26 px inline mark,
// .gen for the series line under the name. Tokens: --mark-<vendor>, --mark-<vendor>-well, --mark-<vendor>-ring.
export const VENDORS = {
"nvidia": {
"label": "NVIDIA",
"dark": "#8BE37A",
"light": "#2F8A22"
},
"amd": {
"label": "AMD Radeon",
"dark": "#FF5A5A",
"light": "#C41E2A"
},
"intel": {
"label": "Intel",
"dark": "#7CC4FF",
"light": "#1C6FD6"
},
"apple": {
"label": "Apple",
"dark": "#E6E3DD",
"light": "#4A4A50"
},
"gpu": {
"label": "GPU",
"dark": "#9A9A9E",
"light": "#6B6B70"
}
};
export const WELL_ALPHA = { dark: 0.14, light: 0.1 };
export const MARKS = {
"nvidia": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M2.6 12C5 7.9 8.3 5.8 12 5.8s7 2.1 9.4 6.2c-2.4 4.1-5.7 6.2-9.4 6.2S5 16.1 2.6 12z\"/><path d=\"M15.6 12A3.6 3.6 0 1 0 12 15.6\"/><circle cx=\"12\" cy=\"12\" r=\"1.2\" fill=\"currentColor\" stroke=\"none\"/></svg>",
"amd": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"currentColor\"><path d=\"M8 3h13v13l-4-4V7h-5z\"/><path d=\"M3 8l4 4v5h5l4 4H3z\"/></svg>",
"intel": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\"><path d=\"M7.4 7.6C4.9 8.8 3.4 10.5 3.4 12.3c0 3.4 4.3 5.9 9.8 5.9 4.5 0 7.6-1.6 7.6-3.9 0-1.4-1.3-2.6-3.5-3.3\"/><path d=\"M11.2 9.6v6.2\"/><circle cx=\"11.2\" cy=\"6.6\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\"/></svg>",
"apple": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"currentColor\"><path d=\"M16.4 12.6c0-2.5 2-3.6 2.1-3.7-1.2-1.7-3-1.9-3.6-2-1.5-.2-3 .9-3.8.9-.8 0-2-.9-3.3-.8-1.7 0-3.2 1-4.1 2.5-1.8 3-.5 7.6 1.3 10.1.9 1.2 1.9 2.6 3.2 2.5 1.3 0 1.8-.8 3.3-.8 1.6 0 2 .8 3.3.8 1.4 0 2.3-1.2 3.1-2.5 1-1.4 1.4-2.8 1.4-2.9 0 0-2.7-1-2.9-4.1zM13.9 5.3c.7-.8 1.2-2 1-3.2-1 0-2.2.7-2.9 1.5-.6.7-1.2 1.9-1 3 1.1.1 2.2-.5 2.9-1.3z\"/></svg>",
"gpu": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\"><rect x=\"5.5\" y=\"5.5\" width=\"13\" height=\"13\" rx=\"2.2\"/><rect x=\"9.5\" y=\"9.5\" width=\"5\" height=\"5\" rx=\"1\"/><path d=\"M9 2.5v3M12 2.5v3M15 2.5v3M9 18.5v3M12 18.5v3M15 18.5v3M2.5 9h3M2.5 12h3M2.5 15h3M18.5 9h3M18.5 12h3M18.5 15h3\"/></svg>"
};
// OS marks in the same treatment: Apple is the vendor glyph; Windows is the four slanted panes
export const OS_MARKS = {
macos: MARKS.apple,
windows: "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"currentColor\"><path d=\"M3 5.6l7.3-1v7.1H3zM11.4 4.4L21 3v8.7h-9.6zM3 12.3h7.3v7.1L3 18.4zM11.4 12.3H21V21l-9.6-1.4z\"/></svg>"
};
export const OS_COLOURS = { macos: VENDORS.apple, windows: { label: 'Windows', dark: '#7CC4FF', light: '#1C6FD6' } };
// the CSS tokens for both themes, as the app declares them
export function tokensCss() {
const line = (theme) => Object.entries(VENDORS).map(([v, c]) => { const h = c[theme], r = parseInt(h.slice(1, 3), 16), g = parseInt(h.slice(3, 5), 16), b = parseInt(h.slice(5, 7), 16), a = String(WELL_ALPHA[theme]).replace(/^0/, ''); return `--mark-${v}:${h};--mark-${v}-well:rgba(${r},${g},${b},${a});--mark-${v}-ring:rgba(${r},${g},${b},.45)`; }).join(';');
return `:root{${line('dark')}}\n@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){${line('light')}}}\n:root[data-theme="light"]{${line('light')}}`;
}
// the well: <div class="badge nvidia"><svg…></svg></div>
export function markHtml(vendor, size) { const v = MARKS[vendor] ? vendor : 'gpu'; return '<div class="badge ' + (size ? size + ' ' : '') + v + '" data-mark="' + v + '" title="' + VENDORS[v].label + '">' + MARKS[v] + '</div>'; }

View file

@ -15,7 +15,7 @@ Mark description for the device, when a form asks: "A stylised flame formed of t
## Applicant
Igneum Labs LTD, Licensee Address: Unit IH-00-01-01-OF-01, Level 01, Innovation One, Dubai International Financial
Centre (decided 5 October 2026; it replaces the ADGM DLT Foundation named on 4 October). NOT the earlier entity: an earlier-entity
Centre (decided 5 October 2026; it replaces the ADGM DLT Foundation named on 4 October). NOT [other-business]: a [other-business]
filing would tie Igneum to VIVA and to its owner through public registers, which the standing rule forbids. The
registered address above is the applicant address, with a trademark attorney as the address for service, so no
personal address appears anywhere. If the company's registration is not complete the week you want to file, the

View file

@ -1,18 +0,0 @@
[profile.default]
src = "src"
test = "test"
script = "script"
out = "out"
libs = []
solc_version = "0.8.28"
# The verifier calls the BLS12-381 precompiles of EIP-2537 (live on Sepolia and mainnet since Pectra), so the test EVM
# runs the Osaka rules, the fork Sepolia is on (EIP-7883 modexp pricing counts here).
evm_version = "osaka"
optimizer = true
optimizer_runs = 200
via_ir = true
fs_permissions = [{ access = "read", path = "./test/vectors" }, { access = "read-write", path = "./deploy-out.json" }]
auto_detect_remappings = false
[rpc_endpoints]
sepolia = "https://ethereum-sepolia-rpc.publicnode.com"

View file

@ -1,46 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Vm, VM_ADDRESS} from "../test/Vm.sol";
import {IgneumCertificateVerifier} from "../src/IgneumCertificateVerifier.sol";
/// Deploys the verifier on Sepolia from BRIDGE_DEPLOYER_KEY (environment, never printed), installs the voter table
/// from the vectors file named in BRIDGE_TABLE_JSON (a gen.mjs output: keys, weights, index, chain_id) and, when the
/// file carries a certificate (bitmap, signature), submits it, so the contract records one checkpoint final from the start.
///
/// BRIDGE_TABLE_JSON=test/vectors/chain.json forge script script/Deploy.s.sol:Deploy --rpc-url sepolia --broadcast --sig "run()"
contract Deploy {
Vm constant vm = Vm(VM_ADDRESS);
function run() external {
uint256 key = vm.envUint("BRIDGE_DEPLOYER_KEY");
string memory j = vm.readFile(vm.envOr("BRIDGE_TABLE_JSON", "test/vectors/chain.json"));
bytes[] memory keys = vm.parseJsonBytesArray(j, ".keys");
uint256[] memory w = vm.parseJsonUintArray(j, ".weights");
bytes memory packed;
uint64[] memory weights = new uint64[](w.length);
for (uint256 i = 0; i < keys.length; i++) {
packed = abi.encodePacked(packed, keys[i]);
weights[i] = uint64(w[i]);
}
uint64 index = uint64(vm.parseJsonUint(j, ".index"));
vm.startBroadcast(key);
IgneumCertificateVerifier v = new IgneumCertificateVerifier(vm.parseJsonString(j, ".chain_id"));
v.installTable(index, packed, weights);
bool hasCert = vm.keyExistsJson(j, ".bitmap");
if (hasCert) {
v.submitCertificate(index, vm.parseJsonBytes32(j, ".checkpoint"), vm.parseJsonBytes(j, ".bitmap"), vm.parseJsonBytes(j, ".signature"));
}
vm.stopBroadcast();
vm.writeFile(
"deploy-out.json",
string.concat(
"{\n \"IgneumCertificateVerifier\": \"", vm.toString(address(v)), "\",\n \"chain_id\": \"", vm.parseJsonString(j, ".chain_id"),
"\",\n \"table_index\": ", vm.toString(uint256(index)), ",\n \"voters\": ", vm.toString(keys.length), ",\n \"table_id\": \"",
vm.toString(v.tableId()), "\",\n \"final_checkpoint\": \"", hasCert ? vm.toString(vm.parseJsonBytes32(j, ".checkpoint")) : "none yet", "\"\n}\n"
)
);
}
}

View file

@ -1,37 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Vm, VM_ADDRESS} from "../test/Vm.sol";
import {IgneumCertificateVerifier} from "../src/IgneumCertificateVerifier.sol";
/// Installs (or replaces) the voter table on an already deployed verifier (BRIDGE_VERIFIER) from the vectors file in
/// BRIDGE_TABLE_JSON (a gen.mjs table or chain output: keys, weights, index) and, when the file carries a certificate
/// (bitmap, signature), submits it. The deployer key comes from BRIDGE_DEPLOYER_KEY in the environment, never printed.
/// Used for a chain whose table is read at its first lock, after the contract itself was deployed (8 October 2026,
/// igneum-devnet-4: the contract first, the table when the weight window filled).
///
/// BRIDGE_VERIFIER=0x.. BRIDGE_TABLE_JSON=test/vectors/dn4-table.json forge script script/Install.s.sol:Install --rpc-url sepolia --broadcast --sig "run()"
contract Install {
Vm constant vm = Vm(VM_ADDRESS);
function run() external {
uint256 key = vm.envUint("BRIDGE_DEPLOYER_KEY");
IgneumCertificateVerifier v = IgneumCertificateVerifier(vm.envAddress("BRIDGE_VERIFIER"));
string memory j = vm.readFile(vm.envOr("BRIDGE_TABLE_JSON", "test/vectors/dn4-table.json"));
bytes[] memory keys = vm.parseJsonBytesArray(j, ".keys");
uint256[] memory w = vm.parseJsonUintArray(j, ".weights");
bytes memory packed;
uint64[] memory weights = new uint64[](w.length);
for (uint256 i = 0; i < keys.length; i++) {
packed = abi.encodePacked(packed, keys[i]);
weights[i] = uint64(w[i]);
}
uint64 index = uint64(vm.parseJsonUint(j, ".index"));
vm.startBroadcast(key);
v.installTable(index, packed, weights);
if (vm.keyExistsJson(j, ".bitmap")) {
v.submitCertificate(index, vm.parseJsonBytes32(j, ".checkpoint"), vm.parseJsonBytes(j, ".bitmap"), vm.parseJsonBytes(j, ".signature"));
}
vm.stopBroadcast();
}
}

View file

@ -1,22 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Vm, VM_ADDRESS} from "../test/Vm.sol";
/// Sends SEND_WEI of the chain's coin from the key in BRIDGE_DEPLOYER_KEY to SEND_TO (Sepolia test ETH between the
/// lanes' throwaway deployers). The key is read from the environment and never printed.
///
/// SEND_TO=0x.. SEND_WEI=20000000000000000 forge script script/Send.s.sol:Send --rpc-url sepolia --broadcast --sig "run()"
contract Send {
Vm constant vm = Vm(VM_ADDRESS);
function run() external {
uint256 key = vm.envUint("BRIDGE_DEPLOYER_KEY");
address to = vm.envAddress("SEND_TO");
uint256 wei_ = vm.envUint("SEND_WEI");
vm.startBroadcast(key);
(bool ok,) = to.call{value: wei_}("");
require(ok, "send failed");
vm.stopBroadcast();
}
}

View file

@ -1,113 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// BLS12-381 through the EIP-2537 precompiles (Ethereum mainnet and Sepolia since Pectra): the hash-to-curve of
/// RFC 9380 (BLS12381G2_XMD:SHA-256_SSWU_RO_) with the caller's domain separation tag, public-key aggregation in G1
/// and the two-pairing check of a "minimal public key" signature (keys in G1, signatures in G2), the scheme of
/// Igneum's finality votes (consensus/core/src/finality.rs, blst "min_pk").
///
/// Encodings are the precompiles' own: a field element is 64 bytes (16 zero bytes then the 48-byte big-endian
/// value), a G1 point 128 bytes (x, y), a G2 point 256 bytes (x.c0, x.c1, y.c0, y.c1). Compressed chain forms
/// (48-byte keys, 96-byte signatures) are decompressed off chain by the submitter; the pairing precompile refuses
/// a point off the curve or outside the prime-order subgroup, so a wrong decompression fails the check.
library BLS12381 {
address internal constant G1ADD = address(0x0b);
address internal constant G2ADD = address(0x0d);
address internal constant PAIRING = address(0x0f);
address internal constant MAP_FP2_TO_G2 = address(0x11);
address internal constant MODEXP = address(0x05);
uint256 internal constant G1_LEN = 128;
uint256 internal constant G2_LEN = 256;
/// The field modulus p, big-endian, 48 bytes (the modexp precompile's modulus).
bytes internal constant P = hex"1a0111ea397fe69a4b1ba7b6434bacd764774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaab";
/// The G1 generator with its y negated (p - y), in the 128-byte encoding, for the pairing check
/// e(pk, H(m)) * e(-G1, sig) == 1.
bytes internal constant NEG_G1 =
hex"0000000000000000000000000000000017f1d3a73197d7942695638c4fa9ac0fc3688c4f9774b905a14e3a3f171bac586c55e83ff97a1aeffb3af00adb22c6bb"
hex"00000000000000000000000000000000114d1d6855d545a8aa7d76c8cf2e21f267816aef1db507c96655b9d5caac42364e6f38ba0ecb751bad54dcd6b939c2ca";
error PrecompileFailed(address which);
error BadLength(string what);
// ---- hash to curve ----
/// expand_message_xmd with SHA-256 (RFC 9380 section 5.3.1) to `len` bytes; len at most 255 * 32.
function expandMessageXmd(bytes memory msg_, bytes memory dst, uint256 len) internal pure returns (bytes memory out) {
require(dst.length <= 255, "BLS: DST too long");
uint256 ell = (len + 31) / 32;
require(ell <= 255 && len > 0, "BLS: bad length");
bytes memory dstPrime = abi.encodePacked(dst, uint8(dst.length));
bytes32 b0 = sha256(abi.encodePacked(new bytes(64), msg_, uint16(len), uint8(0), dstPrime));
bytes32 bi = sha256(abi.encodePacked(b0, uint8(1), dstPrime));
out = new bytes(ell * 32);
assembly {
mstore(add(out, 32), bi)
}
for (uint256 i = 2; i <= ell; i++) {
bi = sha256(abi.encodePacked(b0 ^ bi, uint8(i), dstPrime));
assembly {
mstore(add(add(out, 32), mul(sub(i, 1), 32)), bi)
}
}
assembly {
mstore(out, len)
}
}
/// A 64-byte big-endian integer reduced mod p and returned in the precompiles' 64-byte field encoding.
function reduce64(bytes memory chunk, uint256 offset) internal view returns (bytes memory fe) {
require(chunk.length >= offset + 64, "BLS: chunk");
bytes memory base = new bytes(64);
for (uint256 i = 0; i < 64; i++) {
base[i] = chunk[offset + i];
}
// modexp(base^1 mod p): lengths 64, 1, 48
bytes memory input = abi.encodePacked(uint256(64), uint256(1), uint256(48), base, uint8(1), P);
(bool ok, bytes memory r) = MODEXP.staticcall(input);
if (!ok || r.length != 48) revert PrecompileFailed(MODEXP);
fe = abi.encodePacked(bytes16(0), r);
}
/// hash_to_curve for G2: two field elements of Fp2 from a 256-byte expansion, each mapped by the precompile
/// (which clears the cofactor), then added.
function hashToG2(bytes memory msg_, bytes memory dst) internal view returns (bytes memory point) {
bytes memory u = expandMessageXmd(msg_, dst, 256);
bytes memory q0 = mapFp2ToG2(abi.encodePacked(reduce64(u, 0), reduce64(u, 64)));
bytes memory q1 = mapFp2ToG2(abi.encodePacked(reduce64(u, 128), reduce64(u, 192)));
point = g2Add(q0, q1);
}
function mapFp2ToG2(bytes memory fp2) internal view returns (bytes memory point) {
if (fp2.length != 128) revert BadLength("fp2");
(bool ok, bytes memory r) = MAP_FP2_TO_G2.staticcall(fp2);
if (!ok || r.length != G2_LEN) revert PrecompileFailed(MAP_FP2_TO_G2);
point = r;
}
// ---- group operations ----
function g1Add(bytes memory a, bytes memory b) internal view returns (bytes memory c) {
if (a.length != G1_LEN || b.length != G1_LEN) revert BadLength("g1");
(bool ok, bytes memory r) = G1ADD.staticcall(abi.encodePacked(a, b));
if (!ok || r.length != G1_LEN) revert PrecompileFailed(G1ADD);
c = r;
}
function g2Add(bytes memory a, bytes memory b) internal view returns (bytes memory c) {
if (a.length != G2_LEN || b.length != G2_LEN) revert BadLength("g2");
(bool ok, bytes memory r) = G2ADD.staticcall(abi.encodePacked(a, b));
if (!ok || r.length != G2_LEN) revert PrecompileFailed(G2ADD);
c = r;
}
/// e(pk, hm) * e(-G1, sig) == 1, which holds exactly when sig = sk * hm for pk = sk * G1.
function verifyMinPk(bytes memory pk, bytes memory hm, bytes memory sig) internal view returns (bool) {
if (pk.length != G1_LEN || hm.length != G2_LEN || sig.length != G2_LEN) revert BadLength("pairing");
(bool ok, bytes memory r) = PAIRING.staticcall(abi.encodePacked(pk, hm, NEG_G1, sig));
if (!ok || r.length != 32) return false;
return abi.decode(r, (uint256)) == 1;
}
}

View file

@ -1,155 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {BLS12381} from "./BLS12381.sol";
import {MerklePatricia} from "./MerklePatricia.sol";
interface IIgneumCertificateVerifier {
function chainId() external view returns (string memory);
function tableId() external view returns (bytes32);
function verifyCertificate(uint64 index, bytes32 checkpoint, bytes calldata bitmap, bytes calldata signature)
external
view
returns (bool ok, uint256 signedWeight, uint256 totalWeight);
function submitCertificate(uint64 index, bytes32 checkpoint, bytes calldata bitmap, bytes calldata signature) external;
function finalCheckpoint(uint64 index) external view returns (bytes32);
function isFinal(bytes32 checkpoint) external view returns (bool);
function verifyAccount(bytes32 stateRoot, address account, bytes[] calldata proof)
external
pure
returns (bool exists, uint256 nonce, uint256 balance, bytes32 storageRoot, bytes32 codeHash);
}
/// The Igneum light-client bridge primitive on Ethereum: verifies a Devnet 3 finality certificate (the aggregate
/// BLS signature of the canonical voter list over the vote message, under the 2/3-of-total-weight rule) against an
/// installed voter table, records the checkpoint hashes it proved final, and verifies an Ethereum-shape account
/// proof against a state root. What it proves and what it does not: docs/bridge/light-client-bridge.md.
///
/// Devnet 3, test tokens, no value.
contract IgneumCertificateVerifier is IIgneumCertificateVerifier {
using MerklePatricia for bytes32;
string public constant VOTE_PREFIX = "igneum-vote-v1/";
bytes public constant DST_VOTE = "IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_";
string private _chainId;
address public owner;
/// The canonical voter list at the installed checkpoint index: 128-byte G1 keys in the node's canonical order
/// (sorted by key hash, every key above dust and not stripped) and their weights (blue blocks in the window).
bytes[] private _keys;
uint64[] private _weights;
uint256 public totalWeight;
uint64 public tableIndex;
bytes32 public override tableId;
mapping(uint64 => bytes32) public override finalCheckpoint;
mapping(bytes32 => bool) public override isFinal;
event TableInstalled(uint64 indexed atIndex, uint256 voters, uint256 totalWeight, bytes32 tableId);
event CheckpointFinal(uint64 indexed index, bytes32 checkpoint, uint256 signedWeight, uint256 totalWeight, uint256 signers);
error NotOwner();
error NoTable();
error BadCertificate(string why);
constructor(string memory chainId_) {
_chainId = chainId_;
owner = msg.sender;
}
function chainId() external view override returns (string memory) {
return _chainId;
}
function voterCount() external view returns (uint256) {
return _keys.length;
}
function voter(uint256 i) external view returns (bytes memory key, uint64 weight) {
return (_keys[i], _weights[i]);
}
/// Installs the voter table read from a Devnet 3 node (igneum_getFinalityWeights at `atIndex`): `keys` is the
/// concatenation of 128-byte uncompressed G1 keys in canonical order, `weights` their weights. The table is a
/// trusted input of this first version (see the doc); only the installer may replace it.
function installTable(uint64 atIndex, bytes calldata keys, uint64[] calldata weights) external {
if (msg.sender != owner) revert NotOwner();
if (keys.length != weights.length * BLS12381.G1_LEN || weights.length == 0) revert BadCertificate("table shape");
delete _keys;
delete _weights;
uint256 total;
for (uint256 i = 0; i < weights.length; i++) {
_keys.push(keys[i * BLS12381.G1_LEN:(i + 1) * BLS12381.G1_LEN]);
_weights.push(weights[i]);
total += weights[i];
}
totalWeight = total;
tableIndex = atIndex;
tableId = keccak256(abi.encodePacked(atIndex, keys, abi.encodePacked(weights)));
emit TableInstalled(atIndex, weights.length, total, tableId);
}
/// The bytes every voter signs for (index, checkpoint): "igneum-vote-v1/" chain_id 0x00 index_le64 checkpoint.
function voteMessage(uint64 index, bytes32 checkpoint) public view returns (bytes memory) {
return abi.encodePacked(VOTE_PREFIX, _chainId, bytes1(0), le64(index), checkpoint);
}
function verifyCertificate(uint64 index, bytes32 checkpoint, bytes calldata bitmap, bytes calldata signature)
public
view
override
returns (bool ok, uint256 signedWeight, uint256 totalWeight_)
{
uint256 n = _keys.length;
if (n == 0) revert NoTable();
if (bitmap.length != (n + 7) / 8) revert BadCertificate("bitmap length");
if (signature.length != BLS12381.G2_LEN) revert BadCertificate("signature length");
bytes memory agg;
uint256 signers;
for (uint256 p = 0; p < n; p++) {
if (uint8(bitmap[p >> 3]) & uint8(1 << (p & 7)) == 0) continue;
signedWeight += _weights[p];
signers++;
agg = agg.length == 0 ? _keys[p] : BLS12381.g1Add(agg, _keys[p]);
}
totalWeight_ = totalWeight;
if (signers == 0) return (false, 0, totalWeight_);
// the rule decided 4 October 2026: signed weight at least two thirds of the whole window's weight
if (3 * signedWeight < 2 * totalWeight_) return (false, signedWeight, totalWeight_);
bytes memory hm = BLS12381.hashToG2(voteMessage(index, checkpoint), DST_VOTE);
ok = BLS12381.verifyMinPk(agg, hm, signature);
}
function submitCertificate(uint64 index, bytes32 checkpoint, bytes calldata bitmap, bytes calldata signature) external override {
(bool ok, uint256 signed, uint256 total) = verifyCertificate(index, checkpoint, bitmap, signature);
if (!ok) revert BadCertificate("certificate does not verify");
bytes32 known = finalCheckpoint[index];
if (known != bytes32(0) && known != checkpoint) revert BadCertificate("a different checkpoint is final at this index");
finalCheckpoint[index] = checkpoint;
isFinal[checkpoint] = true;
uint256 signers;
for (uint256 p = 0; p < _keys.length; p++) {
if (uint8(bitmap[p >> 3]) & uint8(1 << (p & 7)) != 0) signers++;
}
emit CheckpointFinal(index, checkpoint, signed, total, signers);
}
function verifyAccount(bytes32 stateRoot, address account, bytes[] calldata proof)
external
pure
override
returns (bool exists, uint256 nonce, uint256 balance, bytes32 storageRoot, bytes32 codeHash)
{
MerklePatricia.Account memory a = MerklePatricia.verifyAccount(stateRoot, account, proof);
return (a.exists, a.nonce, a.balance, a.storageRoot, a.codeHash);
}
function le64(uint64 v) internal pure returns (bytes8 out) {
uint64 r;
for (uint256 i = 0; i < 8; i++) {
r = (r << 8) | ((v >> (8 * i)) & 0xff);
}
out = bytes8(r);
}
}

View file

@ -1,214 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// An Ethereum account proof (the eth_getProof shape) checked against a state root: the keccak-keyed Merkle
/// Patricia trie of reth's layout, which Igneum's executor uses for its stateRoot (igneum/exec/src/state.rs,
/// alloy_trie::root::state_root over keccak256(address) keys and RLP(nonce, balance, storageRoot, codeHash)
/// values). A proof is the list of RLP nodes from the root to the account's leaf, or to the branch or leaf that
/// shows the account absent.
library MerklePatricia {
struct Account {
bool exists;
uint256 nonce;
uint256 balance;
bytes32 storageRoot;
bytes32 codeHash;
}
error BadProof(string why);
/// Verifies `proof` for `account` under `stateRoot`; reverts when a node does not hash to its reference or
/// the path is malformed, returns exists=false when the trie shows no such account.
function verifyAccount(bytes32 stateRoot, address account, bytes[] memory proof) internal pure returns (Account memory out) {
bytes memory value = verifyPath(stateRoot, abi.encodePacked(keccak256(abi.encodePacked(account))), proof);
if (value.length == 0) return out;
(uint256 off, uint256 len, bool isList) = decode(value, 0);
if (!isList) revert BadProof("account value is not a list");
uint256 end = off + len;
uint256 p = off;
(uint256 o1, uint256 l1,) = decode(value, p);
out.nonce = toUint(value, o1, l1);
p = o1 + l1;
(uint256 o2, uint256 l2,) = decode(value, p);
out.balance = toUint(value, o2, l2);
p = o2 + l2;
(uint256 o3, uint256 l3,) = decode(value, p);
if (l3 != 32) revert BadProof("storage root length");
out.storageRoot = toBytes32(value, o3);
p = o3 + l3;
(uint256 o4, uint256 l4,) = decode(value, p);
if (l4 != 32) revert BadProof("code hash length");
out.codeHash = toBytes32(value, o4);
if (o4 + l4 != end) revert BadProof("account value has extra fields");
out.exists = true;
}
/// Walks the proof for `key` (32 bytes, hashed already) from `root`; returns the value found, or empty bytes
/// when the trie proves the key absent.
function verifyPath(bytes32 root, bytes memory key, bytes[] memory proof) internal pure returns (bytes memory value) {
bytes memory nibbles = toNibbles(key);
uint256 pos = 0;
bytes32 want = root;
bytes memory embedded;
for (uint256 i = 0; i < proof.length; i++) {
bytes memory node = proof[i];
if (embedded.length != 0) {
if (keccak256(node) != keccak256(embedded)) revert BadProof("embedded node mismatch");
embedded = "";
} else if (keccak256(node) != want) {
revert BadProof("node hash mismatch");
}
(uint256 off, uint256 len, bool isList) = decode(node, 0);
if (!isList) revert BadProof("node is not a list");
uint256 count = itemCount(node, off, len);
if (count == 17) {
if (pos == nibbles.length) {
// the branch's own value slot
(uint256 vo, uint256 vl,) = itemAt(node, off, 16);
return slice(node, vo, vl);
}
uint8 nib = uint8(nibbles[pos]);
(uint256 co, uint256 cl, bool clist) = itemAt(node, off, nib);
if (cl == 0 && !clist) return ""; // empty slot: the key is absent
pos++;
if (clist) {
embedded = slice(node, co - headerLen(node, co, cl, true), cl + headerLen(node, co, cl, true));
} else {
if (cl != 32) revert BadProof("child reference length");
want = toBytes32(node, co);
}
} else if (count == 2) {
(uint256 po, uint256 pl,) = itemAt(node, off, 0);
(bytes memory path, bool isLeaf) = decodePath(slice(node, po, pl));
if (!matches(nibbles, pos, path)) return ""; // diverging path: the key is absent
pos += path.length;
(uint256 vo, uint256 vl, bool vlist) = itemAt(node, off, 1);
if (isLeaf) {
if (pos != nibbles.length) revert BadProof("leaf before the key's end");
return slice(node, vo, vl);
}
if (vlist) {
embedded = slice(node, vo - headerLen(node, vo, vl, true), vl + headerLen(node, vo, vl, true));
} else {
if (vl != 32) revert BadProof("extension reference length");
want = toBytes32(node, vo);
}
} else {
revert BadProof("node arity");
}
}
revert BadProof("proof ends before the key");
}
// ---- paths ----
function toNibbles(bytes memory key) internal pure returns (bytes memory n) {
n = new bytes(key.length * 2);
for (uint256 i = 0; i < key.length; i++) {
n[2 * i] = bytes1(uint8(key[i]) >> 4);
n[2 * i + 1] = bytes1(uint8(key[i]) & 0x0f);
}
}
/// Hex-prefix decoding of a leaf or extension path.
function decodePath(bytes memory hp) internal pure returns (bytes memory path, bool isLeaf) {
if (hp.length == 0) revert BadProof("empty path");
uint8 flag = uint8(hp[0]) >> 4;
isLeaf = flag >= 2;
bool odd = flag % 2 == 1;
uint256 n = (hp.length - 1) * 2 + (odd ? 1 : 0);
path = new bytes(n);
uint256 w = 0;
if (odd) path[w++] = bytes1(uint8(hp[0]) & 0x0f);
for (uint256 i = 1; i < hp.length; i++) {
path[w++] = bytes1(uint8(hp[i]) >> 4);
path[w++] = bytes1(uint8(hp[i]) & 0x0f);
}
}
function matches(bytes memory nibbles, uint256 pos, bytes memory path) internal pure returns (bool) {
if (pos + path.length > nibbles.length) return false;
for (uint256 i = 0; i < path.length; i++) {
if (nibbles[pos + i] != path[i]) return false;
}
return true;
}
// ---- RLP ----
/// The item at `p`: the offset of its payload, the payload length and whether it is a list.
function decode(bytes memory b, uint256 p) internal pure returns (uint256 off, uint256 len, bool isList) {
if (p >= b.length) revert BadProof("rlp out of range");
uint8 first = uint8(b[p]);
if (first < 0x80) return (p, 1, false);
if (first < 0xb8) return (p + 1, first - 0x80, false);
if (first < 0xc0) {
uint256 n = first - 0xb7;
return (p + 1 + n, readLen(b, p + 1, n), false);
}
if (first < 0xf8) return (p + 1, first - 0xc0, true);
uint256 m = first - 0xf7;
return (p + 1 + m, readLen(b, p + 1, m), true);
}
function headerLen(bytes memory b, uint256 off, uint256 len, bool isList) private pure returns (uint256) {
// the header length of an item whose payload starts at off: single bytes under 0x80 have none
if (!isList && len == 1 && uint8(b[off]) < 0x80) return 0;
if (len < 56) return 1;
uint256 n = 0;
uint256 l = len;
while (l > 0) {
n++;
l >>= 8;
}
return 1 + n;
}
function readLen(bytes memory b, uint256 p, uint256 n) private pure returns (uint256 len) {
if (n == 0 || n > 32 || p + n > b.length) revert BadProof("rlp length");
for (uint256 i = 0; i < n; i++) {
len = (len << 8) | uint8(b[p + i]);
}
}
function itemCount(bytes memory b, uint256 off, uint256 len) private pure returns (uint256 n) {
uint256 p = off;
uint256 end = off + len;
while (p < end) {
(uint256 o, uint256 l,) = decode(b, p);
p = o + l;
n++;
}
if (p != end) revert BadProof("rlp list overrun");
}
function itemAt(bytes memory b, uint256 off, uint256 index) private pure returns (uint256 o, uint256 l, bool isList) {
uint256 p = off;
for (uint256 i = 0; ; i++) {
(o, l, isList) = decode(b, p);
if (i == index) return (o, l, isList);
p = o + l;
}
}
function toUint(bytes memory b, uint256 off, uint256 len) private pure returns (uint256 v) {
if (len > 32) revert BadProof("integer too long");
for (uint256 i = 0; i < len; i++) {
v = (v << 8) | uint8(b[off + i]);
}
}
function toBytes32(bytes memory b, uint256 off) private pure returns (bytes32 v) {
assembly {
v := mload(add(add(b, 32), off))
}
}
function slice(bytes memory b, uint256 off, uint256 len) private pure returns (bytes memory out) {
if (off + len > b.length) revert BadProof("slice out of range");
out = new bytes(len);
for (uint256 i = 0; i < len; i++) {
out[i] = b[off + i];
}
}
}

View file

@ -1,144 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Vm, VM_ADDRESS} from "./Vm.sol";
import {IgneumCertificateVerifier} from "../src/IgneumCertificateVerifier.sol";
import {BLS12381} from "../src/BLS12381.sol";
/// The verifier on Foundry's Prague EVM (the EIP-2537 precompiles): the synthetic vectors made by
/// test/vectors/gen.mjs (five keys, a certificate by four of them, a small account trie) and, when present, a real
/// certificate from the chain (test/vectors/chain.json, as /api/checkpoint serves it, decompressed by gen.mjs).
contract VerifierTest {
Vm constant vm = Vm(VM_ADDRESS);
string json;
IgneumCertificateVerifier v;
function setUp() public {
json = vm.readFile("test/vectors/synthetic.json");
v = new IgneumCertificateVerifier(vm.parseJsonString(json, ".chain_id"));
_install(v, json);
}
function _install(IgneumCertificateVerifier target, string memory j) internal {
bytes[] memory keys = vm.parseJsonBytesArray(j, ".keys");
uint256[] memory w = vm.parseJsonUintArray(j, ".weights");
bytes memory packed;
uint64[] memory weights = new uint64[](w.length);
for (uint256 i = 0; i < keys.length; i++) {
packed = abi.encodePacked(packed, keys[i]);
weights[i] = uint64(w[i]);
}
target.installTable(uint64(vm.parseJsonUint(j, ".index")), packed, weights);
}
function test_vote_message_matches_the_node() public view {
bytes memory want = vm.parseJsonBytes(json, ".vote_message");
bytes memory got = v.voteMessage(uint64(vm.parseJsonUint(json, ".index")), vm.parseJsonBytes32(json, ".checkpoint"));
require(keccak256(want) == keccak256(got), "vote message");
}
function test_expand_message_xmd_known_answer() public view {
// RFC 9380 appendix K.1 (expand_message_xmd with SHA-256, DST "QUUX-V01-CS02-with-expander-SHA256-128"): the
// empty message at 32 bytes is the appendix's own first answer; the "abc" at 128 bytes answer comes from noble
bytes memory dst = bytes(vm.parseJsonString(json, ".xmd_dst"));
bytes memory out = BLS12381.expandMessageXmd("", dst, 32);
require(keccak256(out) == keccak256(hex"68a985b87eb6b46952128911f2a4412bbc302a9d759667f87f7a21d803f07235"), "xmd 32 (RFC)");
require(keccak256(out) == keccak256(vm.parseJsonBytes(json, ".xmd_empty_32")), "xmd 32 (noble)");
bytes memory out2 = BLS12381.expandMessageXmd("abc", dst, 128);
require(keccak256(out2) == keccak256(vm.parseJsonBytes(json, ".xmd_abc_128")), "xmd 128 (noble)");
}
function test_certificate_verifies() public view {
(bool ok, uint256 signed, uint256 total) = v.verifyCertificate(
uint64(vm.parseJsonUint(json, ".index")), vm.parseJsonBytes32(json, ".checkpoint"), vm.parseJsonBytes(json, ".bitmap"), vm.parseJsonBytes(json, ".signature")
);
require(ok, "certificate");
require(signed == vm.parseJsonUint(json, ".signed_weight") && total == vm.parseJsonUint(json, ".total_weight"), "weights");
}
function test_certificate_under_two_thirds_is_refused() public view {
(bool ok, uint256 signed,) = v.verifyCertificate(
uint64(vm.parseJsonUint(json, ".index")), vm.parseJsonBytes32(json, ".checkpoint"), vm.parseJsonBytes(json, ".weak_bitmap"), vm.parseJsonBytes(json, ".weak_signature")
);
require(!ok && signed * 3 < vm.parseJsonUint(json, ".total_weight") * 2, "weak certificate accepted");
}
function test_wrong_checkpoint_or_index_fails() public view {
bytes32 cp = vm.parseJsonBytes32(json, ".checkpoint");
uint64 index = uint64(vm.parseJsonUint(json, ".index"));
bytes memory bm = vm.parseJsonBytes(json, ".bitmap");
bytes memory sig = vm.parseJsonBytes(json, ".signature");
(bool ok1,,) = v.verifyCertificate(index, cp ^ bytes32(uint256(1)), bm, sig);
(bool ok2,,) = v.verifyCertificate(index + 1, cp, bm, sig);
require(!ok1 && !ok2, "forged certificate accepted");
// the right signers' weight with a bitmap naming a different signer set does not match the signature
bytes memory other = vm.parseJsonBytes(json, ".weak_bitmap");
other[0] = bytes1(uint8(other[0]) | 0x1f);
(bool ok3,,) = v.verifyCertificate(index, cp, other, sig);
require(!ok3, "wrong signer set accepted");
}
function test_submit_records_the_checkpoint() public {
bytes32 cp = vm.parseJsonBytes32(json, ".checkpoint");
uint64 index = uint64(vm.parseJsonUint(json, ".index"));
v.submitCertificate(index, cp, vm.parseJsonBytes(json, ".bitmap"), vm.parseJsonBytes(json, ".signature"));
require(v.isFinal(cp) && v.finalCheckpoint(index) == cp, "not recorded");
vm.expectRevert(abi.encodeWithSelector(IgneumCertificateVerifier.BadCertificate.selector, "certificate does not verify"));
v.submitCertificate(index, cp, vm.parseJsonBytes(json, ".weak_bitmap"), vm.parseJsonBytes(json, ".weak_signature"));
}
function test_account_proof_present_and_absent() public view {
bytes32 root = vm.parseJsonBytes32(json, ".state_root");
(bool exists, uint256 nonce, uint256 balance, bytes32 sroot, bytes32 chash) =
v.verifyAccount(root, vm.parseJsonAddress(json, ".account"), vm.parseJsonBytesArray(json, ".account_proof"));
require(exists, "account absent");
require(nonce == vm.parseJsonUint(json, ".account_nonce") && balance == vm.parseJsonUint(json, ".account_balance"), "account fields");
require(sroot == vm.parseJsonBytes32(json, ".account_storage_root") && chash == vm.parseJsonBytes32(json, ".account_code_hash"), "account roots");
(bool exists2,,,,) = v.verifyAccount(root, vm.parseJsonAddress(json, ".absent_account"), vm.parseJsonBytesArray(json, ".absent_proof"));
require(!exists2, "absent account present");
}
function test_account_proof_against_a_wrong_root_reverts() public {
bytes32 root = vm.parseJsonBytes32(json, ".state_root") ^ bytes32(uint256(1));
bytes[] memory proof = vm.parseJsonBytesArray(json, ".account_proof");
address a = vm.parseJsonAddress(json, ".account");
vm.expectRevert(abi.encodeWithSelector(bytes4(keccak256("BadProof(string)")), "node hash mismatch"));
v.verifyAccount(root, a, proof);
}
/// A certificate the chain actually carried (test/vectors/chain.json; skipped when the file is absent).
function test_chain_certificate_verifies() public {
string memory j;
try vm.readFile("test/vectors/chain.json") returns (string memory s) {
j = s;
} catch {
return;
}
IgneumCertificateVerifier c = new IgneumCertificateVerifier(vm.parseJsonString(j, ".chain_id"));
_install(c, j);
(bool ok, uint256 signed, uint256 total) = c.verifyCertificate(
uint64(vm.parseJsonUint(j, ".index")), vm.parseJsonBytes32(j, ".checkpoint"), vm.parseJsonBytes(j, ".bitmap"), vm.parseJsonBytes(j, ".signature")
);
require(signed == vm.parseJsonUint(j, ".signed_weight") && total == vm.parseJsonUint(j, ".total_weight"), "chain weights");
require(ok, "the chain's certificate does not verify");
}
/// One Devnet 3 account under a real Devnet 3 state root (test/vectors/dn3-account.json from a node's eth_getProof;
/// skipped when the file is absent). The root is the chain's own; the link from a certified checkpoint to that root
/// is the gap the doc names.
function test_devnet3_account_balance_proven() public {
string memory j;
try vm.readFile("test/vectors/dn3-account.json") returns (string memory s) {
j = s;
} catch {
return;
}
(bool exists, uint256 nonce, uint256 balance, bytes32 sroot, bytes32 chash) = v.verifyAccount(
vm.parseJsonBytes32(j, ".state_root"), vm.parseJsonAddress(j, ".account"), vm.parseJsonBytesArray(j, ".account_proof")
);
require(exists, "the Devnet 3 account is absent under its root");
require(nonce == vm.parseJsonUint(j, ".account_nonce") && balance == vm.parseJsonUint(j, ".account_balance"), "Devnet 3 account fields");
require(sroot == vm.parseJsonBytes32(j, ".account_storage_root") && chash == vm.parseJsonBytes32(j, ".account_code_hash"), "Devnet 3 account roots");
}
}

View file

@ -1,34 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// The Foundry cheatcodes this project uses, declared here so the tree needs no remote dependency.
interface Vm {
function startBroadcast(uint256 privateKey) external;
function stopBroadcast() external;
function envUint(string calldata name) external view returns (uint256);
function envAddress(string calldata name) external view returns (address);
function envOr(string calldata name, string calldata defaultValue) external view returns (string memory);
function toString(address value) external pure returns (string memory);
function toString(uint256 value) external pure returns (string memory);
function toString(bytes32 value) external pure returns (string memory);
function toString(bytes calldata value) external pure returns (string memory);
function readFile(string calldata path) external view returns (string memory);
function writeFile(string calldata path, string calldata data) external;
function parseJsonBytes(string calldata json, string calldata key) external pure returns (bytes memory);
function parseJsonBytes32(string calldata json, string calldata key) external pure returns (bytes32);
function parseJsonUint(string calldata json, string calldata key) external pure returns (uint256);
function parseJsonString(string calldata json, string calldata key) external pure returns (string memory);
function parseJsonBytesArray(string calldata json, string calldata key) external pure returns (bytes[] memory);
function parseJsonUintArray(string calldata json, string calldata key) external pure returns (uint256[] memory);
function parseJsonAddress(string calldata json, string calldata key) external pure returns (address);
function keyExistsJson(string calldata json, string calldata key) external view returns (bool);
function deal(address who, uint256 newBalance) external;
function prank(address msgSender) external;
function startPrank(address msgSender) external;
function stopPrank() external;
function warp(uint256 newTimestamp) external;
function expectRevert(bytes calldata revertData) external;
function addr(uint256 privateKey) external pure returns (address);
}
address constant VM_ADDRESS = address(uint160(uint256(keccak256("hevm cheat code"))));

View file

@ -1,8 +0,0 @@
node_modules
synthetic.json
chain.json
checkpoint-live.json
checkpoint-dn3.json
weights-dn3.json
checkpoints-dn3.json
dn3-table.json

View file

@ -1,15 +0,0 @@
{
"chain_id": "igneum-devnet-3",
"state_root": "0x1fd551393d84009c398a9b9747cd296d0fdf1e31f85fa21c10426bc219694561",
"block_number": 28462,
"account": "0x085a7ca7338efaf797f3300fa7c64afa9d2c539b",
"account_nonce": "0",
"account_balance": "547807747463600000000",
"account_storage_root": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421",
"account_code_hash": "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470",
"account_proof": [
"0xf90211a035a5663e80bde9c005c3f519613b5c3ff2add9bb9310908d4b6478ddfdd42b09a0628c3663581b1c12c36c5103080474c8f1503dc78e498e290e0ce309ee5dd580a048442259307de1a1ddc270914b19054e914a59f7ad1992c88fe9e5eeb91b91d3a0e72f8245f6bfe8fe9356e186352dc55a7f16e4eaf8bebfa8950625f0185ea699a0c8cf4b30c816ffbc8fc615de2e87ff7fe7ff384e22286b5cbb8aa29f126d2688a06819cfcddf29f3b1c72b4df680105c393217282d22439e204bfaf42eb5dfc3e0a0eafc7b7ab68e87b1574ce1c0a383b876ca03eb851e6d4074a6faa11583f8938fa0077c5575ade9c4657ef3094d44b493452279890fdd2028cbc46d7d7b260bfdf4a0f56e310ad31c53de3dd63db5823d99375f0ebed359d81efc13bc48e0fec734baa0a3a9b96516f395397fc8f86ada0e75c6b56fb56f26686b0de9b994828e7050a3a0f721a95427f32ec9105a8b6ae61ea87d68409f005eb4bb1bce08f8682de0f23aa08a09b8aba440e829c5b75680940ab07f7af7de4d5b784a7ebb4a9a55d5482686a072fed18995aede9d7cd457a87f58abb19b16d2794d7197e8808d049aa81fe4b1a058f1fadf00854cbe3d7b9fc0bdd2e38fe6c25bd944bc5b55f916c394282ba527a01e2b4f50116b9f85ef2b704b4e0858317961a715ff891e996eef10e4856c9d09a0588251a542d92fd21df94204ef0e7eb19224979273f6fa25a276ded6eca00b2080",
"0xf8b180808080a0420981c3d36f89ff245988aee9cf67b0852aa20aac3c0dd683b6441b8325ee6680a077d4821f911305b34d67a0f25372b5102b157ed8a3bcbcca278d663f41c2740ba0ca001aa786bbd28c50b735599b74b883185be98a9ea6118b519720fbed7720b9808080a04ea6a6815f895cbcf98aab72a2db87a197fd077953c650a9d74782dd1ca064df808080a025c2908da5906b49a3da3c07e84912f3428889a96f91da09057604c4bf189ab880",
"0xf872a0200f6ca86de6ec8bae815e435db88eb1e3ccc026af0d2049dc0299f37ac21622b84ff84d80891db25c13e886c5cc00a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"
]
}

View file

@ -1,116 +0,0 @@
// Test vectors for the Igneum certificate verifier. Two sources:
// node gen.mjs synthetic > synthetic.json five vote keys made here (noble BLS12-381), a certificate signed by four
// of them over the Devnet 3 vote message, a small account trie with proofs
// node gen.mjs table <weights.json> > dn3-table.json the voter table alone from a node's igneum_getFinalityWeights answer
// (voters in the canonical order: sorted by key hash; weight = blocks)
// node gen.mjs account <getProof.json> <stateRoot> <blockNumber> > dn3-account.json an eth_getProof answer from a Devnet 3
// node (the reference-apps lane's reader) as the suite's real-root account vector
// node gen.mjs chain <checkpoint.json> > dn3.json a real certificate as igneum.network/api/checkpoint?source=dn3 serves it:
// the voter table and the aggregate signature decompressed to the
// precompiles' encodings (the verifier checks the same bytes the node signed)
// Encodings: field element 64 bytes (16 zero bytes then 48), G1 128 bytes, G2 256 bytes (x.c0, x.c1, y.c0, y.c1).
// The DST and the vote message follow consensus/core/src/finality.rs and site/verify/core.js.
import { bls12_381 } from '@noble/curves/bls12-381';
import { expand_message_xmd } from '@noble/curves/abstract/hash-to-curve';
import { sha256 } from '@noble/hashes/sha256';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const DST = 'IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_';
const CHAIN_ID = 'igneum-devnet-3';
const te = new TextEncoder();
const hex = b => '0x' + Array.from(b, x => x.toString(16).padStart(2, '0')).join('');
const unhex = h => Uint8Array.from(Buffer.from(h.replace(/^0x/, ''), 'hex'));
const be = (n, len) => { const out = new Uint8Array(len); let v = BigInt(n); for (let i = len - 1; i >= 0; i--) { out[i] = Number(v & 0xffn); v >>= 8n; } return out; };
const fe = n => { const out = new Uint8Array(64); out.set(be(n, 48), 16); return out; };
const concat = parts => { const n = parts.reduce((a, p) => a + p.length, 0); const out = new Uint8Array(n); let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return out; };
const u64le = n => { const out = new Uint8Array(8); let v = BigInt(n); for (let i = 0; i < 8; i++) { out[i] = Number(v & 0xffn); v >>= 8n; } return out; };
const G1 = bls12_381.G1.ProjectivePoint, G2 = bls12_381.G2.ProjectivePoint;
function g1Enc(p) { const a = p.toAffine(); return concat([fe(a.x), fe(a.y)]); }
function g2Enc(p) { const a = p.toAffine(); return concat([fe(a.x.c0), fe(a.x.c1), fe(a.y.c0), fe(a.y.c1)]); }
function voteMessage(index, checkpointHex) { return concat([te.encode('igneum-vote-v1/' + CHAIN_ID), new Uint8Array([0]), u64le(index), unhex(checkpointHex)]); }
function bitmapOf(positions, n) { const bm = new Uint8Array(Math.ceil(n / 8)); for (const p of positions) bm[p >> 3] |= 1 << (p & 7); return bm; }
async function synthetic() {
const sks = [1, 2, 3, 4, 5].map(i => { const s = new Uint8Array(32); s[31] = i; s[0] = 0x11 * i; return bls12_381.utils.randomPrivateKey ? bls12_381.G1.normPrivateKeyToScalar(s) : s; });
const keys = sks.map(sk => G1.BASE.multiply(sk));
const weights = [100, 250, 400, 300, 150];
const index = 1234, checkpoint = '0x' + 'ab'.repeat(32);
const msg = voteMessage(index, checkpoint);
const hm = bls12_381.G2.hashToCurve(msg, { DST });
const signers = [0, 1, 2, 3]; // 1,050 of 1,200: above two thirds
const weakSigners = [0, 2, 4]; // 650 of 1,200: under two thirds
const sign = who => who.map(i => hm.multiply(sks[i])).reduce((a, b) => a.add(b));
const sig = sign(signers), weak = sign(weakSigners);
// the account trie: three accounts, proofs for one present and one absent
const { Trie } = require('@ethereumjs/trie');
const { RLP } = require('@ethereumjs/rlp');
const { keccak256 } = require('ethereum-cryptography/keccak');
const trie = new Trie({ useKeyHashing: true });
const accounts = [
{ address: '0x07dd4dbca5c1a66755af28bacca1d901a2d209aa', nonce: 7n, balance: 999174011168718479514n, storageRoot: '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421', codeHash: '0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470' },
{ address: '0x9a6fa842c4e58a87aef1f3ad15233d99283002b7', nonce: 1n, balance: 0n, storageRoot: '0x' + '11'.repeat(32), codeHash: '0x' + '22'.repeat(32) },
{ address: '0x53fe98022c2ac26d5d721457fb1c374b4d56144b', nonce: 3n, balance: 2580n * 10n ** 18n, storageRoot: '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421', codeHash: '0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470' },
];
for (const a of accounts) {
const v = RLP.encode([a.nonce === 0n ? new Uint8Array() : be(a.nonce, Math.ceil(a.nonce.toString(2).length / 8)), a.balance === 0n ? new Uint8Array() : be(a.balance, Math.ceil(a.balance.toString(2).length / 8)), unhex(a.storageRoot), unhex(a.codeHash)]);
await trie.put(unhex(a.address), v);
}
const root = hex(trie.root());
const proofFor = async addr => (await trie.createProof(unhex(addr))).map(hex);
const absent = '0x000000000000000000000000000000000000dead';
return {
chain_id: CHAIN_ID, dst: DST, index, checkpoint,
keys: keys.map(k => hex(g1Enc(k))), weights, total_weight: weights.reduce((a, b) => a + b, 0),
bitmap: hex(bitmapOf(signers, keys.length)), signature: hex(g2Enc(sig)), signed_weight: signers.reduce((a, i) => a + weights[i], 0),
weak_bitmap: hex(bitmapOf(weakSigners, keys.length)), weak_signature: hex(g2Enc(weak)),
vote_message: hex(msg),
// RFC 9380 expand_message_xmd(SHA-256) answers, computed by noble, for the Solidity port's own check
xmd_dst: 'QUUX-V01-CS02-with-expander-SHA256-128',
xmd_abc_128: hex(expand_message_xmd(te.encode('abc'), te.encode('QUUX-V01-CS02-with-expander-SHA256-128'), 128, sha256)),
xmd_empty_32: hex(expand_message_xmd(new Uint8Array(), te.encode('QUUX-V01-CS02-with-expander-SHA256-128'), 32, sha256)),
state_root: root,
account: accounts[0].address, account_nonce: accounts[0].nonce.toString(), account_balance: accounts[0].balance.toString(),
account_storage_root: accounts[0].storageRoot, account_code_hash: accounts[0].codeHash,
account_proof: await proofFor(accounts[0].address),
absent_account: absent, absent_proof: await proofFor(absent),
};
}
function chain(file) {
const d = JSON.parse(readFileSync(file, 'utf8'));
const voters = d.voters.map(v => ({ key: hex(g1Enc(G1.fromHex(v.pubkey_hex.replace(/^0x/, '')))), weight: Math.round(Number(v.weight)) }));
const sig = G2.fromHex(d.certificate.aggregate_signature_hex.replace(/^0x/, ''));
const positions = []; const bm = unhex(d.certificate.bitmap_hex);
for (let p = 0; p < voters.length; p++) if (bm[p >> 3] & (1 << (p & 7))) positions.push(p);
return {
source: d.source, chain_id: d.chain_id, dst: DST, index: d.index, checkpoint: '0x' + d.hash,
keys: voters.map(v => v.key), weights: voters.map(v => v.weight), total_weight: voters.reduce((a, v) => a + v.weight, 0),
bitmap: '0x' + d.certificate.bitmap_hex, signature: hex(g2Enc(sig)), signed_weight: positions.reduce((a, p) => a + voters[p].weight, 0),
signers: positions.length, voters_at_index: d.voters_at_index, stored_at: d.stored_at,
};
}
function table(file) {
const d = JSON.parse(readFileSync(file, 'utf8'));
const r = d.result || d;
const voters = r.keys.filter(k => k.voter === true || k.voter === 'True').map(k => ({ keyHash: String(k.keyHash).replace(/^0x/, ''), key: hex(g1Enc(G1.fromHex(String(k.pubkey).replace(/^0x/, '')))), weight: Number(BigInt(k.blocks)) }));
voters.sort((a, b) => (a.keyHash < b.keyHash ? -1 : a.keyHash > b.keyHash ? 1 : 0));
const total = voters.reduce((a, v) => a + v.weight, 0);
return { chain_id: process.env.IGNEUM_CHAIN_ID || CHAIN_ID, dst: DST, index: Number(BigInt(r.checkpointIndex)), checkpoint_at_index: '0x' + String(r.checkpointHash).replace(/^0x/, ''), keys: voters.map(v => v.key), weights: voters.map(v => v.weight), total_weight: total, total_weight_node: Number(BigInt(r.totalWeight)), voters: voters.length };
}
function account(file, stateRoot, blockNumber) {
const d = JSON.parse(readFileSync(file, 'utf8'));
const r = d.result || d;
return { chain_id: CHAIN_ID, state_root: stateRoot, block_number: Number(blockNumber), account: r.address, account_nonce: BigInt(r.nonce).toString(), account_balance: BigInt(r.balance).toString(), account_storage_root: r.storageHash, account_code_hash: r.codeHash, account_proof: r.accountProof };
}
const mode = process.argv[2];
if (mode === 'synthetic') synthetic().then(v => console.log(JSON.stringify(v, null, 1)));
else if (mode === 'chain') console.log(JSON.stringify(chain(process.argv[3]), null, 1));
else if (mode === 'table') console.log(JSON.stringify(table(process.argv[3]), null, 1));
else if (mode === 'account') console.log(JSON.stringify(account(process.argv[3], process.argv[4], process.argv[5]), null, 1));
else { console.error('usage: gen.mjs synthetic | table <weights.json> | account <getProof.json> <stateRoot> <blockNumber> | chain <checkpoint.json>'); process.exit(2); }

View file

@ -1,19 +0,0 @@
[profile.default]
src = "src"
test = "test"
script = "script"
out = "out"
libs = []
solc_version = "0.8.28"
# Devnet 3's executor runs the EVM through revm; Paris keeps the bytecode off PUSH0 and transient storage so it runs
# on any post-Merge configuration of it (8 October 2026).
evm_version = "paris"
optimizer = true
optimizer_runs = 200
fs_permissions = [{ access = "read-write", path = "./deploy-out.json" }]
# no remote dependencies: the tests and the script declare the cheatcode interface they use (test/Vm.sol); solc 0.8.28 is fetched once by forge
auto_detect_remappings = false
[rpc_endpoints]
# the Devnet 3 EVM reaches the build box through a tunnel on this port (never the Mac); see docs/contracts/devnet-3.json
devnet3 = "http://127.0.0.1:36790"

View file

@ -1,58 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Vm, VM_ADDRESS} from "../test/Vm.sol";
import {WIGN} from "../src/WIGN.sol";
import {TestToken} from "../src/TestToken.sol";
import {IgneumFactory} from "../src/IgneumPair.sol";
import {IgneumRouter} from "../src/IgneumRouter.sol";
/// Deploys the AMM on Devnet 3 from the key in DEX_DEPLOYER_KEY (read from the environment, never printed), seeds
/// three pools from the faucet and 200 IGN, and writes the addresses to deploy-out.json for docs/contracts.
///
/// forge script script/Deploy.s.sol:Deploy --rpc-url devnet3 --broadcast --sig "run()"
///
/// Devnet 3, test tokens, no value.
contract Deploy {
Vm constant vm = Vm(VM_ADDRESS);
function run() external {
uint256 key = vm.envUint("DEX_DEPLOYER_KEY");
address deployer = vm.addr(key);
vm.startBroadcast(key);
WIGN wign = new WIGN();
TestToken tta = new TestToken("Test Token A", "TTA");
TestToken ttb = new TestToken("Test Token B", "TTB");
IgneumFactory factory = new IgneumFactory();
IgneumRouter router = new IgneumRouter(address(factory), address(wign));
tta.drip();
ttb.drip();
tta.approve(address(router), type(uint256).max);
ttb.approve(address(router), type(uint256).max);
uint256 deadline = block.timestamp + 1 hours;
router.addLiquidityIGN{value: 100 ether}(address(tta), 500 ether, 0, 0, deployer, deadline);
router.addLiquidityIGN{value: 100 ether}(address(ttb), 500 ether, 0, 0, deployer, deadline);
router.addLiquidity(address(tta), address(ttb), 500 ether, 500 ether, 0, 0, deployer, deadline);
vm.stopBroadcast();
string memory json = "{\n";
json = _line(json, "WIGN", address(wign), ",");
json = _line(json, "TTA", address(tta), ",");
json = _line(json, "TTB", address(ttb), ",");
json = _line(json, "IgneumFactory", address(factory), ",");
json = _line(json, "IgneumRouter", address(router), ",");
json = _line(json, "pair_TTA_WIGN", factory.getPair(address(tta), address(wign)), ",");
json = _line(json, "pair_TTB_WIGN", factory.getPair(address(ttb), address(wign)), ",");
json = _line(json, "pair_TTA_TTB", factory.getPair(address(tta), address(ttb)), ",");
json = _line(json, "deployer", deployer, "");
json = string.concat(json, "}\n");
vm.writeFile("deploy-out.json", json);
}
function _line(string memory json, string memory key, address value, string memory comma) private pure returns (string memory) {
return string.concat(json, " \"", key, "\": \"", vm.toString(value), "\"", comma, "\n");
}
}

View file

@ -1,37 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Vm, VM_ADDRESS} from "../test/Vm.sol";
import {TestToken} from "../src/TestToken.sol";
import {IgneumRouter} from "../src/IgneumRouter.sol";
/// Seeds the three pools of an already deployed AMM (the addresses from the environment): a drip of each test
/// token, approvals, 100 IGN beside 500 TTA, 100 IGN beside 500 TTB, 500 TTA beside 500 TTB. Run with
/// --skip-simulation so every gas limit comes from the node's own eth_estimateGas: Devnet 3 charges the proving
/// dimension inside the execution gas, so Foundry's local estimate runs out (8 October 2026, drip() at 133,603 gas:
/// out of gas; the node's estimate 685,513).
///
/// DEX_TTA=0x.. DEX_TTB=0x.. DEX_ROUTER=0x.. forge script script/Seed.s.sol:Seed --rpc-url devnet3 --broadcast --slow --skip-simulation --sig "run()"
///
/// Devnet 3, test tokens, no value.
contract Seed {
Vm constant vm = Vm(VM_ADDRESS);
function run() external {
uint256 key = vm.envUint("DEX_DEPLOYER_KEY");
address deployer = vm.addr(key);
TestToken tta = TestToken(vm.envAddress("DEX_TTA"));
TestToken ttb = TestToken(vm.envAddress("DEX_TTB"));
IgneumRouter router = IgneumRouter(payable(vm.envAddress("DEX_ROUTER")));
vm.startBroadcast(key);
if (tta.dripWait(deployer) == 0 && tta.balanceOf(deployer) < 1_000 ether) tta.drip();
if (ttb.dripWait(deployer) == 0 && ttb.balanceOf(deployer) < 1_000 ether) ttb.drip();
tta.approve(address(router), type(uint256).max);
ttb.approve(address(router), type(uint256).max);
uint256 deadline = block.timestamp + 1 hours;
router.addLiquidityIGN{value: 100 ether}(address(tta), 500 ether, 0, 0, deployer, deadline);
router.addLiquidityIGN{value: 100 ether}(address(ttb), 500 ether, 0, 0, deployer, deadline);
router.addLiquidity(address(tta), address(ttb), 500 ether, 500 ether, 0, 0, deployer, deadline);
vm.stopBroadcast();
}
}

View file

@ -1,59 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// A plain ERC-20 with EIP-2612-free approvals, shared by the pair's liquidity token, the wrapped coin and the test
/// tokens. Devnet 3, test tokens, no value.
contract ERC20 {
string public name;
string public symbol;
uint8 public constant decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
constructor(string memory name_, string memory symbol_) {
name = name_;
symbol = symbol_;
}
function _mint(address to, uint256 value) internal {
totalSupply += value;
balanceOf[to] += value;
emit Transfer(address(0), to, value);
}
function _burn(address from, uint256 value) internal {
balanceOf[from] -= value;
totalSupply -= value;
emit Transfer(from, address(0), value);
}
function _transfer(address from, address to, uint256 value) internal {
balanceOf[from] -= value;
balanceOf[to] += value;
emit Transfer(from, to, value);
}
function approve(address spender, uint256 value) external returns (bool) {
allowance[msg.sender][spender] = value;
emit Approval(msg.sender, spender, value);
return true;
}
function transfer(address to, uint256 value) external returns (bool) {
_transfer(msg.sender, to, value);
return true;
}
function transferFrom(address from, address to, uint256 value) external returns (bool) {
uint256 allowed = allowance[from][msg.sender];
if (allowed != type(uint256).max) {
allowance[from][msg.sender] = allowed - value;
}
_transfer(from, to, value);
return true;
}
}

View file

@ -1,174 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC20} from "./ERC20.sol";
interface IERC20Minimal {
function balanceOf(address) external view returns (uint256);
function transfer(address, uint256) external returns (bool);
}
/// A constant-product pool in the Uniswap v2 shape: reserves of two tokens, a 0.3 percent fee kept in the pool,
/// liquidity tokens for the depositors, MINIMUM_LIQUIDITY locked at the first mint. No protocol fee, no price
/// accumulators, no flash callback. Devnet 3, test tokens, no value.
contract IgneumPair is ERC20("Igneum LP", "IGN-LP") {
uint256 public constant MINIMUM_LIQUIDITY = 10 ** 3;
address public immutable factory;
address public token0;
address public token1;
uint112 private reserve0;
uint112 private reserve1;
uint32 private blockTimestampLast;
uint256 private unlocked = 1;
event Mint(address indexed sender, uint256 amount0, uint256 amount1);
event Burn(address indexed sender, uint256 amount0, uint256 amount1, address indexed to);
event Swap(address indexed sender, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out, address indexed to);
event Sync(uint112 reserve0, uint112 reserve1);
modifier lock() {
require(unlocked == 1, "Pair: locked");
unlocked = 0;
_;
unlocked = 1;
}
constructor() {
factory = msg.sender;
}
function initialize(address token0_, address token1_) external {
require(msg.sender == factory, "Pair: forbidden");
token0 = token0_;
token1 = token1_;
}
function getReserves() public view returns (uint112, uint112, uint32) {
return (reserve0, reserve1, blockTimestampLast);
}
function _safeTransfer(address token, address to, uint256 value) private {
(bool ok, bytes memory data) = token.call(abi.encodeWithSelector(IERC20Minimal.transfer.selector, to, value));
require(ok && (data.length == 0 || abi.decode(data, (bool))), "Pair: transfer failed");
}
function _update(uint256 balance0, uint256 balance1) private {
require(balance0 <= type(uint112).max && balance1 <= type(uint112).max, "Pair: overflow");
reserve0 = uint112(balance0);
reserve1 = uint112(balance1);
blockTimestampLast = uint32(block.timestamp);
emit Sync(reserve0, reserve1);
}
function _sqrt(uint256 y) private pure returns (uint256 z) {
if (y > 3) {
z = y;
uint256 x = y / 2 + 1;
while (x < z) {
z = x;
x = (y / x + x) / 2;
}
} else if (y != 0) {
z = 1;
}
}
function _min(uint256 x, uint256 y) private pure returns (uint256) {
return x < y ? x : y;
}
/// Mints liquidity for the tokens sent to the pair since the last sync. Called by the router.
function mint(address to) external lock returns (uint256 liquidity) {
(uint112 r0, uint112 r1,) = getReserves();
uint256 balance0 = IERC20Minimal(token0).balanceOf(address(this));
uint256 balance1 = IERC20Minimal(token1).balanceOf(address(this));
uint256 amount0 = balance0 - r0;
uint256 amount1 = balance1 - r1;
if (totalSupply == 0) {
liquidity = _sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
_mint(address(0xdead), MINIMUM_LIQUIDITY);
} else {
liquidity = _min(amount0 * totalSupply / r0, amount1 * totalSupply / r1);
}
require(liquidity > 0, "Pair: insufficient liquidity minted");
_mint(to, liquidity);
_update(balance0, balance1);
emit Mint(msg.sender, amount0, amount1);
}
/// Burns the liquidity tokens sent to the pair and pays both tokens out pro rata. Called by the router.
function burn(address to) external lock returns (uint256 amount0, uint256 amount1) {
uint256 balance0 = IERC20Minimal(token0).balanceOf(address(this));
uint256 balance1 = IERC20Minimal(token1).balanceOf(address(this));
uint256 liquidity = balanceOf[address(this)];
amount0 = liquidity * balance0 / totalSupply;
amount1 = liquidity * balance1 / totalSupply;
require(amount0 > 0 && amount1 > 0, "Pair: insufficient liquidity burned");
_burn(address(this), liquidity);
_safeTransfer(token0, to, amount0);
_safeTransfer(token1, to, amount1);
_update(IERC20Minimal(token0).balanceOf(address(this)), IERC20Minimal(token1).balanceOf(address(this)));
emit Burn(msg.sender, amount0, amount1, to);
}
/// Pays out up to the amounts asked and checks the fee-adjusted product did not fall. The input must already
/// sit in the pair (the router sends it first).
function swap(uint256 amount0Out, uint256 amount1Out, address to) external lock {
require(amount0Out > 0 || amount1Out > 0, "Pair: insufficient output amount");
(uint112 r0, uint112 r1,) = getReserves();
require(amount0Out < r0 && amount1Out < r1, "Pair: insufficient liquidity");
require(to != token0 && to != token1, "Pair: invalid to");
if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);
uint256 balance0 = IERC20Minimal(token0).balanceOf(address(this));
uint256 balance1 = IERC20Minimal(token1).balanceOf(address(this));
uint256 amount0In = balance0 > r0 - amount0Out ? balance0 - (r0 - amount0Out) : 0;
uint256 amount1In = balance1 > r1 - amount1Out ? balance1 - (r1 - amount1Out) : 0;
require(amount0In > 0 || amount1In > 0, "Pair: insufficient input amount");
uint256 adjusted0 = balance0 * 1000 - amount0In * 3;
uint256 adjusted1 = balance1 * 1000 - amount1In * 3;
require(adjusted0 * adjusted1 >= uint256(r0) * uint256(r1) * 1000 ** 2, "Pair: K");
_update(balance0, balance1);
emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
}
/// Sends any balance above the reserves to `to`.
function skim(address to) external lock {
_safeTransfer(token0, to, IERC20Minimal(token0).balanceOf(address(this)) - reserve0);
_safeTransfer(token1, to, IERC20Minimal(token1).balanceOf(address(this)) - reserve1);
}
/// Sets the reserves to the balances.
function sync() external lock {
_update(IERC20Minimal(token0).balanceOf(address(this)), IERC20Minimal(token1).balanceOf(address(this)));
}
}
/// Creates one pair per unordered token pair and remembers it.
contract IgneumFactory {
mapping(address => mapping(address => address)) public getPair;
address[] public allPairs;
event PairCreated(address indexed token0, address indexed token1, address pair, uint256 count);
function allPairsLength() external view returns (uint256) {
return allPairs.length;
}
function createPair(address tokenA, address tokenB) external returns (address pair) {
require(tokenA != tokenB, "Factory: identical addresses");
(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
require(token0 != address(0), "Factory: zero address");
require(getPair[token0][token1] == address(0), "Factory: pair exists");
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
pair = address(new IgneumPair{salt: salt}());
IgneumPair(pair).initialize(token0, token1);
getPair[token0][token1] = pair;
getPair[token1][token0] = pair;
allPairs.push(pair);
emit PairCreated(token0, token1, pair, allPairs.length);
}
}

View file

@ -1,253 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {IgneumFactory, IgneumPair} from "./IgneumPair.sol";
interface IERC20Router {
function balanceOf(address) external view returns (uint256);
function transfer(address, uint256) external returns (bool);
function transferFrom(address, address, uint256) external returns (bool);
}
interface IWIGN is IERC20Router {
function deposit() external payable;
function withdraw(uint256) external;
}
/// The router in the Uniswap v2 shape: adds and removes liquidity, swaps along a path of pairs, quotes. Pairs are
/// looked up on the factory, never derived from an init-code hash. Devnet 3, test tokens, no value.
contract IgneumRouter {
IgneumFactory public immutable factory;
address public immutable WIGN;
modifier ensure(uint256 deadline) {
require(deadline >= block.timestamp, "Router: expired");
_;
}
constructor(address factory_, address wign_) {
factory = IgneumFactory(factory_);
WIGN = wign_;
}
receive() external payable {
require(msg.sender == WIGN, "Router: only WIGN");
}
// ---- pure maths ----
function sortTokens(address tokenA, address tokenB) public pure returns (address token0, address token1) {
require(tokenA != tokenB, "Router: identical addresses");
(token0, token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
require(token0 != address(0), "Router: zero address");
}
function quote(uint256 amountA, uint256 reserveA, uint256 reserveB) public pure returns (uint256 amountB) {
require(amountA > 0, "Router: insufficient amount");
require(reserveA > 0 && reserveB > 0, "Router: insufficient liquidity");
amountB = amountA * reserveB / reserveA;
}
function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut) public pure returns (uint256 amountOut) {
require(amountIn > 0, "Router: insufficient input amount");
require(reserveIn > 0 && reserveOut > 0, "Router: insufficient liquidity");
uint256 amountInWithFee = amountIn * 997;
amountOut = amountInWithFee * reserveOut / (reserveIn * 1000 + amountInWithFee);
}
function getAmountIn(uint256 amountOut, uint256 reserveIn, uint256 reserveOut) public pure returns (uint256 amountIn) {
require(amountOut > 0, "Router: insufficient output amount");
require(reserveIn > 0 && reserveOut > 0, "Router: insufficient liquidity");
amountIn = (reserveIn * amountOut * 1000) / ((reserveOut - amountOut) * 997) + 1;
}
// ---- views ----
function pairFor(address tokenA, address tokenB) public view returns (address pair) {
pair = factory.getPair(tokenA, tokenB);
require(pair != address(0), "Router: no pair");
}
function getReserves(address tokenA, address tokenB) public view returns (uint256 reserveA, uint256 reserveB) {
(address token0,) = sortTokens(tokenA, tokenB);
(uint112 r0, uint112 r1,) = IgneumPair(pairFor(tokenA, tokenB)).getReserves();
(reserveA, reserveB) = tokenA == token0 ? (r0, r1) : (r1, r0);
}
function getAmountsOut(uint256 amountIn, address[] memory path) public view returns (uint256[] memory amounts) {
require(path.length >= 2, "Router: invalid path");
amounts = new uint256[](path.length);
amounts[0] = amountIn;
for (uint256 i; i < path.length - 1; i++) {
(uint256 reserveIn, uint256 reserveOut) = getReserves(path[i], path[i + 1]);
amounts[i + 1] = getAmountOut(amounts[i], reserveIn, reserveOut);
}
}
function getAmountsIn(uint256 amountOut, address[] memory path) public view returns (uint256[] memory amounts) {
require(path.length >= 2, "Router: invalid path");
amounts = new uint256[](path.length);
amounts[amounts.length - 1] = amountOut;
for (uint256 i = path.length - 1; i > 0; i--) {
(uint256 reserveIn, uint256 reserveOut) = getReserves(path[i - 1], path[i]);
amounts[i - 1] = getAmountIn(amounts[i], reserveIn, reserveOut);
}
}
// ---- liquidity ----
function _addLiquidity(address tokenA, address tokenB, uint256 amountADesired, uint256 amountBDesired, uint256 amountAMin, uint256 amountBMin)
private
returns (uint256 amountA, uint256 amountB)
{
if (factory.getPair(tokenA, tokenB) == address(0)) {
factory.createPair(tokenA, tokenB);
}
(uint256 reserveA, uint256 reserveB) = getReserves(tokenA, tokenB);
if (reserveA == 0 && reserveB == 0) {
(amountA, amountB) = (amountADesired, amountBDesired);
} else {
uint256 amountBOptimal = quote(amountADesired, reserveA, reserveB);
if (amountBOptimal <= amountBDesired) {
require(amountBOptimal >= amountBMin, "Router: insufficient B amount");
(amountA, amountB) = (amountADesired, amountBOptimal);
} else {
uint256 amountAOptimal = quote(amountBDesired, reserveB, reserveA);
require(amountAOptimal <= amountADesired && amountAOptimal >= amountAMin, "Router: insufficient A amount");
(amountA, amountB) = (amountAOptimal, amountBDesired);
}
}
}
function addLiquidity(
address tokenA,
address tokenB,
uint256 amountADesired,
uint256 amountBDesired,
uint256 amountAMin,
uint256 amountBMin,
address to,
uint256 deadline
) external ensure(deadline) returns (uint256 amountA, uint256 amountB, uint256 liquidity) {
(amountA, amountB) = _addLiquidity(tokenA, tokenB, amountADesired, amountBDesired, amountAMin, amountBMin);
address pair = pairFor(tokenA, tokenB);
_pull(tokenA, msg.sender, pair, amountA);
_pull(tokenB, msg.sender, pair, amountB);
liquidity = IgneumPair(pair).mint(to);
}
function addLiquidityIGN(address token, uint256 amountTokenDesired, uint256 amountTokenMin, uint256 amountIGNMin, address to, uint256 deadline)
external
payable
ensure(deadline)
returns (uint256 amountToken, uint256 amountIGN, uint256 liquidity)
{
(amountToken, amountIGN) = _addLiquidity(token, WIGN, amountTokenDesired, msg.value, amountTokenMin, amountIGNMin);
address pair = pairFor(token, WIGN);
_pull(token, msg.sender, pair, amountToken);
IWIGN(WIGN).deposit{value: amountIGN}();
require(IWIGN(WIGN).transfer(pair, amountIGN), "Router: WIGN transfer failed");
liquidity = IgneumPair(pair).mint(to);
if (msg.value > amountIGN) _sendIGN(msg.sender, msg.value - amountIGN);
}
function removeLiquidity(address tokenA, address tokenB, uint256 liquidity, uint256 amountAMin, uint256 amountBMin, address to, uint256 deadline)
public
ensure(deadline)
returns (uint256 amountA, uint256 amountB)
{
address pair = pairFor(tokenA, tokenB);
_pull(pair, msg.sender, pair, liquidity);
(uint256 amount0, uint256 amount1) = IgneumPair(pair).burn(to);
(address token0,) = sortTokens(tokenA, tokenB);
(amountA, amountB) = tokenA == token0 ? (amount0, amount1) : (amount1, amount0);
require(amountA >= amountAMin, "Router: insufficient A amount");
require(amountB >= amountBMin, "Router: insufficient B amount");
}
function removeLiquidityIGN(address token, uint256 liquidity, uint256 amountTokenMin, uint256 amountIGNMin, address to, uint256 deadline)
external
ensure(deadline)
returns (uint256 amountToken, uint256 amountIGN)
{
(amountToken, amountIGN) = removeLiquidity(token, WIGN, liquidity, amountTokenMin, amountIGNMin, address(this), deadline);
require(IERC20Router(token).transfer(to, amountToken), "Router: token transfer failed");
IWIGN(WIGN).withdraw(amountIGN);
_sendIGN(to, amountIGN);
}
// ---- swaps ----
function _swap(uint256[] memory amounts, address[] memory path, address to_) private {
for (uint256 i; i < path.length - 1; i++) {
(address input, address output) = (path[i], path[i + 1]);
(address token0,) = sortTokens(input, output);
uint256 amountOut = amounts[i + 1];
(uint256 amount0Out, uint256 amount1Out) = input == token0 ? (uint256(0), amountOut) : (amountOut, uint256(0));
address to = i < path.length - 2 ? pairFor(output, path[i + 2]) : to_;
IgneumPair(pairFor(input, output)).swap(amount0Out, amount1Out, to);
}
}
function swapExactTokensForTokens(uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline)
external
ensure(deadline)
returns (uint256[] memory amounts)
{
amounts = getAmountsOut(amountIn, path);
require(amounts[amounts.length - 1] >= amountOutMin, "Router: insufficient output amount");
_pull(path[0], msg.sender, pairFor(path[0], path[1]), amounts[0]);
_swap(amounts, path, to);
}
function swapTokensForExactTokens(uint256 amountOut, uint256 amountInMax, address[] calldata path, address to, uint256 deadline)
external
ensure(deadline)
returns (uint256[] memory amounts)
{
amounts = getAmountsIn(amountOut, path);
require(amounts[0] <= amountInMax, "Router: excessive input amount");
_pull(path[0], msg.sender, pairFor(path[0], path[1]), amounts[0]);
_swap(amounts, path, to);
}
function swapExactIGNForTokens(uint256 amountOutMin, address[] calldata path, address to, uint256 deadline)
external
payable
ensure(deadline)
returns (uint256[] memory amounts)
{
require(path[0] == WIGN, "Router: invalid path");
amounts = getAmountsOut(msg.value, path);
require(amounts[amounts.length - 1] >= amountOutMin, "Router: insufficient output amount");
IWIGN(WIGN).deposit{value: amounts[0]}();
require(IWIGN(WIGN).transfer(pairFor(path[0], path[1]), amounts[0]), "Router: WIGN transfer failed");
_swap(amounts, path, to);
}
function swapExactTokensForIGN(uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline)
external
ensure(deadline)
returns (uint256[] memory amounts)
{
require(path[path.length - 1] == WIGN, "Router: invalid path");
amounts = getAmountsOut(amountIn, path);
require(amounts[amounts.length - 1] >= amountOutMin, "Router: insufficient output amount");
_pull(path[0], msg.sender, pairFor(path[0], path[1]), amounts[0]);
_swap(amounts, path, address(this));
IWIGN(WIGN).withdraw(amounts[amounts.length - 1]);
_sendIGN(to, amounts[amounts.length - 1]);
}
// ---- transfers ----
function _pull(address token, address from, address to, uint256 value) private {
(bool ok, bytes memory data) = token.call(abi.encodeWithSelector(IERC20Router.transferFrom.selector, from, to, value));
require(ok && (data.length == 0 || abi.decode(data, (bool))), "Router: transferFrom failed");
}
function _sendIGN(address to, uint256 value) private {
(bool ok,) = to.call{value: value}("");
require(ok, "Router: IGN send failed");
}
}

View file

@ -1,32 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC20} from "./ERC20.sol";
/// A test token with its own faucet: anyone takes DRIP tokens once an hour. Devnet 3, test tokens, no value.
contract TestToken is ERC20 {
uint256 public constant DRIP = 1_000 ether;
uint256 public constant DRIP_INTERVAL = 1 hours;
mapping(address => uint256) public lastDrip;
event Drip(address indexed to, uint256 value);
constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {}
/// Mints DRIP tokens to the caller; refused inside DRIP_INTERVAL of the caller's last drip.
function drip() external {
uint256 last = lastDrip[msg.sender];
require(last == 0 || block.timestamp >= last + DRIP_INTERVAL, "TestToken: one drip an hour");
lastDrip[msg.sender] = block.timestamp;
_mint(msg.sender, DRIP);
emit Drip(msg.sender, DRIP);
}
/// Seconds until the caller may drip again (0 when it may).
function dripWait(address who) external view returns (uint256) {
uint256 last = lastDrip[who];
if (last == 0) return 0;
uint256 next = last + DRIP_INTERVAL;
return block.timestamp >= next ? 0 : next - block.timestamp;
}
}

View file

@ -1,27 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC20} from "./ERC20.sol";
/// Wrapped IGN, the WETH9 shape: one WIGN per IGN held by this contract, minted on deposit and burned on withdraw.
/// Devnet 3, test tokens, no value.
contract WIGN is ERC20("Wrapped IGN", "WIGN") {
event Deposit(address indexed to, uint256 value);
event Withdrawal(address indexed from, uint256 value);
receive() external payable {
deposit();
}
function deposit() public payable {
_mint(msg.sender, msg.value);
emit Deposit(msg.sender, msg.value);
}
function withdraw(uint256 value) external {
_burn(msg.sender, value);
emit Withdrawal(msg.sender, value);
(bool ok,) = msg.sender.call{value: value}("");
require(ok, "WIGN: send failed");
}
}

View file

@ -1,137 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Vm, VM_ADDRESS} from "./Vm.sol";
import {WIGN} from "../src/WIGN.sol";
import {TestToken} from "../src/TestToken.sol";
import {IgneumFactory, IgneumPair} from "../src/IgneumPair.sol";
import {IgneumRouter} from "../src/IgneumRouter.sol";
/// The AMM end to end on Foundry's EVM: pair creation, liquidity in and out, token and IGN swaps, the faucet's
/// hour, the k check. Every number is a test number: Devnet 3, test tokens, no value.
contract DexTest {
Vm constant vm = Vm(VM_ADDRESS);
WIGN wign;
TestToken tta;
TestToken ttb;
IgneumFactory factory;
IgneumRouter router;
address alice = address(0xA11CE);
address bob = address(0xB0B);
receive() external payable {}
function setUp() public {
wign = new WIGN();
tta = new TestToken("Test Token A", "TTA");
ttb = new TestToken("Test Token B", "TTB");
factory = new IgneumFactory();
router = new IgneumRouter(address(factory), address(wign));
vm.deal(alice, 1_000 ether);
vm.deal(bob, 1_000 ether);
}
function _drip(address who) internal {
vm.startPrank(who);
tta.drip();
ttb.drip();
tta.approve(address(router), type(uint256).max);
ttb.approve(address(router), type(uint256).max);
vm.stopPrank();
}
function _seed() internal returns (address pairAB, address pairAW) {
_drip(alice);
vm.startPrank(alice);
router.addLiquidity(address(tta), address(ttb), 500 ether, 500 ether, 0, 0, alice, block.timestamp + 60);
router.addLiquidityIGN{value: 100 ether}(address(tta), 500 ether, 0, 0, alice, block.timestamp + 60);
vm.stopPrank();
pairAB = factory.getPair(address(tta), address(ttb));
pairAW = factory.getPair(address(tta), address(wign));
}
function test_faucet_drips_once_an_hour() public {
vm.prank(bob);
tta.drip();
require(tta.balanceOf(bob) == 1_000 ether, "drip amount");
vm.prank(bob);
vm.expectRevert(bytes("TestToken: one drip an hour"));
tta.drip();
vm.warp(block.timestamp + 1 hours);
vm.prank(bob);
tta.drip();
require(tta.balanceOf(bob) == 2_000 ether, "second drip");
}
function test_add_liquidity_creates_pairs_and_mints() public {
(address pairAB, address pairAW) = _seed();
require(pairAB != address(0) && pairAW != address(0) && pairAB != pairAW, "pairs");
require(factory.allPairsLength() == 2, "two pairs");
(uint256 rA, uint256 rB) = router.getReserves(address(tta), address(ttb));
require(rA == 500 ether && rB == 500 ether, "AB reserves");
(uint256 rT, uint256 rW) = router.getReserves(address(tta), address(wign));
require(rT == 500 ether && rW == 100 ether, "AW reserves");
require(IgneumPair(pairAB).balanceOf(alice) == 500 ether - 1000, "LP minus the locked minimum");
require(IgneumPair(pairAB).balanceOf(address(0xdead)) == 1000, "locked minimum");
require(wign.balanceOf(pairAW) == 100 ether, "WIGN held by the pair");
}
function test_swap_exact_tokens_for_tokens_keeps_k() public {
(address pairAB,) = _seed();
_drip(bob);
address[] memory path = new address[](2);
path[0] = address(tta);
path[1] = address(ttb);
uint256[] memory quoted = router.getAmountsOut(10 ether, path);
// 10 in at 0.3 percent on 500/500: 10*997*500 / (500*1000 + 10*997) tokens
require(quoted[1] == 9775084808910328058, "quote");
(uint112 r0b, uint112 r1b,) = IgneumPair(pairAB).getReserves();
vm.prank(bob);
uint256[] memory amounts = router.swapExactTokensForTokens(10 ether, quoted[1], path, bob, block.timestamp + 60);
require(amounts[1] == quoted[1], "swap matches the quote");
require(ttb.balanceOf(bob) == 1_000 ether + quoted[1], "bob received");
(uint112 r0a, uint112 r1a,) = IgneumPair(pairAB).getReserves();
require(uint256(r0a) * uint256(r1a) >= uint256(r0b) * uint256(r1b), "k did not fall");
}
function test_swap_ign_both_ways() public {
_seed();
address[] memory path = new address[](2);
path[0] = address(wign);
path[1] = address(tta);
uint256 before = tta.balanceOf(bob);
vm.prank(bob);
uint256[] memory amounts = router.swapExactIGNForTokens{value: 1 ether}(0, path, bob, block.timestamp + 60);
require(tta.balanceOf(bob) == before + amounts[1] && amounts[1] > 4.9 ether && amounts[1] < 5 ether, "IGN to TTA");
path[0] = address(tta);
path[1] = address(wign);
uint256 ignBefore = bob.balance;
vm.startPrank(bob);
tta.approve(address(router), type(uint256).max);
uint256[] memory back = router.swapExactTokensForIGN(amounts[1], 0, path, bob, block.timestamp + 60);
vm.stopPrank();
require(bob.balance == ignBefore + back[1] && back[1] < 1 ether && back[1] > 0.99 ether, "TTA to IGN");
}
function test_remove_liquidity_returns_both_tokens() public {
(address pairAB,) = _seed();
uint256 lp = IgneumPair(pairAB).balanceOf(alice);
vm.startPrank(alice);
IgneumPair(pairAB).approve(address(router), lp);
(uint256 a, uint256 b) = router.removeLiquidity(address(tta), address(ttb), lp, 0, 0, alice, block.timestamp + 60);
vm.stopPrank();
require(a == 500 ether - 1000 && b == 500 ether - 1000, "pro rata minus the locked share");
require(IgneumPair(pairAB).balanceOf(alice) == 0, "LP burned");
}
function test_expired_deadline_is_refused() public {
_seed();
address[] memory path = new address[](2);
path[0] = address(tta);
path[1] = address(ttb);
vm.prank(alice);
vm.expectRevert(bytes("Router: expired"));
router.swapExactTokensForTokens(1 ether, 0, path, alice, block.timestamp - 1);
}
}

View file

@ -1,23 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// The Foundry cheatcodes this project uses, declared here so the tree needs no remote dependency.
interface Vm {
function startBroadcast(uint256 privateKey) external;
function stopBroadcast() external;
function envUint(string calldata name) external view returns (uint256);
function envAddress(string calldata name) external view returns (address);
function envOr(string calldata name, string calldata defaultValue) external view returns (string memory);
function toString(address value) external pure returns (string memory);
function toString(uint256 value) external pure returns (string memory);
function writeFile(string calldata path, string calldata data) external;
function deal(address who, uint256 newBalance) external;
function prank(address msgSender) external;
function startPrank(address msgSender) external;
function stopPrank() external;
function warp(uint256 newTimestamp) external;
function expectRevert(bytes calldata revertData) external;
function addr(uint256 privateKey) external pure returns (address);
}
address constant VM_ADDRESS = address(uint160(uint256(keccak256("hevm cheat code"))));

View file

@ -1,84 +0,0 @@
# What a 51 percent attacker can and cannot do on Igneum
6 October 2026. For the litepaper and the ledger. Written for a reader who maintains Monero or Kaspa and does not take a finality claim on trust. Every number names its model or its measurement; "approximate" marks the rest. Models and runs: `sim/horizon/consensus-security/` (`ghostdag_sim.py`, `finality_horizon.py`, `cost_model.py`, `signalling.py`), the finality simulator `sim/finality_v2.py` with `sim/results_v2.md`, the specification `docs/spec/02-consensus.md` and `03-finality.md`. The long form is `docs/analysis/horizon/consensus-security.md`.
Price basis: USD 11.7 per GH/s-hour, measured on rented pods on 6 October 2026 (`docs/bench-log.md`, "Rental cost of hash": 1,748 MH/s for USD 20.44 an hour; the market supplied no more than about 2 GH/s that evening, so every figure above that is a list-price extrapolation). An attacker holding share A of the total hash rents A/(1 - A) times the honest network N.
## 1. What 51 percent buys, and at what price
Igneum orders blocks by GHOSTDAG (a rusty-kaspa fork, k = 18 at 1 block/s) and locks a checkpoint every 30 blue blocks when two thirds of the trailing 30 days of blue blocks, per vote key, have signed it (spec 03, Q3). The lock lands 63 to 93 s after the checkpoint block (determined 60 blue score later, certified about 3 s after; `sim/results_v2.md` A).
A majority of hash buys the ordering race up to that lock. The DAG simulator (`ghostdag_sim.py`, GHOSTDAG as `vendor/igneum-node/consensus/src/processes/ghostdag/protocol.rs` runs it, 20 seeds per cell, one-way delay 0.67 s = the cloud devnet's p99 propagation) gives, at 1 block/s:
| attacker share | hold 30 s | hold 60 s | hold 90 s | hold 120 s |
|---|---|---|---|---|
| 20% | wins 10%, reorg median 0 / max 18 | 0%, 0 / 1 | 0%, 0 / 1 | 0%, 0 / 1 |
| 34% | 40%, 0 / 18 | 40%, 0 / 36 | 5%, 0 / 39 | 10%, 0 / 52 |
| 51% | 85%, 10 / 15 | 85%, 20 / 28 | 80%, 32 / 44 | 80%, 42 / 53 |
| 67% | 100%, 8 / 13 | 100%, 16 / 23 | 100%, 26 / 31 | 100%, 34 / 41 |
"wins" = the private chain became the honest selected chain on release; "reorg" = chain blocks removed. The arithmetic: a private chain merges honest blocks as blue until its first block has k in its anticone, then every later honest block is red in it; it wins when its R blocks plus k exceed the honest N_h, always at or above 50 percent, below it only for holds under k A / ((1 - 2A) lambda) seconds (6 s at 20%, 56 s at 34%). Withholding longer than the lock latency is useless: the first checkpoint inside the hold locks at most 93 s after it is mined, and a chain missing a certified checkpoint is not a fork-choice candidate (spec 03 F1). Bound: the lock latency, 90 to 120 s. Price of the 90-s race at 51 percent: USD 0.30 at 1 GH/s, USD 304 at 1 TH/s (`cost_model.py`). What it earns: a deposit credited before its lock, which the four-state rule forbids (ledger P17: a wallet shows included, executed, proven, finalised and credits on the last).
Repeated 60-s withholding at 51 percent turns 26 to 28 percent of honest blocks red; a red block's subsidy goes to its merger (spec 02 2.5), so the majority takes about a quarter of honest income and lifts its weight share to about 56 percent (approximate), reorganising the chain every minute in public. It does not reach two thirds.
The proving pool is the one place 51 percent earns more than it spends today. Consensus checks a proof record's signature and its statement against the node's own execution, and not the proof (spec 07 7.7 item 4, 7.8 item 8; ledger P21). A producer who writes the correct statement, random proof bytes and its own payout address into its own block is paid the shard; at 51 percent of blocks that is at least 51 percent of the 20 percent pool (11,636 IGN an hour at full subsidy) and, since its fake rides its next block while an honest proof takes 9 to 11 s on a 5090, most of the shards outside the 10-s exclusive window. Proof verification in consensus closes it; it is the first item in section 5.
## 2. What it cannot do, and why
| it cannot | because | measured |
|---|---|---|
| Reverse a certified checkpoint | a candidate tip must pass through every certified checkpoint (F1); two certificates at one index need two thirds of total weight each, 4/3 in all, so a conflicting pair needs an equivocator holding a third of 30 days of blocks (spec 03 3.11.2) | 0 conflicting locks under 1/3 in every partition and eclipse seed; conflicts from 34% (`sim/results_v2.md` H, I, L3, M5; `finality_horizon.py` P and E) |
| Reach two thirds of weight with 51 percent of blocks | weight is blue blocks over a flat 30-day window: share = (t/30) A, ceiling A; 51% holds 51% on day 30 and never more while honest miners mine | the formula holds to 0.1 day (`results_v2.md` B, `finality_horizon.py` R); red-flooding lifts it to about 56% (approximate) |
| Buy weight faster than mining it | a pulsed rental buys 0.26 blocks per hash under the controller (`sim/difficulty/attacks` scenario 2); a bought key is worth its blocks and decays as the window slides, share = A (1 - t/30) + r t/30 (3.11.5) | `results_v2.md` K, `finality_horizon.py` K: keys worth 51% hold the veto to day 24 and are worth 30% on day 30 |
| Forge state | every full node executes natively and ignores a record whose statement differs from its own execution (the veto, spec 07 7.2 item 5); a soundness bug is a light-client problem (P7) | the exec-attacks suite, 96 of 97 checks on the shipped node (`docs/review/redteam-2026-10-04.md` row 28) |
| Change a rule | code activates when 95% of a day's blue blocks signal it, with a floor height as backstop (P2, `docs/plans/counter-asic-3-node.md` section 6); the signal has 0.07 points of noise over 86,400 blocks, so 94.9% never flips and 95.1% flips on day one | `signalling.py`; the fast-time gate's three cases and its failed case |
| Grind the hourly program | the epoch seed is a 10-minute class-group VDF of a checkpoint block fixed 20 minutes before the epoch; withholding a block to pick a program has expected gain 0 against a 300x evaluator margin (spec 04 4.1, 4.6) | `proto-vdf` Monte Carlo over 2,000,000 epochs |
| Stretch the clocks | a header is at most 10 s ahead of the clock and 10 s behind its parent; a sanitised clock pays a forgery back | a 50% forger drifts the rate +0.4 to +1.1% (M23 fixed, `sim/difficulty/attacks/README.md`) |
## 3. The table: capability against share, time, cost and earnings
| capability | share | time | rent at 1 GH/s | at 100 GH/s | at 1 TH/s | subsidy it earns meanwhile (IGN) | net |
|---|---|---|---|---|---|---|---|
| Reorder the last k blocks | any | 20 s | USD 0 | 2 | 24 | 0 | a loss |
| Win the lock-latency race (90 s) | 45 to 51% | 90 s | USD 0.3 | 30 | 304 | 1k | nothing credited under a lock |
| Reach the veto (1/3 of weight): pause finality at will | 51% | 20 days | USD 6k | 585k | 5.8M | 22M | the attacker earns 51% of it back |
| the same | 90% | 11.1 days | USD 28k | 2.8M | 28M | 22M | |
| Lock alone (2/3 of weight): certify any chain forward | 67% | 30 days | USD 17k | 1.7M | 17M | 44M | 33% of the period's subsidy at equilibrium |
| the same | 90% | 22.2 days | USD 56k | 5.6M | 56M | 44M | |
| Hold a pause once the veto is held | 1/3 | for ever | 0 marginal | 0 | 0 | keeps earning | free |
| 12-h double spend during a pause or the first 30 days | 51% | 12 h | USD 146 | 15k | 146k | 559k | the deposit |
| Orphan an hour of honest blocks beyond merge depth (pause only) | 51% | 1.5 h | USD 18 | 2k | 18k | 70k | the honest hour's subsidy, lost to all |
| Private DAG heavier over the window (cold-start node, no certificate) | 51% | 30 days | USD 9k | 877k | 8.8M | 34M | one fresh node misled |
| Capture the proving pool with fake records | any producer | continuous | 0 extra | 0 | 0 | up to 20% of emission | the one positive line |
| Block a rule change | 6% | per day until the floor | USD 18 | 1.8k | 18k | 6% of subsidy | about zero |
| Pause finality by taking the two hands down (tonight's topology) | 0 hash | | a DoS | | | 0 | free |
At the rental-market equilibrium, where hash joins until rent equals subsidy (39, 156 and 780 GH/s at IGN prices of USD 0.005, 0.02 and 0.10, the three inputs of `docs/analysis/security-budget.md`, not predictions), the veto nets about 48 percent of 20 days of the chain's subsidy and locking alone about 33 percent of 30 days (`cost_model.py` section 3).
## 4. The residual risks, plainly
1. **The pause.** Finality pauses whenever less than two thirds of 30-day weight is connected and signing, and the chain runs on proof of work with a 12-hour depth meanwhile (spec 03 3.7 item 2, 3.9). A third of weight holds the pause for nothing once it has it (`finality_horizon.py` S: 0 locks for the whole silence at 34 to 90 percent, 0 conflicts). During a pause a 51 percent miner is a 51 percent miner on any proof-of-work chain, with the 12-hour depth and USD 146 at 1 GH/s to buy it. What we are building against it is in section 5.
**The departure case** (not an attack, the same pause): tonight on the live devnet 20 keys holding 42.7 percent of the frozen weight table stopped mining within three minutes (a rehearsal job), the signing weight fell to 53.1 percent at checkpoint 6843 and finality paused at 18:39:40Z; rule v2 would have locked again after 35 minutes as the departed blocks aged out of the sliding table, and the frozen table (Q5, the live rule) holds the pause for one window, 2 hours there and 30 days on mainnet; locks had formed with the hand nodes down, so it was weight, not topology (`docs/analysis/horizon/finality-and-weight.md` 3.1 and 4.1; `finality_horizon.py` C: 51 percent leaving pauses 10.5 days under v2, 30.0 under v3). A sudden exit of a third or more of weight, by a price crash or a hosting failure, does the same. What an attacker can do during it is exactly the pause line: proof of work with a 12-hour depth, nothing against any certificate, no new lock to forge; what it cannot do is end the pause early or lock alone, since the departed weight is still in the denominator. The fix is a signed departure (LEAVE, section 5): a key that announces it is leaving is out of every denominator one hour later, which a partitioned key cannot fake.
2. **The first 20 to 30 days.** No lock forms before the window holds 30 days of history (spec 03 3.8, `min_daa` = window): the first month is proof of work with the 12-hour depth, by design, and the renter's day counts start at genesis. On day 30 an attacker producing share s of blocks from day k holds s (31 - k)/30 of the window: 75 percent from day 2 locks alone on day 30 (ledger F1).
3. **Sybil of keys buys nothing; buying keys buys their blocks.** Weight is blue blocks and every draw is by weight (W6; harness s2). A pool's key with its history can be sold or stolen and is worth exactly its 30 days of blocks, decaying linearly as the window slides (K); the sellers' price, not hash, is the limit, and a seller who keeps a copy strips the buyer by equivocating.
4. **Two thirds of total under churn.** The denominator is every key's blocks in the window. If half the honest miners leave, a miner at 51 percent of the old hash holds 51/(51 + 24.5) = 67.5 percent of the window after 30 days and locks alone (spec 03 3.7 item 4: "same as Bitcoin, with a month's warning"). A departure that stops mining and signing at once pauses finality 30 (1 - 1/(3A)) days under rule v2 and until the frozen table expires, 30 days after the last lock, under rule v3 (`finality_horizon.py` C), because a view cannot tell a departure from a partition.
5. **A partition longer than a window forks finality.** Each side fills its own table; under v3 neither side under two thirds locks for 30 days after the last common certificate, then both lock alone at once (`results_v2.md` M3) and an operator's trusted certificate resolves it (3.11.4). Under a third of weight, no equivocator shortens that.
6. **The proving pool.** Section 1's fake-record capture, until proofs are verified in consensus.
## 5. What we are building next
| rank | defence | what it closes | cost | liveness cost |
|---|---|---|---|---|
| 1 | Proof verification in consensus: a record whose aggregated proof does not verify against the pinned key is invalid | the pool capture of section 1 | 8 to 12 hours; per-record verify time to measure | none |
| 2 | Weight-gated deep fork choice: a tip forked more than D (about 10 min) back is a candidate only if the keys that built it hold a third of the weight table at the fork | the pause-time and first-month deep reorg: rented hash has no weight for 10 days, so a 12-h double spend needs the 20-day veto | 10 to 16 hours | none for certificates; a sub-third partition side cannot reorg the other past D, which is the intended outcome |
| 3 | Vote-or-burn: a block whose key has participation under 0.5 in its own past burns 20 percent of its producer share | the free pause: silence then costs 7,757 IGN an hour at 34 percent | 6 to 8 hours | none; partition-safe by construction |
| 4 | Peer floor and mesh: every box dials three others beside the hands; the node alarms under three peers or two checkpoints without a vote; no `unwrap` on any peer-driven sync path (a pruned node was crashed by one request tonight) | a star fleet pausing on one host; one request crashing any pruned node | 5 to 7 hours + 4 | none |
| 5 | The vote signs the execution root too | snapshot poisoning, and "ordered and executed" in one certificate | 8 to 12 hours | lock latency plus executor lag |
| 6 | Signalling over 7 consecutive daily windows, the floor a week past the publish | a one-day renter forcing a flip | 3 hours | a week's latency on class changes |
| 7 | LEAVE, the signed departure (lane 3's rank 1): a `leave` item carried in blocks, the key out of every denominator one hour after inclusion, sent by the app and the fleet library on a clean stop; F5's trusted certificate implemented | tonight's 30-day-scale pause after a planned departure; sim: first lock 1 h after a 34 to 50 percent departure, 0 conflicts in every partition row (`finality-and-weight.md` 6) | 6 + 4 hours | none |
| 8 | A client-shipped certified checkpoint | the cold-start private DAG | 3 hours | none |
Not adopted: prover attestations as a finality leg (the provers are the miners, coverage is a few percent, every lock would wait on a proof); vesting weight (a bought key transfers vested weight); any automatic rule that keeps locks going after an abrupt departure (a view cannot tell it from a partition: `results_v2.md` L4, M3).
The honest sentence for the litepaper: a hash majority on Igneum can reorder about two minutes, can buy a veto over finality in twenty public days and then pause it for free, can double-spend at a 12-hour depth only while finality is paused or in the first month, and today can take the proving pool without proving; it cannot reverse a certificate, cannot reach two thirds of weight while honest miners mine, cannot forge state, and cannot change a rule without 95 percent of a day's blocks.

View file

@ -1,6 +1,6 @@
# Proving on AMD and Apple cards: what exists, what the CPU can do, what to tell the public
5 October 2026, from the founder's two questions that evening: "test proving on the amd card?" and "can we test proving on
5 October 2026, from the project lead's two questions that evening: "test proving on the amd card?" and "can we test proving on
mac?". PC 1 holds an RTX 5090 and an RX 9070 XT (gfx1201, 16 GB) in an eGPU; this Mac is an M5 Max. The prover is
SP1 (`proving/igneum-prove`, `docs/plans/proving-v0.md`, `proving-v1.md`), run on the GPU only through SP1's CUDA
server. Every figure below is measured (with its bench-log entry or job id) or cited (with its file or page); the
@ -115,7 +115,7 @@ gates or a backend ships; it is reviewed with every prover release.
| Consequence | Action | Owner |
|---|---|---|
| An AMD-only miner loses the proving share | the CPU tier of 4a is measured here (section 2); whether it becomes a tier is a decision for the founder on those numbers | this analysis; the founder |
| An AMD-only miner loses the proving share | the CPU tier of 4a is measured here (section 2); whether it becomes a tier is a decision for the project lead on those numbers | this analysis; the project lead |
| The rig's prover unit must select NVIDIA cards only | already true in `prover_decision`; told the rig-installer agent to keep it as a stated rule and to print the CPU-fallback line for AMD-only rigs | rig-installer agent |
| The app's Proving tile on an AMD-only or Apple machine should say why it is off and name the CPU path | the `provedefault.rs` lines already say so for Apple; AMD-only Windows machines get "no NVIDIA card ..." | proving agent (told) |
| The site and litepaper over-promise for AMD and Apple | the line of 4c, to land with the next site pass (copy law; `node site/build.mjs`; link-check) | site-pages owner; not changed here |

View file

@ -1,8 +1,8 @@
# ASIC resistance, 2011 to 2026: the history, the papers, the lessons, and the audit of Igneum against them
5 October 2026 (night), branch `asic-history`. Asked by the founder at 20:05 UTC: "do a full on deep dive into the full history of 'asic resistance' and see if we can add or upgrade anything." Baseline for the audit: the Counter ASIC 2.0 final class decided tonight (`docs/plans/counter-asic-2-status.md` on `ca2-coord`, entries 20:16 to 22:25 UTC; `docs/analysis/chip-model-v3.md` on `ca2-mixer` 1ab8b21). Every figure about another chain cites a repo file, a paper or a dated article, or is labelled approximate. Hash-per-joule gains are computed from the cited hashrate and watt figures of the chip and of the best consumer GPU of the same year, and are approximate by construction (GPU figures vary by tuning). Research gathered by four sub-agents between 20:10 and 20:45 UTC; the fetch failures they reported are listed in section 6.
5 October 2026 (night), branch `asic-history`. Asked by the project lead at 20:05 UTC: "do a full on deep dive into the full history of 'asic resistance' and see if we can add or upgrade anything." Baseline for the audit: the Counter ASIC 2.0 final class decided tonight (`docs/plans/counter-asic-2-status.md` on `ca2-coord`, entries 20:16 to 22:25 UTC; `docs/analysis/chip-model-v3.md` on `ca2-mixer` 1ab8b21). Every figure about another chain cites a repo file, a paper or a dated article, or is labelled approximate. Hash-per-joule gains are computed from the cited hashrate and watt figures of the chip and of the best consumer GPU of the same year, and are approximate by construction (GPU figures vary by tuning). Research gathered by four sub-agents between 20:10 and 20:45 UTC; the fetch failures they reported are listed in section 6.
## 0. One page for the founder
## 0. One page for the project lead
**What the history says Igneum is doing right.**
@ -228,7 +228,7 @@ Ranked by how much the history says each would change the outcome, with the cost
| 1 | **Price the partial-store chip and draw the time-memory curve.** A chip that stores a fraction f of the dataset in HBM or on many narrow DRAM channels, recomputes the rest from a 256 MiB on-die cache under x8, and reads with 4-byte granularity. Rows for f = 0.25, 0.5, 1 at HBM3 and at GDDR7 random-read rates, priced in energy per hash (Rao's metric) and in reads in flight per watt | The only chip class that beat a memory-bound GPU hash: Ethash's 2.1x to 4.8x came from the memory system with no on-die dataset (rows 3, 4); Rao priced a 16-die DAG holder under a GPU board in 2019; Cuckoo's curve was wrong by 50x until drawn [P20]; O-1.6 is open and `MEMHARD.md` section 3 item 2 says the curve was never drawn | None (analysis) | The row may come out over 2x, which would qualify the public claim before anyone else does | Genesis (before the vectors freeze) |
| 2 | **A random item-derivation program per day** in place of the fixed-shape mixer: a SuperscalarHash-style generator, integer only, drawn from the day key, with its own acceptance test, compiled once a day by miners and verifiers | RandomX's reason for SuperscalarHash: a fixed derivation is hard-wired by a chip; a random one makes the light-mode chip a CPU (section 2.4). In Igneum's model the fixed shape is the 3x factor that turns 0.31x into 0.92x; removing the factor is worth more than x8 to x16 would be (x16: 0.46x with the factor by M16's table) | None per hash (the daily build is 23 to 77 ms at x8 and would roughly double); the verifier needs a per-day compiled derivation (a JIT, or a round schedule drawn from a fixed set of reviewed rounds), measured against the 10 ms gate | Cryptanalysis of random ARX programs; weak draws; a JIT in the verifier is new attack surface; the vendors must agree bit-exactly on a program they compile | Reserve (named family, unlock by height or signal) now; genesis if the verifier cost is measured under the gate before the freeze |
| 3 | **External cryptanalysis of M_r, the chained cache and the acceptance rule before genesis**, with the x8 shape as the target | Lesson 9 (MTP, Catena, Argon2i, Cuckoo); RandomX bought four audits for $141,000 before launch [S60]; the x8 decision multiplies the mixer's weight in the chip model, so a shortcut inside the mixer is now worth 8x more to a chip | None | Finding something late moves the vectors; not finding it in time moves nothing | Genesis gate (ledger M7, raised in priority) |
| 4 | **The clock and the detector.** (a) A share-pattern detector on the observer: per-program hash-rate spread, nonce-group patterns and per-card-model rate bands, with an alert when a population behaves like one fixed design (MoneroCrusher's method); (b) a stated trigger: the bounty escrowed and the benchmark live before daily issuance crosses about $50K (Vorick's rule), not on a calendar date | Lesson 10 (85% secret share); section 2.5's table (chips at $20K to $30K a day on compute-bound hashes); D11 (the bounty is unfunded) | None | A detector with false positives; a trigger the founder has to fund | Not a layer; genesis-independent; do it before the public testnet |
| 4 | **The clock and the detector.** (a) A share-pattern detector on the observer: per-program hash-rate spread, nonce-group patterns and per-card-model rate bands, with an alert when a population behaves like one fixed design (MoneroCrusher's method); (b) a stated trigger: the bounty escrowed and the benchmark live before daily issuance crosses about $50K (Vorick's rule), not on a calendar date | Lesson 10 (85% secret share); section 2.5's table (chips at $20K to $30K a day on compute-bound hashes); D11 (the bounty is unfunded) | None | A detector with false positives; a trigger the project lead has to fund | Not a layer; genesis-independent; do it before the public testnet |
| 5 | **Rank layer 9 (the epoch length) up, and measure the FPGA lane**: the compile-ahead cost per card at a 10-minute epoch (the `ca2-epoch` work), plus an estimate of a soft-overlay FPGA miner with HBM (reads in flight per watt against the 5090's 17.5 G/s) | FPGAs were the first adversary of Lyra2REv2 and X16R and came back within weeks of X16Rv2 (rows 7, 9); Xelis forked for FPGA resistance (row 30); a per-hour program is a bitstream target in a way a per-hash program is not | At 10-minute epochs: 6x the compile work per card (measured on `ca2-epoch`); the VDF lead shrinks | A short epoch moves the difficulty window (spec 1.12) and the seed path | Reserve (as decided), with the measurement before the public testnet |
| 6 | **Order the reserve by chip-unfriendliness**: families that force a full 32-bit datapath per lane first (byte permute, bit-field extract, variable shifts, popcount, select, the second shuffle form), mm8 last | Least Authority's "watch ML hardware"; int8 matrix blocks are licensable IP at every node; Apple pays 1.6x to 4.7x per emulated dot4 (status 20:38) | None at launch | None | Reserve ordering, genesis |
| 7 | **A vendor-share metric and a 3.0 target for the AMD gap**: the share of hashrate by vendor published with the benchmark, and the line-width question kept open as the plan says | Lesson 8: a one-vendor fleet is a softer version of chip capture; Equihash's NVIDIA tilt and Ethash's balance were part of each chain's miner politics (rows 3, 5) | n/a | A width that closes the gap makes the 5090 bandwidth-bound (status 20:27) | Counter ASIC 3.0 |
@ -256,14 +256,14 @@ Evaluated and placed nowhere, with the reason:
| Divergent data-dependent branches | Nowhere (already excluded) | Branches cost a GPU divergence and a chip nothing; RandomX's single predictable branch targets speculative CPUs, which Igneum does not have |
| Floating point | Nowhere (already excluded) | Vendor rounding splits the chain (spec 1.14); RandomX could afford it because its target is one ISA family with IEEE semantics |
## 5. Decisions this raises for the founder
## 5. Decisions this raises for the project lead
| # | Decision | Recommendation |
|---|---|---|
| 1 | Add the partial-store chip rows to `chip-model-v3.md` and draw the time-memory curve before the public testnet | Yes, before the vectors freeze (addition 1) |
| 2 | Name a random item-derivation program as a reserve family, and fund the verifier measurement that would move it to genesis | Reserve now; genesis if the verifier lands under the gate (addition 2) |
| 3 | Commission the external cryptanalysis of M_r and the chained cache before genesis, with the x8 shape as the target | Yes (addition 3; ledger M7) |
| 4 | Escrow the bounty and set its trigger to daily issuance, not to a date; build the share-pattern detector on the observer | Yes to the detector now; the escrow is the founder's (D11) |
| 4 | Escrow the bounty and set its trigger to daily issuance, not to a date; build the share-pattern detector on the observer | Yes to the detector now; the escrow is the project lead's (D11) |
| 5 | Rank the epoch-length reserve above the mm8 reserve, and measure the FPGA lane | Yes (additions 5 and 6) |
## 6. Sources and limits of this research

Some files were not shown because too many files have changed in this diff Show more