Compare commits

..

52 commits

Author SHA1 Message Date
igneum-labs
bc2b759827 adv-mixer-3 report: SAT ladder closed on both days (k=1 solved, k=2..4 timeout), totals at the close, nothing running
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 03:06:34 +00:00
igneum-labs
dc3e95e351 adv-mixer-3 report: Q7 complete on all eight days (uniform, clean), queue 07 finished, log copies
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 02:18:25 +00:00
igneum-labs
549bad16f1 adv-mixer-3 report: SAT day 20733 k=3 timeout, k=4 running
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 02:05:19 +00:00
igneum-labs
be09ab1668 adv-mixer-3 report: Q7 days 27406 and 33333 complete (uniform, clean)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 02:01:33 +00:00
igneum-labs
75358adf28 adv-mixer-3 report: Q7 days 21057 and 23311 complete (uniform, clean)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 01:45:39 +00:00
igneum-labs
ff9fcf8921 adv-mixer-3 report: Q7 section, days 20730 and 20745 complete (uniform, clean)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 01:29:44 +00:00
igneum-labs
1ff2ab36ae adv-mixer-3: queue 07 (the eight random days) rewritten in the lease form with run_shard, claimed and launched on build-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 01:19:10 +00:00
igneum-labs
a9a943b124 adv-mixer-3 report: queue 17 finished (Q2 k=3 at 2^27 clean), every harness row in; log copies
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 01:17:53 +00:00
igneum-labs
69bd825398 adv-mixer-3 report: Q2 k=2 at 2^27 clean, SAT day 20733 k=2 timeout
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 01:05:26 +00:00
igneum-labs
cc8ea10e28 adv-mixer-3: log copies through the 2^28 rows
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 00:31:33 +00:00
igneum-labs
38dca2d459 adv-mixer-3 report: Q2b day 20733 at 2^28 (k=2, 3 clean), second pre-emption recorded
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 00:30:21 +00:00
igneum-labs
5649ca4cd7 adv-mixer-3: queue 17 runs the sac0 2^28 rows before the sac 2^27 rows
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 00:11:13 +00:00
igneum-labs
00705f31eb adv-mixer-3 report: one pre-emption recorded (23:58 UTC, re-queued)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 00:10:15 +00:00
igneum-labs
e6451e8fca adv-mixer-3 report: box-hour totals and the partial rows named
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 00:03:43 +00:00
igneum-labs
f152539401 adv-mixer-3 report: SAT k=4 timeout (day 20729 k=1..4 closed), day 20733 SAT rows queued
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 23:41:34 +00:00
igneum-labs
092e43906e adv-mixer-3 report: Q1 day 20733 closed k=1..8 (uniform at the real first read on both days)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 23:37:17 +00:00
igneum-labs
024e6860b9 adv-mixer-3: log copies through the Q2 close on both days
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 23:29:21 +00:00
igneum-labs
9e0d9689fc adv-mixer-3 report: Q2 day 20733 closed k=1..8 (both days complete)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 23:28:04 +00:00
igneum-labs
bc0609fa1d adv-mixer-3 report: Q2 day 20729 closed k=1..8 (k=8 clean), SAT k=4 CNF size
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 23:07:15 +00:00
igneum-labs
55fab1c848 adv-mixer-3: census leases accept 4 cores at the least (4 were free while min 8 waited 81 min)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 22:45:50 +00:00
igneum-labs
e6e44c39cb adv-mixer-3 report: SAT k=3 timeout at one hour, k=4 running
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 22:41:37 +00:00
igneum-labs
1bc1555477 adv-mixer-3 report: ledger row for the any-size pre-emption rule and the driver restarts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 21:24:11 +00:00
igneum-labs
ed6e091239 adv-mixer-3: run_shard retries a pre-empted lease with the same line and skips done rows
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 21:23:52 +00:00
igneum-labs
afe96d8aa9 adv-mixer-3 report: SAT k=2 timeout at one hour (no SAT shortcut), Q2 day 20729 k=5..7 clean
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 21:13:52 +00:00
igneum-labs
0243840bcd adv-mixer-3: census leases accept 8 cores at the least (14 were free while min 16 waited 31 min)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:14:48 +00:00
igneum-labs
dd442e8b6a adv-mixer-3 report: queue 18 launched on build-2 (box 2 reopened), SAT k=2 running
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 20:13:54 +00:00
igneum-labs
b1d54ac060 adv-mixer-3 report: queue 18 released on the shared queue, queue 17 wait state
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:57:30 +00:00
igneum-labs
3b10952312 adv-mixer-3 report: ledger row for the ranked lease and the 32-thread re-submission, log copies
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:44:38 +00:00
igneum-labs
9f19f48d4c adv-mixer-3: leases at 32 threads (never pre-empted under the priority classes), queue 17 re-submitted under the ranked lease
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:42:54 +00:00
igneum-labs
7de3cf9dd3 adv-mixer-3 report: queue 18 withdrawn from build-2 (ledger), SAT k=2 CNF size
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:41:41 +00:00
igneum-labs
50cd91dfe2 adv-mixer-3 report: day 20730 sac rows from queue 07, Q7 board row
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:33:27 +00:00
igneum-labs
8c06497f95 adv-mixer-3: yield by owner (class-v5, or attack-pass with v5), never to another adv- lane
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:32:40 +00:00
igneum-labs
b0041e6a3f adv-mixer-3 report: v5 yield row in the ledger
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:31:35 +00:00
igneum-labs
e88aef83ad adv-mixer-3: yield to any v5 waiter or class-v5 owner
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:30:26 +00:00
igneum-labs
266817afa0 adv-mixer-3: yield to v5 gate and v5 kit waiters before every lease
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:26:29 +00:00
igneum-labs
15ab442fd8 adv-mixer-3: x-lock.sh is the pool lease, queues 17 and 18 (the lost rows through the lease), ledger row for the re-queue
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:25:08 +00:00
igneum-labs
631acd10ef adv-mixer-3: the kill ledger (19:20 UTC, main's lease-pool rule), the round margin stated, queue 17 in the lease form, all logs copied
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:23:02 +00:00
igneum-labs
44c7abefed adv-mixer-3 report: Q2b day 20733 k=0..8 (finding at k=1, clean from k=2), 2^28 at k=4, Q2 day 20733 k=4
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:20:18 +00:00
igneum-labs
a62cefce9c adv-mixer-3 report: Q1 day 20729 closed k=1..8 (uniform at the real first read), day 20730 rows, Q2 k=4 and 20733 k=3, SAT table
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:14:50 +00:00
igneum-labs
0f0975bed2 adv-mixer-3: queue 16 (index 20733 k=2..4 under the lock)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:13:51 +00:00
igneum-labs
448d92011c adv-mixer-3 report: day 20733 Q2 k=2, Q4b, Q5 complete; Q1 k=7; Q2b 2^28 at k=3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:10:51 +00:00
igneum-labs
815161ee69 adv-mixer-3 report: Q1 k=5,6; Q4 complete on day 20733 (k=1..8 inside the band); Q2 k=3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:06:42 +00:00
igneum-labs
6355d5e6b4 adv-mixer-3 report: Q5 rows (no RX property from k=1), Q2 day 20733, Q2b 2^28 at k=2, Q4 day 20733, the lowest-bit trail mechanism, queue 07 run by a sibling
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:00:15 +00:00
igneum-labs
fb7d836b1e adv-mixer-3 report: Q2 k=2 clean (margin 1 of 8 on random states), Q4b lin0 k=1..8, Q5 plant, Q4 k=7, the one-sweep-per-box rule
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:58:14 +00:00
igneum-labs
726753a50e adv-mixer-3 report: Q2b k=1..8 (margin 1 of 8 on the round-0 input), Q3 both days, Q1 k=4 and day 20733, Q4 k=5..6, log copies
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:55:14 +00:00
igneum-labs
b0feb0381a adv-mixer-3: times in UTC (identity grep)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:52:43 +00:00
igneum-labs
6e948f7fd8 adv-mixer-3 report: header, status board, first rows (Q1 k=1..3 uniform, Q2 k=1, Q2b k=1, Q4 k=1..4 inside the band, SAT k=1 bound)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:51:42 +00:00
igneum-labs
b58fe02330 adv-mixer-3: sac0 (t-bit avalanche on the round-0 input) and queue 09
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:50:03 +00:00
igneum-labs
049b7fdd99 adv-mixer-3: harness (index census, sac, diff, lin, lin0, rx, cnf), run-box.sh, eight queue files
Internal adversarial pass, not an independent review. igneum-pow by path; the plant shapes one, nomul, weak, weak0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:47:02 +00:00
igneum-labs
3be23aede6 adv-mixer-3 plan: base commit and box routing noted
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:30:37 +00:00
igneum-labs
e5d0e1d82c Merge remote-tracking branch 'build/master' into adv-mixer-3 2026-10-07 18:30:37 +00:00
igneum-labs
0fbec8294c adv-mixer-3: attack plan for the statistical distinguisher and the round margin of M_r
Internal adversarial pass, not an independent review. Target 84601bc9 (class v4 sub-version 3). Seven questions:
exhaustive round-0 line-index census, high-N avalanche, differential, linear, rotational-XOR, SAT on the round-0
input, per-day runs. Known-failed shape per method, box-hours, the complete read set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:29:55 +00:00
2548 changed files with 17094 additions and 545020 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

@ -3,7 +3,7 @@
# 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
# One line per failed 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
@ -16,9 +16,7 @@ on:
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' }}
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
# 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
@ -37,7 +35,6 @@ jobs:
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 }}

View file

@ -36,7 +36,6 @@ jobs:
# 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:
@ -63,7 +62,6 @@ jobs:
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
steps:
- uses: actions/checkout@v4
- name: toolchain
@ -83,7 +81,6 @@ jobs:
# 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
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
@ -107,10 +104,6 @@ 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
@ -125,4 +118,4 @@ jobs:
run: bash tools/ci/pre-push.sh --ci
- 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

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)" }

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.
//!

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

@ -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,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

View file

@ -1,635 +0,0 @@
# Internal attack pass before the freeze (F1 to F10)
The internal cryptanalysis pass of `docs/plans/cryptanalysis.md` section 4.2, run before the freeze tag
`cryptanalysis-target-1`, so the adversarial lanes confirm rather than discover. The founder's word, 7 October 2026:
"make sure they find ZERO flaws". Every finding is ours, fixed and re-gated, before the public report.
Target: the hash class the chain runs after 0.3.15's flip, `igneum-pow` generator v4 `V4_CLASS` = `mx8+sh256x27`
(`LoadClass::MX8`, `ShadowClass { instrs: 256, reps: 27 }`), the acceptance rule, the verifier, the era draw,
the latency-shadow dataset and ladder, the chip and FPGA cost model. Scope and gates are section 1.1 and 1.4 of
the plan, the same tests the public report is held to.
Lane: attack-pass, worktree `igneum-wt-attack`, branch `attack-pass` from `origin/master` `ab99e5e3`.
Binary built on igneum-build-1 (ELF x86-64, `igneum-pow` 0.2.0, sha256 6d2867...1a9ebe5) and run there under
the box's slots; model and era work from `sim/horizon/algorithm/model.py` and `infra/fast-time/`. Each row below
carries the method, the known-failed shape where one exists, the result with numbers, and PASS, RUNNING,
BLOCKED or FINDING. PASS RECORD (7 October 2026, 16:0x UTC, 17:0x UK): every row reads PASS or FIXED-AND-PASSED. F1 PASS (AP-F1-1 on the
v5 list at 3.0 percent); F2 PASS, effort-bounded; F3 PASS; F4 PASS against class v4 (AP-F4-1 on the v5 list); F5
FIXED-AND-PASSED (the F2 hour skipped by decision); F6 PASS (the worst of 10^5 programs 8.708 ms on the half-core
proxy; O-1.14 closed on an i7-9700K); F7 PASS on all three sub-rows (the era VDF in the node, 0 of 6 re-rolls); F8
FIXED-AND-PASSED in class v4 sub-version 3 at 017e7037 (AP-F8-1, AP-F8-2, AP-F8-3 ours and closed; the four-seed
tail attributed to per-site bucket concentration, 8 October 2026); F9 PASS on (a) and (c), (b) closed by the same fix; F10 PASS. Frozen generator: class v4
sub-version 3, igneum-pow 017e70376489251e18564c0abce7e466e606c8b3, devnet epoch-0 id a785001687d8688a. The
freeze tag `cryptanalysis-target-1` is the coordinator's cut on this record; the era VDF precondition is met (F7 a). Main checks every number
against the log before quoting it to the founder.
## Status board
| # | Attack | Gate (same as 1.4) | Result so far | Status |
|---|---|---|---|---|
| F1 | Shadow block compressibility and shortcut search | no compression of the shadow block beyond the honest compiler's simplification, measured against that compiler on the same program; the 27 repetitions never fewer than 27x (coordinator's ruling 7 Oct 2026, 12:3x UK; the plan's gate (1) and row F1 carry the same) | 10^4 and 10^5 class v4 programs: saved instructions mean 0.62%, max 5.078% at 10^5 (1 of 100,000 over 5%, 0 over 10%); nothing folds or dedupes across the 27 passes; every saving is local peephole algebra that clang -O3 removes from the honest kernel too (IR counts match on the worst programs), so against the compiler the compression is 0; 0 mismatches in 2 x 10^5 differential and verifier checks, z3 window proofs 0 counterexamples. AP-F1-1 routed to the v5 list as a shadow redundancy bound. Record `docs/analysis/attack-pass/f1-shadow.md` | PASS; AP-F1-1 on the v5 list |
| F2 | Mixer round margin (SAT/MILP, 1 to 4 keyed applications) | no distinguisher or shortcut beyond 2 of the 8 applications | one application characterised (differential weight 10 to 12, linear 1, verified on the real code on three days); two applications: no trail at or below weight 20 to 24 within 7,200 s per job, the MSB and LSB families die at two; rotational-XOR no bias at one application; the multiply layer folds on 0 of 2^20 inputs, k applications cost k; three applications: no differential trail at or below weight 29 to 35 and no linear at or below 24 to 28, four: 39 to 47 and 24, every job at its 7,200 s cap. Record `docs/analysis/attack-pass/f2-mixer.md` | PASS (effort-bounded) |
| F3 | Chained cache j+1 bound and storage-vs-recompute curve | no derivation under j+1 blocks; curve monotone; f=1 point unchanged | 0 of 64 and 0 of 1,024 lines under j+1 (exhaustive closure search, cross-checked by exhaustive pebbling at 10 lines, 10,240 pairs, 0 mismatches); both planted broken chains fire; curve monotone at both op counts; f=1 point 9,360 ops per item unchanged. Record `docs/analysis/attack-pass/f3-cache.md` | PASS |
| F4 | Weak-day census over 2^24 day keys | fraction of days with gain over 1.1x under 2^-20 | PASS against M2 (DSP-bound datapath): 0 of 2^28 days over 1.1x; planted weak days fire; every ROT and RC class 0. Bound finding AP-F4-1 on M1 (LUT adders): 5,476 of 2^24 days (3.26e-4) over 1.1x as the tail of a sum, no weak class; worst public-calendar day 29,337 at 1.121x, at most 12.1% more rate that day for a per-day LUT FPGA, 0 for any chip; redraw rule (NAF sum under 163 rejected) routed to the next class. Record `docs/analysis/attack-pass/f4-weakday.md` | PASS (v4); AP-F4-1 routed to the next class |
| F5 | Chip-model sweep + AWS F2 FPGA hour | evidence row 17 holds across the sweep; FPGA row under 27 M reads/s/W | sweep: 2.1x at k=1 GDDR7 reproduces, 3.2x at k=0.5, 4.1x at k=0.3 (matches ledger M32); FPGA row 2.3 to 2.9 G/s, 10 to 20 M reads/s/W (literature). FINDING: the k=0.33 figure is framed as the X9's measured core (M32) and a "measured class" (ladder branch §5a); the X9 was withdrawn before launch and never benchmarked. F2 hour SKIPPED: no AWS account | FIXED-AND-PASSED (sweep PASS; AP-F5-1 fixed and re-gated 7 Oct 2026: chip section re-run 2.1x at k=1 unchanged, identity grep 0 hits, site lane concurred); F2 hour SKIPPED-BY-DECISION (the founder, 7 Oct 2026, 09:5x UK; plan 4.2 row F5 is the sweep only at 3714c2a0; the FPGA row stays the JEDEC-ceiling model row labelled unmeasured) |
| F6 | Verifier worst case over 10^5 programs + O-1.14 laptop run | worst program under 10 ms cold on the half-core proxy and the laptop | 100,000 programs ranked, 50,000 timed cold one-core (worst 6.194 ms), the worst 200 re-timed and the worst 1,000 timed on the half-core proxy under the per-core lease (core 40 at 3,799.9 MHz): worst 8.708 ms (`attack-f6/87142`), 1.29 ms under the gate, every half-core reading under 9 ms; dr736 fails as it must (15.49). O-1.14 CLOSED on an i7-9700K (v4 6.334 ms cold max, dr736 10.04 fails). Ladder ceiling from the worst program on the half-core proxy: N about 300,000, so rung 2 admissible, rung 3 not. Record `docs/analysis/attack-pass/f6-verifier.md` | PASS |
| F7 | Era-draw bias harness + 2^20 era-seed census | no re-roll inside the publish window; no era class with gain over 1.1x over 2^-20 | (b) census PASS at 2^24 seeds (no class over 1.1x, stride bijective, R, pos and M uniform, planted cases fire); (c) 64-bit day-key seeding PASS (0 collisions in 2^17); (a) PASS with the era VDF in the node (era-vdf lane, fork era-vdf-node 394a5902 on release-0.3.20-node c4459193, behind era_vdf_activation_daa; master a4eaf766 carries the spec text, `docs/analysis/era-vdf-2026-10-07.md` and `tools/era-vdf/reroll.mjs`): against the real era cut on three fast-time nodes the re-roll harness fires with the VDF off (6 of 6 cuts) and is silent with it on (0 of 6; the adversary's 5.4 to 6.6 s evaluation against a 1 s block interval, the honest chain 3 to 10 blocks ahead, three nodes agreeing on every era seed); the delay is 517 s on the fastest prover measured (chiavdf NUDUPL over GMP, 208.8K squarings/s) at T = 108,000,000, 259x the 2 s window. Open, not a gate: the verify is 22 ms against the 10 ms target (O-4.6, 0.3.22). Record `docs/analysis/attack-pass/f7-era.md` | PASS (a, b, c) |
| F8 | Uniformity censuses (line-index 2^28, distinct lines, cross-hash histogram) | uniform within the window model of spec 1.13.1, layer 8; the excess beyond it within 6 sigma over 64 seeds; no hot set under 1% of items beyond the model | line index PASS at 2^28. The 6 Oct stream: 31 of 64 seeds over 1.2x (AP-F8-1, the lossy load source). Sub-version 1 (8c728ca3): 11 of 64. Sub-version 2 (07a809a7 / 8bdcbdd8): 9 of 64; AP-F8-2 (exhaustion) closed there, 0 of 10^6. Sub-version 3 (017e7037: the acceptance executing the shadow block, AP-F8-3; the shared-operand rule; (c'') the distinct-index ratio): 60 of 64 under 1.2x, the four over the named tail at 1.22x to 1.50x, attributed 8 October 2026 to a quarter-bit bucket concentration at one narrow-window site each, with no chip consequence; 0 exhausted in 24,631 chain-shaped seeds, the draw total by construction. Record `docs/analysis/attack-pass/f8-uniform.md` | FIXED-AND-PASSED (sub-version 3, 017e7037, the frozen generator) |
| F9 | Acceptance edges (39) + header grinding on an RTX 5090 | zero passing programs with a hot set under 1%; grinding gain under 1% of rate | (a) edges on generator 4 over 10^5 seeds: 34 disagreements in 105,064 candidates (29 const_bit, 4 bias, 1 lane_const), every one a one-bit or sampling-noise property moving the choice to an attempt both stand-ins accept, nothing in the attacker's favour: PASS; (b) hot-set search over 10^6 seeds: 11,696 passing programs (1.17%) concentrate 1% or more of reads on a hot set, worst 17.3%: FINDING, the same or-saturation load-source class as AP-F8-1 found by a second harness (F9-1 merged into AP-F8-1), re-gated on the amended stream; (c) grinding on the 5090: +0.004% at K = 2^14, ceiling +43%: PASS. Record `docs/analysis/attack-pass/f9-grind.md` | (a) PASS; (b) the AP-F8-1 class on the 6 Oct stream, closed by sub-version 3 (F8's census is the re-gate instrument; this harness's hot-share metric counts the era's windows); (c) PASS |
| F10 | Ladder signal monotonicity harness | no step without 90% over 7 windows in either direction | known-fail fails, known pass passes; new cases on the exact-share driver: 89% up holds (no step), 89 then 90% down with restarts steps only at 90% after the 7-window cool-down, the floor holds under 100% down (never below rung 0); decision per seed block memoised, identical after a restart, never differs between nodes; 19 of 19 checks per case. Two items to main, not findings: a stale commit string in the ladder lane's igneumd, and proof-synced nodes deciding rung 0 until the witness lands (a precondition line for spec 01). Record `docs/analysis/attack-pass/f10-ladder.md` | PASS |
## The rows
### F1. Shadow block compressibility and shortcut search (hash lane)
Method: over 10^4 class v4 programs, constant folding, dead-register elimination, common subexpressions across the
27 repetitions, linear sub-block detection, SAT equivalence on reduced blocks; the minimum op count per program
against N. Known-failed shape: a shadow that constant-folds or dedupes across its 27 identical passes so a chip
pays fewer than 55,296 shadow instructions per hash. Entry point: `igneum-pow show --program-class v4` prints the
256-instruction shadow (op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12 on the
genesis seed). Gate: best compressed block within 5% of N on every program; no program over 10% compressible.
Result: RUNNING. What a failure moves: an acceptance-rule line for the shadow block (rule (c) runs the block),
packs re-cut.
### F2. Mixer round margin (hash lane, on the box)
Method: SAT or MILP differential and linear search on 1 to 4 keyed applications with drawn rotations; rotational-XOR
on the ARX layer; the fold of the multiply layer across applications checked algebraically. Known-failed shape: a
differential or linear trail or an algebraic fold that distinguishes or shortcuts more than 2 of the 8 applications
between dependent reads. Gate: no distinguisher or shortcut beyond 2 of the 8 applications. Result: RUNNING (prior
`ca2-mixer` evidence to be re-gated). What a failure moves: `mixer_mult` 16 or a shape change; verifier re-measured.
### F3. Chained cache j+1 bound and storage-vs-recompute curve (hash lane, on the box)
Method: exhaustive search on a 2^10-line model segment for a line derivable without an earlier line; the curve from
f = 1/64 to 1 in ops per item. Known-failed shape: a line (s, j) computable in fewer than j+1 block evaluations
without an earlier line (the MTP address-steering break shape). Gate: no derivation under j+1 blocks; curve monotone;
f=1 point unchanged. Result, 7 October 2026, 09:10 to 09:12 UK on the box (`docs/analysis/attack-pass/f3-cache.md`; logs
`/srv/builds/igneum-wt-attack/attack-f3/r1-*.log`): PASS on all three clauses. The chain extracted from
`Cache::fill_segment` (verified equal to the code on 16 of 16 key and segment pairs; `block == chacha_block` on
100,000 random inputs) has line j fed by line j - 1 only; the exhaustive closure search finds 0 of 64 and 0 of
1,024 lines under j + 1 (every line costs exactly j + 1), cross-checked by an exhaustive pebbling search at 10
lines (10,240 configuration and target pairs, 0 mismatches). The two planted chains fire: `skip2` (63 of 64 under
j + 1) and `nofeed` (every line in 1 block). The curve over stored cache lines is monotone non-increasing from
f = 1/64 to 1 at 608 (counted) and 700 (MEMHARD.md) ops per block; the f = 1 point is 9,360 ops per item,
41.7 MH/s at the 50 T op/s budget, unchanged. `ca2-cache` was the hot-table experiment, not a chain analysis, so
there was nothing to re-gate. Observation (coordinator and the F3 record, not a finding): `funding.md` B2 rank 2
prices the trade-off at the naive placement; the optimal placement of every 8th line costs 3.17 blocks per read,
not 3.5, and 16.0 at f = 1/64, not 31.5 (brute force over 4,426,165,368 sets at n = 8); the chip stays worse than
the full mirror at every f under 1, so the verdict stands, and a `chacha_block` shortcut in chaining mode stays
the open question of the adversarial lanes. What a failure would have moved: the chain construction (a second feed-forward or
a cross-segment tie).
### F4. Weak-day census over 2^24 day keys (hash lane, on the box)
Method: 2^24 day keys through `MixParams::with_shape`; the ROT classes (all equal, complementary pairs, small
amounts), MUL low weight, RC structure, each per-day gain measured on the box verifier. Known-failed shape: a day
key whose drawn ROT/MUL/RC gives a fixed datapath a gain over 1.1x (the "weaker authorized parameters" class,
Kudelski 2019). Gate: the fraction of days with any gain over 1.1x under 2^-20. Result: RUNNING. What a failure
moves: a rejection-and-redraw rule on the draws.
### F5. Chip-model sweep and the FPGA hour (algorithm lane)
Method: `sim/horizon/algorithm/model.py` over k 0.2 to 1.5, tFAW 12 and 28 ns, HBM4 2.3 and 21.4 G reads per
stack, amortisation 1 to 3 years, electricity USD 0.05 to 0.15 per kWh; and the AWS F2 hour replacing the FPGA
ceiling row with a measurement. Known-failed shape: an input of the published model that, when corrected, lifts the
f=1 chip's per-joule edge over the 5090 above the published 2.1x at k=1.
Gate: the published sentence (evidence row 17) holds across the sweep; the FPGA row under 27 M reads/s/W.
Result (sweep): PASS on the numbers. The model's measured-anchor column (GDDR7, the 5090 reads 82% of its ceiling)
gives the f=1 chip's v4 per-joule edge over the RTX 5090 bench row as 4.1x / 3.2x / 2.1x / 1.5x at k = 0.3 / 0.5 /
1 / 1.5. At k = 1 the figure is 2.1x, and 3.9x at k about 0.33, which matches `fud-ledger.md` M32. The higher HBM3
and HBM4 columns rest on an 8-activate per 12 ns window that JEDEC HBM2 timings (4 per 28 ns) do not support; the
model already states GDDR7 is the column to quote. One wording gap: `evidence.md` row 17 says "brings it to about
2x", which is a floor that holds at k about 0.9 and above but understates the edge at lower k (3.2x at k = 0.5). The
accurate statement is M32's, 2.1x at k = 1 with the k range beside it. The sweep's numbers stand; the finding is the
X9 framing below.
FPGA row: the HBM2 FPGA ceiling is 2.3 to 2.9 G reads/s (measured Shuhai U280, FCCM 2020, equal to the JEDEC
tFAW-bound 2.3 G/s), 10 to 21 M reads/s/W at 115 to 150 W, 0.30 to 0.47x of the 5090 per watt. Under the 27 M
reads/s/W gate. The AWS F2 hour is SKIPPED-BY-DECISION (the founder, 7 October 2026, 09:5x UK: not needed for now, not blocked;
plan 4.2 row F5 at commit 3714c2a0 on branch cryptanalysis is the chip-model sweep only, 4 h, the algorithm lane).
There is also no AWS account or `aws` CLI on this Mac. The FPGA row stays the JEDEC-ceiling model row labelled
unmeasured; the chip-model lane prices it from the reads-in-flight model; the public report states that the F2 measurement was not run.
FINDING (X9 framing), owning lane algorithm and hash (the ladder lane is closed, so ours): the published numbers
already carry 2.1x at k = 1 beside 3.9x at k about 0.33 (`fud-ledger.md` M32, recalibrated under X35). The error is
the framing. M32 calls the k = 0.33 figure "the X9's core" and the `ladder` branch's `latency-ladder.md` section 5a
calls k about 0.33 a "measured class". Bitmain's Antminer X9 (RandomX ASIC, 1 MH/s, 2,472 W, about USD 5,600) was
announced and, per pcpraha.cz ("Antminer X9 canceled: Bitmain withdraws model from market before launch") and
r/MoneroMining, withdrawn before launch. Its implied core efficiency (k about 0.33) is a CLAIMED datasheet figure
from a design that never shipped and was never benchmarked, not a measured calibration point. It is carried as the
pessimistic bound, not a calibration. This collides with the merged ledger X34 ("RandomX has a shipping chip;
correct every sentence that said otherwise"): if the X9 was withdrawn, X34's correction is itself wrong and must be
reversed. Confirmed from primary sources (coordinator, 7 October 2026): pre-orders opened 26 December 2025 (shipments
scheduled for July 2026), withdrawn in mid-May 2026 with buyers refunded before any unit shipped, no independent
benchmark, Bitmain never published a cancellation (its shop lists it as sold out), and the box was commodity Sophgo
SG2044 server SoCs with an AES accelerator and 60-plus DRAM sticks, no tapeout; its claimed edge about 2x per joule
over a tuned Zen 4 part, about 3x over a stock desktop CPU. Re-cut applied the same day: `evidence.md` row 17 (claim
and measured cells) and `fud-ledger.md` M32's answer paragraph on attack-pass, and the ladder design doc section 5a
on branch `attack-ladder-5a` from the ladder tip 7003f9f5 (the `ladder` branch is checked out by another lane, so
the fix rides its own branch for the ladder owner to take). The served-text rows (X34 reversal, X36) belong to the
site lane, which confirmed the wording agrees. What a finding moves
(plan 4.2 F5): the sentence re-cut before the freeze so the public report carries the corrected model. The re-cut, once the
fact is confirmed: k about 0.33 labelled a claimed pessimistic bound from a withdrawn design everywhere it appears;
2.1x at k = 1 on the GDDR7 measured anchor kept as the headline with the k range beside it; k itself unmeasured
until the chip-model lane produces it. This row reads FIXED-AND-PASSED only after the re-cut and its re-gate.
Economic row the withdrawal implies: a recompute chip at a 3x fixed-function factor against a CPU and GPU fleet
must recover its NRE (low to mid seven figures at a modern node, `chip-model-v3.md`) and carry a fork threat (a
class change at 95% miner signal can redraw the datapath the chip bakes in). The X9 at 2.47 J per KH against a
RandomX CPU fleet did not clear that bar at Monero's hash and price; the same arithmetic against Igneum's class v4,
with the shadow block and the automatic era draw as extra firmware risk, is why the chip model's verdict is a
deliverable and not a courtesy (plan 2.3). The confirmed reading: a box with a 2x to 3x per-joule edge and no NRE (commodity SoCs) was withdrawn rather than
face a 1.5x re-tune of RandomX, so the tapeout economics of a 3x chip against Igneum are worse than the X9's. This
row is the pessimistic case, not a measured gain.
### F6. Verifier worst case (algorithm lane)
Method: 10^5 class v4 programs timed on the box one-core and half-core proxies for the slowest warp (base program
and shadow block), plus the O-1.14 laptop run (the Windows `igneum-pow` build on the box, the relay, `bench
--warps 50`). Known-failed shape: a drawn program whose verifier warp exceeds 10 ms cold (the acceptance rule bounds
the miner's side, not the verifier's; `dr736` already FAILs at 10.51 ms cold one-core, but it is not the shipping
class). Gate: the worst program under 10 ms cold on the half-core proxy and on the laptop.
O-1.14 route (coordinator, 7 October 2026, 10:1x UK): no US laptop is due, so the laptop run is replaced by a
rented 2019-class CPU host through the fleet agent, capped at two hours of rent; the Linux `igneum-pow` from the box
(the same binary as the proxies) runs `bench --warps 50` for v2, mx8, mx8+sh256x27, dr368 and dr736 (the
known-fail) on it; INCOMPLETE with the numbers so far if the cap lands first. The Windows exe was also built on the
box for the day a laptop appears (1,009,675 bytes, sha256 fbed7538...e6c9). Outcome, 09:40 UK: no 2019-class CPU
host stood up. Vast accepted and dropped five CPU-class rents within 30 s each (i7-9700K, i5-8500, Xeon W-2133 and
W-2123) under the account's automatic new-account spend limit (support ticket open since 6 October), and RunPod has
no 2019-class CPU pod; so O-1.14 reads INCOMPLETE on the box proxies today and stays a precondition of the freeze.
Next try: the US laptop when it registers on the relay (the exe is ready), or Vast once the spend limit lifts; the
bench script is staged and runs in minutes. Correction, 10:4x UK (fleet agent): the provider dropped nothing; every
rent stood up and ran, hidden by Vast's instance listing cap of 25 rows on an account holding 38, so the hosts sat
idle and were destroyed. The fallback is re-rented under the same word (i7-9700K class, two-hour cap from its start,
read by id); the result replaces this line when it lands.
O-1.14 RESULT, 7 October 2026, 09:49 UK, Vast instance 54613164, Intel Core i7-9700K (2019 desktop core, Coffee Lake,
read at 4,170 MHz during the run, 31 GB DDR4, Ubuntu 24.04), the box-built Linux `igneum-pow` (sha256 6d286783...),
`bench --seed igneum-genesis --day 2026-10-03 --warps 50` on one core (`taskset -c 1`), the host otherwise idle; log
`docs/analysis/attack-pass/o114-i7-9700K-2026-10-07.log`:
| Class | Cold max (ms per warp) | Average of 50 (ms) | Gate 10 ms | Box one-core cold | Box half-core |
|---|---|---|---|---|---|
| v2 | 1.582 | 1.280 | pass | 1.30 | n/a |
| mx8 (class v3) | 5.394 | 5.267 | pass | 4.67 | 7.56 |
| mx8+sh256x27 (class v4, the target) | 6.334 | 6.006 | pass, 3.7 ms of headroom | 5.06 | 8.23 |
| dr368 | 5.540 | 5.426 | pass | 5.32 | 8.16 |
| dr736 (the known-fail) | 10.290 | 10.042 | FAIL, as it must | 10.51 | 15.49 |
Cache fill 276 ms on the 9700K core (box 361 ms, M5 Max 175 to 181 ms). The 2019 desktop core sits between the box's
two proxies as the arithmetic predicted (1.2x the box one-core cold on v4, 0.77x the half-core); the known-fail
fires on it. A 2019 laptop core at 3.5 GHz reads about 15 to 20 percent slower than this desktop part (approximate,
clock ratio), so about 7.0 to 7.6 ms on v4, still under 10 ms. Consequences: a 2019-class node verifying class v4
spends 0.6 percent of one core at 1 bps and 6 percent at 10 bps; a header flood needs about 160 invalid headers a
second to saturate one such core; a pool verifies about 160 shares a second per core; IBD of 108,000 headers is
about 11 minutes of one core. The implied ladder ceiling on this core: the shadow costs 0.74 ms per 55,296
instructions (v4 minus mx8), so the 4.0 ms of headroom buys about 300,000 more shadow instructions, N about 650,000
counted ops at the 1.83 convention (approximate), against 370,000 on the half-core proxy and 1,060,000 on the
2.5x rule; the half-core proxy stays the standing pessimistic rule and the ladder's ceiling should be taken from
it, not from this desktop part. O-1.14 is CLOSED on a real 2019-class core for the genesis program; the F6 row
still owes the 10^5-program worst case before it reads PASS.
Result (average, verified on the box): class v4 `mx8+sh256x27` runs 4.90 to 5.06 ms per warp cold on one EPYC
9454P core (nice 19, taskset), 8.23 ms on the half-core proxy (both SMT siblings busy). Under 10 ms. Status
RUNNING: the 10^5-program worst-case search and the O-1.14 laptop relay run are owed before the row reads PASS.
What a failure moves: an acceptance-rule bound on verifier cost; the ladder's ceiling set from the measured core.
### F7. Era-draw bias harness and census (node lane harness, hash lane census)
Method: the fast-time 3-node network (`infra/fast-time/`) with an adversary withholding or publishing the last blue
block before C_era(n) to re-roll the draw; a census of 2^20 era seeds for stride, ROT and weight-perturbation
classes with gain over 1.1x; the 64-bit seeding of the day-key stream against the spec's intent. Known-failed shape:
a re-roll of the era draw inside the 2 s publish window, or an era class (stride bijection, all-equal ROT, low-weight
M) with a chip gain. Gate: no re-roll inside the publish window; no era class with gain over 1.1x at a fraction over
2^-20; the draw's input set as the spec states it.
Result (full record `docs/analysis/attack-pass/f7-era.md`; harness `tools/attack/f7-era/`). Census: 2^20 and 2^24 era
seeds through `generator::era_draw` over `V3_ALLOWED` (the chain's path), classified; the planted known-fail/known-pass
of the classifier fired and the sound draw raised nothing. No era class with gain over 1.1x at any fraction (the richest
is M = 1 at 1.0034x, absent in 2^24; every class over 2^-20 is 1.0000x to 1.0007x); the stride is a bijection on every
sample (0 even M), R and pos and the M bits uniform; the op-weight corners (15 to 31 of 75) are 1.0x against the GPU, 0
memory effect. The 64-bit day-key seeding is the spec's intent (spec 1.8.4); 2^16 days are all distinct, birthday 2^-33.
Harness: the 3-node fast-time network (`reroll.mjs`, ports 29800+, suffix 980) with an adversary holding the last block
before the cut; known-pass (`--vdf-ms 0`) fires at 1 of 6 cuts (seed = adversary block), known-fail (`--vdf-ms 5000`)
is silent at 0 of 6, both SOUND. The node has no era VDF yet (`seed_below` is a plain block hash, era-layout.md section
8), so the harness cannot show the real 2 s-window gate; the era draw's grinding resistance rests on the 1-hour VDF of
spec 4.4 (re-roll needs a 1,800x evaluator, spec 4.6 gives 300x; forge needs 20 days of 100% hash). Verdict: census PASS,
64-bit seeding PASS, harness INCOMPLETE with the written argument. Logs on igneum-build-1
`/srv/builds/igneum-wt-attack/attack-f7/census-2p24.log`, `census-2p20.log`, `reroll-knownpass.log`, `reroll-knownfail.log`.
What a failure moves: the draw procedure or the C_era cut rule; a redraw rule for the era stream.
Sub-row (a) CLOSED, 7 October 2026, 15:1x UK (the era-VDF lane, launched by the coordinator on this row's INCOMPLETE):
the era VDF is in the node (fork `era-vdf-node` 394a5902 on `release-0.3.20-node` c4459193, behind
`era_vdf_activation_daa`, never on any network until the founder sets it per network; repo master a4eaf766 carries the spec
text, the record `docs/analysis/era-vdf-2026-10-07.md` and the harness `tools/era-vdf/reroll.mjs`, which attacks the
era cut directly now that the node takes `pow_era_blocks` and `pow_era_lead` from the override file). Against the
REAL era cut (era 120 DAA, lead 20 on the fast-time file, three nodes on igneum-build-2) the harness fires with the
VDF off (6 of 6 cuts: the adversary's block is the cut block and its hash the seed, known the instant it is built) and
is silent with it on (0 of 6 across six cuts: the adversary's 5.4 to 6.6 s evaluation with the node's own code against
a 1 s block interval, the honest chain 3 to 10 blocks ahead when it published, three nodes agreeing on every era seed,
the record ready at every era start). SOUND both ways. The production delay: 517 s on the fastest prover measured
(chiavdf NUDUPL over GMP, 208.8K squarings/s on the same core) at T = 108,000,000 squarings, 259x the 2 s window.
Freeze sentence (the era-VDF lane's, carried to the plan's owner): "The era seed E_n is the output of a one-hour
verifiable delay (class-group Wesolowski, 1,024-bit prime discriminant, T 108,000,000, scheme byte 0 with the
hash-chain fallback as byte 1) over the blue blocks of the day ending at the era's cut block; the attack pass's F7
re-roll harness fires against the stand-in and is silent against the delay, so the era draw procedure and the C_era
cut rule are frozen with the VDF in the node, behind era_vdf_activation_daa, never until set per network." Open, not
a gate of F7: the verify is 22 ms with the group held, against the 10 ms target (once per 180 days per importing
node; O-4.6's reducer or GMP behind a feature, 0.3.22). Logs `/srv/builds/igneum-wt-era-vdf/ev-harness-out/
reroll-vdf-{on,off}-5.json` on build-2. F7: PASS on all three sub-rows.
### F8. Uniformity censuses (hash lane, on the box)
Method: the line-index distribution over 2^28 derivations; distinct lines per hash and per warp on 10^6 nonces of
three programs; the cross-hash item histogram of one epoch. Gate: the largest bucket within 6 sigma of uniform; no
hot set under 1% of items. Result: RUNNING. What a failure moves: the mask or the fold; packs re-cut.
### F9. Acceptance edges and header grinding (hash lane; one PC 2 job)
Method: the 39 edge disagreements reproduced and bounded; a search over 10^6 seeds for programs that pass rule (c)
with a hot set under 1%; the header-grinding search cost against its DRAM-locality gain measured on PC 2's RTX 5090
(one job through `tools/build-job.mjs`). Known-failed shape: a seed grind that steers a program to a hot cache set
for DRAM locality, or an edge where the closed-form stand-in disagrees with the live verifier in the attacker's
favour. Gate: zero passing programs with a hot set under 1%; the grinding gain under 1% of rate at any search cost.
Result: the `accept` path reproduces per-seed verdicts (genesis seed: 1 candidate ACCEPTED, bias max 54, 0
saturated). The header-grinding cost-versus-gain measurement needs a 5090. Status BLOCKED on the go decision: use
PC 2's 5090 through a relay run job only if PC 2 is online and mining is unaffected, else a rented pod under the
standing fleet budget. What a failure moves: the closed-form stand-in replaced by the live verdict at the edges; a
locality term in rule (c).
### F10. Ladder signal monotonicity (node lane)
Method: the fast-time harness with a weight that steps the ladder down and never up, and an 89% signal; the step
rule's monotonicity and its memoisation per seed block. Known-failed shape: a chip owner stepping the ladder down
(cheaper N) without the 90% threshold, or a step registered under 90%. Gate: no step without 90% over 7 windows in
either direction; a step down needs the same. Result: RUNNING. What a failure moves: the step rule's text in spec 01
before the ladder is frozen.
## Lane (d): the families re-run on class v5 (7 October 2026, evening; the coordinator's word on the founder's order)
Object: igneum-pow on branch `class-v5` at e4f1f275 (the frozen sub-version 3 017e7037 merged; the v5 chain draw is
the amended v4's instruction for instruction, generator 5, every item keyed by the window's state through the leaf
XOR before the first mixer; `V5_CLASS` = `mx8+sh256x27+state`), against the first v5 pack
`proto-cuda/packs-ca3-v5/v5-dn3-epoch0` (Devnet 3's genesis 4020cb43... as epoch and era seed, day 20,733, program id
e5a4ac5978462156, reproduced by the e4f1f275 build on box 2: the pairing). The four harnesses carried onto the
class-v5 tree in worktree `igneum-wt-attack-v5` (branch `attack-v5`), each with `--class v5` and, where the dataset
enters, `--state <IGSD1>` attaching the leaves through `with_leaves` as the CLI does; F8's traced derivation carries
the leaf XOR and validates bit for bit against `derive_items_leaves` and `Epoch::hash_warp` (p1 on the dn3 state at
4,096 nonces: 0 mismatches over 16,777,216 items and 64 warps; the flipped-state file mismatches: the known-fail).
Both boxes at nice 10 beside the release builds; the binaries run from copies in each run's scratch directory (AP-H2).
GitHub answered 403 (account suspended) from 17:2x UK, so this section lands on the box mirror (`build`, master and
attack-pass) by the coordinator's exception rule; nothing touches GitHub.
| Family | Class v5 run | Result | Verdict |
|---|---|---|---|
| F4 weak-day census | 2^24 chain days from 20,729 under `Shape::for_class(&V5_CLASS)`, box 1, 18:5x to 19:1x UTC | byte-identical to the class v4 census: M2 (DSP-bound) 0 of 2^24 days over 1.1x; M1 (LUT adders) 5,476 days, 3.264e-4, the same bounded tail, worst day 4,819,563 at cost 197 against the median 231; planted weak days fire (mul1all M2 unbounded, mulnaf 1.333x). The day-key draw depends on the mixer shape alone and v5 adds only the state flag, so identity is the expected and the measured result | PASS (v4's reading; AP-F4-1 stays the next-class item) |
| F8 hot-set gate (pre-freeze reading) | 64 seeds at 2^24, chain path, v5 with the dn3 state, box 2: hand-started at 18:47 UTC (11 seeds, killed on the coordinator's rule: every hand-started run off the boxes, loads 601 and 401), re-queued through `lease pool 64` at 19:23 UTC (17 more seeds), released at 19:4x UTC on the Counter ASIC lane's yield so the class v5 (c''') census, the 0.3.24 board's critical path, could take the pool | every seed read equals sub-version 3's seed for seed (0.9915x to 1.144x, p10 1.50x), as the v5 lane predicted: the leaves change the words, not the read addresses | READING, not the gate line |
| F8 hot-set gate, the frozen tip (THE GATE LINE) | igneum-pow class-v5 1c420786 (the 0.995 per-site floor (c''') on sub-version 3's rules; binary sha256 0f5c98dc41a1b3aa..., run from a copy); pairing: the library draws the dn3 epoch-0 program as e5a4ac5978462156, the harness validates bit for bit against the library on the dn3 state (0 mismatches on 66 validation lines); 64 seeds p2 to p65 at 2^24 nonces, chain path, the v5 dataset from v5-dn3-epoch0's state.igsd1, window-model control, build-2 under `lease pool` class v5 as two halves of 32 (ended 21:58:43Z and 22:03:21Z) | 61 of 64 under 1.2x of the window model (0.9919x to 1.144x, p75 1.0024x); 3 over, all inside the named four-seed residue and none new: p10 1.5047x (0x4018f5, 346 reads of 2^31, no predicted source), p8 1.3787x (0x839d33, 419), p4 1.2166x (0x400197, 363); p34 0.9997x under the (c''') floor; p23 1.0000x, p19 0.9997x, p15 0.9998x, p18 1.0001x, p56 1.0000x. Seed for seed the ratios equal sub-version 3's within 0.001 except where the floor moved a draw: the state leaves change the words, not the read addresses. Nothing to a chip | PASS (the known residue p4, p8, p10, attributed 8 October 2026 to per-site bucket concentration; the largest-bucket bound is a next class's item) |
| F9 exhaustion count, the frozen tip (A GATE LINE for the 0.3.24 move) | 10^5 chain-shaped seeds on the v5 chain path (era-composed class, `--chain`) with the dn3 state at igneum-pow class-v5 1c420786, pairing e5a4ac5978462156, build-1, ten chunks of 10,000 under `lease pool 4` (chunks 1, 3, 5 to 9 under class v5; chunks 0, 2, 4 re-leased under class release on the coordinator's order; 14,477 to 14,480 s per chunk, about 1.45 s per seed); binary copied into the run dir; interim line sent at 00:55 UTC (seeds drawn, 0 exhausted, 0 panics, max 30, F1 0 failures), which cleared the move; last chunk written 02:34:54 UTC | 100,000 of 100,000 seeds drawn, 0 exhausted, 0 panics, 0 past attempt index 31, max attempt index 30; histogram by attempt index (0 = accepted on the first draw) 0: 31,454; 1: 21,460; 2: 14,660; 3: 10,263; 4: 7,047; 5: 4,701; 6: 3,297; 7: 2,256; 8: 1,532; 9: 1,027; 10: 702; 11: 509; 12: 365; 13: 216; 14: 153; 15: 103; 16: 80; 17: 56; 18: 39; 19: 20; 20: 24; 21: 9; 22: 6; 23: 9; 24: 3; 25: 4; 26: 2; 27: 1; 29: 1; 30: 1; first-draw acceptance 0.3145, mean attempt index 2.185 (3.185 draws per seed), 4,862 seeds (4.86 percent) at index 8 or above, 255 (0.255 percent) at 16 or above; the 256-attempt cap and the deterministic last resort never reached. Meaning per tier: no epoch seed in 10^5 fails to draw a program, so the liveness halt of AP-F8-2 has no observed case on the frozen tip at this count (the bound it supports is under 3e-5 per seed at 95 percent, about one epoch in 33,000 at worst; a halt a node operator would see as a stuck epoch, a miner as a dead epoch, a holder as a paused chain), and the draw cost stays at about 3.2 candidates per epoch for every node | PASS (0 of 10^5; the record `f9-grind.md`, section (d)) |
| F1 shadow redundancy, the frozen tip (A GATE LINE for the 0.3.24 move) | 10^5 class v5 programs through the string-seed path (`generate_from_seed_bytes_program_class`, class V5, every candidate draw through the (c''') floor over 2^20) at igneum-pow class-v5 1c420786, pairing e5a4ac5978462156, build-1, `lease pool 16` class release (cores 8 to 23, re-leased 22:34:05 UTC), binary sha256 bb70bbf69a4b3223... copied into `frozen-1c420786-f1/bin`; 20,774 s of census (5 h 46 min; about 5 core-s per program, the (c''') draw cost), census.csv (sha256 4e34b669f2f5c680..., 100,000 rows) written 04:20 UTC on 8 October 2026 | 100,000 of 100,000 programs; instructions saved min 0.000, mean 0.623, max 4.688 percent (worst `attack-f1/95060` at attempt 0, 6,912 to 6,588 per iteration; `81748`, `66933`, `3006` at the same 4.688; next 4.311); chip-view ops saved mean 0.520, max 4.783; programs over 5 percent 0, over 10 percent 0; soundness: differential mismatches 0 of 100,000 (8 random states each), verifier mismatches 0 of 100,000; 0 panics; histogram of saved, 0.5 percent bins from 0: 55,241; 20,597; 11,762; 9,851; 1,484; 656; 259; 133; 13; 4; 0; 0; draw attempts per program: index 0 31,630, max 30 (the same shape as F9's). Against the v4 10^5 (max 5.078, the AP-F1-1 letter miss): the v5 tip's worst sits 0.39 points under the letter, the two top bins are empty, and the mean is unchanged (0.617 to 0.623), so the v5 leaves add no redundancy and remove the one letter miss. Meaning per tier: the shadow block of every drawn program stays within 5 percent of its naive count under the harness's rules and the honest compiler finds the same shortcuts, so no chip gets a shadow-side discount (a miner on a card pays the full block, a hypothetical ASIC gains nothing here) and the chain's verifier agrees with the harness on every program (0 mismatches), so no node disagrees with another on any drawn block. Harness gap and its fix: section 12 of the record (the running census was the old binary; the flush is in 18a9c04a for every census after it) | PASS (0 of 10^5 over the letter; AP-F1-1 FIXED-AND-PASSED on v5 at this count; the record `f1-shadow.md`, section 13) |
| F4 weak-day census, the post-freeze commit | 2^24 chain days at class-v5 8ca66afa (AP-F4-1 in the agreed form: cost A at most 205, k >= 1 or all-ROT-equal rejected, the forty redrawn), the harness on the agreed w32 convention (digits 0 to 31) and median 226; known-failed day 29,337 redrawn under the rule (cost 203 to 228), day 20,729 at 219 unchanged; build-1 `lease pool 12` class adv, 379 s, ended 22:3x UTC | 0 of 2^24 days over 1.1x on M1 (median 226; minimum cost 206 at day 27,016, 1.097x, one adder above the reject line) and 0 on M2; mean 225.79, sd 6.07 (pre-rule: 5.69e-4 over, min 203). The redraw rule removes the LUT tail by construction and the measurement agrees | PASS; AP-F4-1 FIXED-AND-PASSED against class v5 at 8ca66afa |
| F9 exhaustion count | 10^5 chain-shaped seeds on the v5 chain path with the dn3 state, box 1, ten parallel chunks (the chain draw costs about 2.2 s per candidate through (c''), so 10^6 is about fifty hours); killed before its first chunk closed, re-queued through `lease pool` | pending | pending the re-queue |
| F1 shadow redundancy | 10^5 class v5 programs through the string-seed path, box 1; the known firings fire under v5 (planted 50 of 256: 19.53 percent; the real block 0.000; the must-not-fire 1.157); the census killed before its end, re-queued through `lease pool` | pending | pending the re-queue |
## Operating hazards found by the pass
AP-H1 (box scratch cleaned by builds; found by F3, 7 October 2026, 10:0x UK). `infra/build-server/remote-run.sh`
line 71 runs `git clean -qfd -e target -e 'target-*' ...` on `/srv/builds/<worktree>` before every remote build, so
an untracked box scratch directory of one row (a venv, a log dir, a crate's `tools/attack/*/target`) is deleted by
the next build from any row. F3 protected its own directory through the box mirror's `.git/info/exclude`; the lane
then added `attack-*/`, `target-attack-*/`, `tools/attack/` and `.build-remote.log` to that file at 10:1x UK, after
which `git clean -fdn` on the mirror lists nothing (the clean has no `-x`, so the exclude file applies). The class
check is owed to the build-server lane: the clean line should spare a lane's declared scratch prefix (`-e 'attack-*'`
style, or read a per-worktree exclude list), and a CI check should fail a remote-run.sh whose clean line lacks it.
OPEN until that check lands (CLAUDE.md: a rule row closes only with its check).
AP-H2 (this lane's own, 7 October 2026, 13:3x and 14:5x UK, twice). Two census runs launched from the same crate's
`target/release` binary path on the box mirror: a rebuild of the crate at a new commit replaces the binary under a
run still in progress, and every chunk the run launches after that executes the new commit's code with the old run's
label (the 07a809a7 control's later chunks ran 8bdcbdd8; the ddacfbd3 class check's later chunks ran 017e7037). Both
runs were caught by their attempt histograms (attempts 32 and 35 under a cap of 32) and their contaminated chunks
discarded. Fix in the lane's launcher: `run-census-chain.sh` copies the binary into the run's own scratch directory
before the first chunk and runs from the copy, so a rebuild cannot reach a run in progress; a run's record names the
sha256 of the copy. Class check owed: the same rule for every lane's long run (the box's build runner could refuse to
replace a binary that a running process has open, or stamp the commit into the run's log at every chunk).
## Ledger rows
AP-F1-1 (hash lane; ruling asked). At 10^5 class v4 programs one program (`attack-f1/37341`) compresses by 5.078
percent (13 of 256 shadow instructions per pass), 0.078 points over the gate's first clause, on 1 of 100,000; every
other program is within 5 percent and none over 10. The saving is the same local shape as on every program (a
register written twice from one source with no write between), nothing crosses a pass, and clang -O3 removes the
same instructions from the honest kernel (IR counts match the harness on the worst programs), so a chip gains nothing
relative to a card: no shortcut. The gate as written counts honest-compiler simplification as compressibility. Two
ways to close: re-word gate (1) and row F1 to "compressible beyond the honest compiler's own simplification" (the
question is chip-relative compression), or a shadow-draw redundancy bound in the next
class (reject a shadow with over 12 peephole-removable instructions per pass, rejection about 1e-5; class v4 is on
the live vote). Ruling (coordinator, 7 October 2026, 12:3x UK): both. Gate (1) and row F1 re-worded to "no
compression of the shadow block beyond the honest compiler's simplification, measured against that compiler on the
same program" (sent to the cryptanalysis lane for the plan and the public report), under which the 5.078 percent
letter miss at honest-compiler parity is a PASS; and a shadow redundancy bound on the v5 generator's list beside
AP-F4-1 and AP-F8-1 (the generator refuses a shadow block whose honest-compiler simplification exceeds a stated
fraction; the v5 lane sets the fraction from F1's census), gated by F1's harness on 64 seeds of the v5 stream.
The fraction is 3.0 percent (v5 lane, class-v5 45e29cb0; 384 of 100,000 draws redrawn in its census, 3.8e-3, against
F1's histogram where the 3.0 to 5.5 percent bins hold 397 of 100,000); the plan's 1.1 sentence carries the number.
Status: F1 PASS; AP-F1-1 FIXED-AND-PASSED against v5 once the bound is in the v5 generator and F1's census passes.
AP-F5-1 (algorithm and hash lane, ours; the ladder lane is closed). The k about 0.33 chip-efficiency figure is
framed as a measured calibration ("the X9's core", `fud-ledger.md` M32 L172; "measured class", `ladder` branch
`docs/design/latency-ladder.md` section 5a). The Antminer X9 was withdrawn before launch and never benchmarked, so
k about 0.33 is a claimed datasheet bound, not a measurement. This also puts the merged ledger X34 ("RandomX has a
shipping chip") in question. Fix owed, held until the coordinator's research agent confirms the withdrawal and the
no-benchmark fact: relabel k about 0.33 as a claimed pessimistic bound from a withdrawn design in `evidence.md` row
17, `fud-ledger.md` M32 and the `ladder` branch; reverse X34 if the withdrawal is confirmed; keep 2.1x at k = 1 on
the GDDR7 measured anchor as the headline with the k range beside it. Re-gate after the re-cut. Status: FIXED on the docs rows (evidence 17, M32, ladder 5a on branch attack-ladder-5a d3cb17b6; attack-pass
rebased on master a3678789 after X36); FIXED-AND-PASSED once the site lane's X34/X36 served rows are confirmed in
one voice (no objection received) and the sweep is re-run against the re-cut sentence (the numbers are unchanged, so
the re-gate is the identity check and one `model.py --section chip` run against the new wording). Re-gate done 7 October 2026, 09:5x UK: the 5090
bench row still reads 5.7x / 4.1x / 3.2x / 2.1x / 1.5x (v3; v4 at k = 0.3 / 0.5 / 1 / 1.5), identity grep 0 hits
over 290 export files, the site lane confirmed the served text agrees. AP-F5-1: FIXED-AND-PASSED.
AP-F8-1 (hash lane; the generator fix is the Counter ASIC lane's on the v4 seam, routed 7 October 2026, 10:3x UK).
The class v4 item read map is not uniform. F8 phase D, one program, 2^26 nonces: the top 0.1 percent of items take
0.520 percent of reads against 0.115 percent for the uniform control (4.05x); the top 1 percent take 2.49 percent
(1.37x); one item (0xca5b92) takes 78,479 reads, 153x the mean; read site 15 feeds 6.37 percent of its reads into
that 0.1 percent in all 8 iterations; the excess grows with N as a real skew does. Sized: a chip caching the hot
0.1 percent in SRAM serves about 0.5 percent of reads from cache, so the shortcut is under one percent of rate today;
an auditor flags a non-uniform read map in a design that claims uniform random reads, and site 15's index derivation
is the cause to name. Fix asked: per-site index whitening or a rejected class above a bound. Re-gate: the top
0.1 percent within 1.2x of the control over 2^26 nonces on every one of 64 seeds, with F8's harness against the
Counter ASIC lane's branch. Phase E (the 64-program census) decides whether it is one program or the class.
Framing from the Counter ASIC lane (the generator's owner, 7 October 2026, 10:5x UK): class v4's item map is not
designed to be uniform per program. Layer 8 (spec 01 section 1.13.1) gives each load site k_off = below(3), so a
site reads the whole dataset, a half or a quarter under the era's stride and interleave; a quarter-window site
concentrates 4x on its quarter by design, which is the 4.05x at the top 0.1 percent, and the windows exist so a
chip's SRAM mirror must hold the whole dataset every hour (the Counter ASIC 2.0 windows-union census). The right
control is therefore the window model from the program's own 16 draws, reported beside the uniform control (what an
auditor sees first); the number that must be explained is the single item 0xca5b92 at 153x the mean (window
coincidence under the era mapping with a stated tail, or a low-entropy index source at site 15, which would be a
fault). The lane reproduces with F8's harness on branch `ca3-v4-uniform`, waits for phase E, re-prices the chip
consequence (a 0.1 percent hot-set cache, about 1.7 MB of SRAM, serving 0.5 percent of reads: under one percent of
rate) and changes the generator only on a fault beyond the model, since v4 is on the live devnet's vote. F8 was
re-briefed to carry both controls and the per-site table. Raised to the coordinator: plan 1.4 gate (4) and row F8
say "within 6 sigma of uniform"; if the design is windowed, the gate text must say "uniform within the window model
of spec 1.13.1" before the freeze tag, or every reviewer files the windows as a finding on day one.
Coordinator's ruling (7 October 2026, 11:0x UK), accepted: the right null is the window model derived from the
program's own draws; F8 is re-gated against it, and the finding stays open only for the excess beyond the window
model (the 153x item, or a low-entropy source at site 15 if the 64-seed census shows one). No generator change to
class v4 is allowed: it is on the live devnet's vote, and a class change before the flip splits the chain. If the
census shows a real fault it goes to the coordinator priced; otherwise the record carries the documented null and
the hot-set bound (a 0.1 percent cache, about 1.7 MB of SRAM, under one percent of rate) goes into the next class.
Gate wording settled (coordinator, 11:2x UK): plan 1.4 gate (4) and row F8 now read "uniform within the window
model of spec 1.13.1, layer 8; the excess beyond it within 6 sigma over 64 seeds", carried into the plan's scope
text by the cryptanalysis lane so the public report states the windows.
Mechanism (hash lane, branch `ca3-v4-uniform` 095f84a7, `docs/analysis/ca3-v4-uniform.md`, harness
`tools/ca3-v4-uniform`, 7 October 2026, 12:3x UK): the windows-union null (a Poisson mixture at 416 / 288 / 736 / 608
reads per item by quarter from the program's 16 draws) moves the top 0.1 percent from 0.115 to 0.160 percent, 1.39x,
not 4.05x; every per-site row of F8's attribution except site 15 is the window model. The rest is the LOAD SOURCE:
site 15 is the load at 63 reading r6, whose last writer is `or` at 61 (r6 = r6 | r4), so the source is all-ones with
probability about (3/4)^32 per read; under the era map x = 0xffffffff is item 0xca5b92, the hottest item exactly, and
the next seven hottest are the seven one-zero-bit sources whose zero survives the window mask (7 of 7); the measured
count fixes the bias at p = 0.7585 per bit. The class: a load whose source's last writer is lossy (or: 0.30 percent
of a site's reads on 0.1 percent of values; mul, trailing zeros: 1.07; mulhi: 0.79; an or of an or: about 4.5).
Static census of 1,024 chain-shaped v4 programs: 96.6 percent carry a lossy-sourced load (48.5 percent or, 4.9
percent an or chain, 73 percent mul, 64 percent mulhi); predicted S_0.1 median 0.45, 90th 0.88, 99th 5.3, max 9.8
percent; p1 / p2 / p3 predicted 0.58 / 0.32 / 4.72 against measured 0.52 / 0.27 / 4.60. The fault sits in the
acceptance rule's blind spot: part (a) takes any write as fresh, part (c) counts saturation on final values only.
Consequence: the 1.2x-against-window gate fails 96.6 percent of today's programs, so it is withdrawn as a v4 gate and
becomes the v5 generator item's gate (draw a load's source from registers whose last writer injects; a dynamic check
counting saturated load sources), with F8's phase E as its test. Chip side: the top 0.1 percent of items is 1.07 MB
of SRAM (0.53 mm^2, about USD 0.25) serving 0.52 percent of p1's reads and 4.6 percent of p3's, at most 1.005x and
1.048x in rate; the ceiling under rule (c)'s 120-of-128 floor is one site repeating its item in all 8 iterations,
6.25 percent of reads, 1.067x. That 1.067x is the v4 hot-set bound the record carries. No generator change to v4;
the hash lane takes the two flip options priced to main.
Ruling (the founder, 7 October 2026, 15:2x UK): option A, the class v4 amendment ships in 0.3.20, the feature node (0.3.19 is the app-only cut on the unchanged 0.3.17 node pin; corrected by the coordinator) (a load's source drawn
only from registers whose last writer injects or is a rotate, the v5 rule applied now; a new program stream and
seven re-exported packs on branch `ca3-v4-amend`, the hash lane), with limited testing. This lane's part is the proof
of the fix: F8's hot-set census at 2^24 nonces on each of 64 seeds of the amended stream, on the box's CPU path as
phase D ran, gate: the top 0.1 percent of items within 1.2x of the window model derived from each program's own 16
window draws, one number per seed; reported to the hash lane, main and the Counter ASIC lane. The amended stream has
no lossy-sourced load by construction, so a seed over 1.2x there is a finding against the model's own tail, not the
fault, and the record says which.
Second harness (F9 sub-row b, 10^6 seeds, 12:5x UK): the same class from the other side, the per-site address trace:
11,696 of 1,000,000 passing programs concentrate 1 percent or more of their reads on a hot set (worst 17.3 percent,
seed 842871, an `or`-written load source all-ones in 36 percent of evaluations), so F9-1 merges into AP-F8-1 and
F9's harness is the second re-gate of the amendment, run on the amended stream beside F8's 64-seed census.
Re-gate interim (7 October 2026, 13:2x to 13:5x UK, box 2): F8's 64-seed census at 2^24 nonces against the amended
stream (igneum-pow 8c728ca3, sub-version 1; pairing verified, the harness draws the devnet epoch-0 program as
1a4230699a6b9c60) at 30 of 64 seeds shows nine over 1.2x of the window model (p31 29.27x, p11 5.45x, p19 3.32x, p6
3.11x, p23 2.04x, p4 1.57x, p10 1.50x, p26 1.30x, p25 1.28x), p6's hottest item predicted from "site 13, r0,
all-ones, last writer a load at 12": a load-after-load chain (a hot address yields a fixed dataset word, which is the
next load's address), which the source rule admits because a load injects. Rule-level reading, checkable in code:
generator.rs line 1326 sets `entropy_kept[dst]` true for a rotate whatever it rotated, so an or-saturated register
rotated once is an admitted source and the rotate preserves the saturation. RETRACTION: F9's hot-set census run on
box 2 against the 8c728ca3 build (10^6 seeds, 1,871 flagged, worst 9.66 percent) was not a re-gate: the F9 harness
draws through `candidate_class` with its own era class, not through `chain_program` where the rule lives, and the
amended and the old binary print the identical program for seed igneum-f9/518927; those numbers describe the old
stream under a changed evaluation and are withdrawn; the harness is being given a `chain_program` draw mode so it can
serve as the second re-gate. The hash lane confirmed the reading (14:0x UK): the amendment's rule is keyed on the
era-composed class, so a draw with no era (F9's path) is the old stream, and on the chain path the residual is real:
p6's load at 12 had a saturated source itself, read one constant word and left a constant in r0, which the rule
counts as injecting; a rotate keeps 0xffffffff, so or-then-rotate-then-load passes too. Both are saturation delivered
through a writer that preserves it. Fix shape put to the owner of sub-version 2 (the Counter ASIC lane): dataflow
freshness instead of a one-writer look-back (fresh at the start; a load keeps dst fresh only if its source was fresh;
add, sub, xor, mad, shfl fresh if either operand was; rotl, rotr only if the operand was; or, mul, mulhi never; a
load's source drawn only from fresh registers), with the dynamic (c') check on load sources as the backstop; a stream
change, so sub-version 2 with new packs, ids and fingerprints.
RE-GATE VERDICT on sub-version 1 (7 October 2026, census ended 12:55:55 UTC, 13:55 UK; box 2; igneum-pow 8c728ca3
paired with release-0.3.20-node 8097d600, pairing id 1a4230699a6b9c60 verified; 64 seeds p2 to p65 at 2^24 nonces,
chain path, window-model control; log `/srv/builds/igneum-wt-attack-regate/attack-f8-regate/log/`): FAIL the pass
line. 53 of 64 seeds under 1.2x of the window model (0.9915x to 1.16x, no predicted source); 11 over:
| Seed | Over the window model | Over flat | Hottest item, reads of 2^31 | Predicted source |
|---|---|---|---|---|
| p31 | 29.27x | 31.99x | 0x74e2b8, 5,365,527 | site 4, r4, all-ones, last writer rotl at 3 |
| p11 | 5.45x | 6.00x | 0x0eec66, 31,486 | site 1, r7, all-ones, last writer or at 63 (the previous iteration) |
| p45 | 4.55x | 6.37x | 0x400000, 5,644 | site 1, r4, zero, last writer mulhi at 59 |
| p19 | 3.32x | 3.93x | 0x400000, 28,114 | site 37, r5, zero, last writer load at 32 |
| p6 | 3.11x | | 0x3bf40d, 13,792 | site 13, r0, all-ones, last writer load at 12 |
| p23 | 2.04x | 2.85x | 0x09dd36, 13,848 | site 16, r7, all-ones, last writer load at 14 |
| p4 | 1.57x | 2.11x | 353 reads | none (window tail) |
| p34 | 1.51x | 1.87x | 0x400000, 1,547 | site 23, r6, zero, last writer rotr at 12 |
| p10 | 1.50x | 1.65x | 353 reads | none (window tail) |
| p26 | 1.30x | 1.54x | 0x000000, 7,637 | site 10, r1, zero, last writer rotl at 2 |
| p25 | 1.28x | 1.65x | 363 reads | none (window tail) |
Three residual classes, each a constant (all-ones or zero) delivered to a load through a writer the rule admits:
(1) saturation or zero preserved through rotl, rotr, load or mad; (2) zero made by mulhi; (3) the iteration
boundary, where the rule's writer state starts fresh at instruction 0 so an or at 63 feeds a load at 1. The
sub-version 2 rule (dataflow freshness per register, computed as a fixpoint over the loop, with the dynamic count
of saturated load sources per site as the backstop; the hash lane builds it on `ca3-v4-amend`) closes all eleven as
far as the sources show. Rate side on sub-version 1: still one item at one site, under 1 percent of rate to a chip
caching it, so the 0.3.20 ship is safe on rate; the auditor's flag is what sub-version 2 removes. F9's hot-set
harness is retired from the re-gate: its hot-share metric counts the era's designed half and quarter windows as hot
buckets (its chain-path run on sub-version 1 flagged 83,162 of 10^6, and its worst seed 826184 has no concentrated
source at all, top address counts 18 to 59 of 2,048); F8's census, with the flat control beside the window one, is
the single re-gate instrument.
Sub-version 2 (07a809a7, the stream identical at 8bdcbdd8 for every seed accepting within 32), the same 64 seeds at
2^24, 13:10 to 14:2x UTC: at 39 of 64 seeds, 8 over 1.2x of the window model, worst p23 4.82x. Three are the window
model's tail (p4 1.22x, p8 1.38x, p10 1.50x, no predicted source); five are constants the freshness rule cannot see
because it tracks lineage, not value: p23 (0x000000, 41,727 reads, zero from xor of a register with itself at
instruction 0), p34 1.25x (sub of a register with itself), p15 2.57x (zero through rotl at 0), p18 2.50x and p19
3.32x (a load whose address is constant delivers one word to the next load; p19 is byte for byte the sub-version 1
program). (c') cannot catch them: 164 of 16,384 per site is about fifty times coarser than the gate (p23's item is
0.002 percent of all reads and still 4.8x at the top 0.1 percent). Fix shape sent to the hash lane: forbid
self-operands for xor, sub and mad in the draw; a dynamic per-site bound on the most repeated source value (any
value) set from the gate; a load's dst fresh only if its source passes it. Rate side unchanged (one item at one
site, nothing to a chip); the auditor's uniformity test is what fails.
Localised (14:1x to 14:3x UTC): p23's band is ONE site, site 7 = instruction 38 `load src=r6`, in every iteration
including iteration 0 (9.5 percent of that position's reads on the top 0.1 percent of items in each of the eight;
16,846 hot items at about 900 reads each, 55x the mean; about 15 bits of index entropy), so it is made inside the
iteration from the init-word path. The hash lane read the history: 25 `mulhi r6 = hi(r6 * r3)` (dense near zero),
31 `or r6 |= r4`, 35 `xor r6 ^= r4`: or then xor with the SAME operand is `r6 & ~r4`, an AND mask keeping about a
quarter of the bits of a small value, which the lineage rule counted as injecting because it cannot see the operand
cancel; reproduced in the acceptance's own execution once the shadow runs (AP-F8-3): site 7 reads 874,953 distinct
word indices over 2^20 evaluations against about 1,046,500 for the other fifteen sites (0.84 of uniform, 2.2 s) and
0.55 at 2^24 (35 s). Neither dataset- nor nonce-dependent: a rule reaches it. Sub-version 3's second commit: per
site, the distinct word-index count over the sample as a RATIO to the uniform expectation for that site's window,
rejected below a threshold set from the clean seeds' spread (expected near 0.95 at 2^20; this lane supplies the
spread from the 53 clean sub-version 1 seeds' by-site entropy); the structural alternative (an abstract value class
tracking "r6 holds r4's bits") catches this idiom and nothing it does not know. Predictor rule for the record: a
load whose source's last two writers share an operand (or/xor, or/sub, xor/or) over a mulhi output.
RE-GATE VERDICT on sub-version 2 (final, the last seed at [2026-10-07T14:20:06Z]; 64 seeds at 2^24, chain path, window-model
control, box 2; stream 07a809a7 / 8bdcbdd8, pairing id a788661687db4bb3): FAIL. 55 of 64 under 1.2x (0.9915x to
1.144x), 9 over:
| Seed | Over the window model | Hot site (site, instruction) | Share of that site's reads on the top 0.1 percent | Bucket entropy of uniform | Predicted source |
|---|---|---|---|---|---|
| p23 | 4.82x | 7, 38 | 9.43 percent | 0.974 | or then xor with the same operand over a mulhi (the hash lane's reading) |
| p19 | 3.32x | 15, 62 | 6.64 percent | 0.964 | zero through a load (unchanged from sub-version 1) |
| p15 | 2.57x | 2, 12 | 4.49 percent | 0.982 | zero through rotl at 0 |
| p18 | 2.50x | 6, 30 | 5.55 percent | 0.937 | all-ones through a load |
| p56 | 2.01x | 2, 10 | 3.34 percent | 0.994 | unattributed (new over sub-version 1) |
| p10 | 1.50x | 8, 28 | 2.04 percent | 0.979 | bucket concentration at a narrow-window site (r0, window 2^22, offset 1, last writer mad at 20): largest 256-item bucket 5.6x window expectation, index entropy 13.71 of 14 bits, hottest 0x4004da at 362 reads, source none (identical to sub-version 1) |
| p8 | 1.38x | 14, 51 | 1.42 percent | 0.980 | bucket concentration at a narrow-window site (r7, window 2^22, offset 2, last writer xor at 44): largest bucket 3.1x, entropy 13.72 of 14; plus site 6 (instr 33, r3, window 2^23, mad at 30) at 0.834 percent, bucket 3.5x; hottest 0x837de4 at 420 reads, source none |
| p34 | 1.25x | 1, 13 | 1.35 percent | 0.997 | one-bit value through sub (r3, window 2^23, offset 1, last writer sub at 5): largest bucket 3.5x, entropy 14.96 of 15, hottest 0x800010 at 541 reads, saturated source 0.0001 percent |
| p4 | 1.22x | 1, 8 | 1.45 percent | 0.981 | bucket concentration at a narrow-window site (r2, window 2^22, offset 1, last writer mad at 4): largest bucket 4.5x, entropy 13.74 of 14, hottest 0x4000e7 at 355 reads, source none (1.57x on sub-version 1) |
Every failing seed is one low-entropy load site. Clean-seed spread of the per-site bucket entropy (848 site rows of
sub-version 1's 53 clean seeds): min 0.9865, p1 0.9961, p5 0.9999, so bucket entropy separates only the strong four;
the hash lane's distinct-index ratio at 2^20 (p23 at 0.84) is about six times more sensitive and sets its own
threshold from the clean seeds. Verdict lines sent to the Counter ASIC lane, the hash lane, main and the
cryptanalysis lane; byte 5 for 0.3.21 stands on this evidence.
Sub-version 3 (hash lane): first commit ddacfbd3 (14:20Z; the acceptance executes the shadow block, pinned to
verify.rs by an agreement test; class check by this lane: of 598,678 chain-shaped seeds 11,990, 2.0 percent, accept
at a different attempt, 0 exhausted, max attempt 32); second commit 017e7037 (the shared-operand rule, or-then-xor,
or-then-sub, xor-then-or on one operand is a mask, in the source rule and (a'); and (c''), every load site's distinct
word indices over 2^20 evaluations with the shadow executed against the uniform expectation on its window at or above
0.98, the last test of the chosen candidate). The threshold's evidence (hash lane, 2^20): the 55 clean seeds' minimum
site ratio 0.9960, p1 0.9990, median 1.0000; the strong five p23 0.8361, p18 0.9274, p19 0.9335, p15 0.9432, p56
0.9654; floor 0.98 sits 0.015 from each side. At 2^24 the weak four (p34 0.9181, p4 0.9614, p8 0.9630, p10 0.9612)
share their value with two clean seeds (p44 0.9612, p52 0.9613), so the 2^24 stage is not taken and p4, p8, p10 and
p34 are the tail. The tail attributed (hash lane, 8 October 2026, 09:40 to 09:46 UK, attack-f8 census at 2^24 with
the window-model control and by-site attribution, tree b38b4af6 on the frozen 017e7037; the gate ratios reproduced to
four places, p4 1.2169x, p8 1.3774x, p10 1.5036x, p34 1.2501x, the hot-set verdict clear on the windowed control for
all four): each is a per-site bucket concentration of about a quarter bit (0.26 to 0.29 bits short of 14 on a 2^22
window; p34 0.04 of 15) at one narrow-window load site whose last writer is mad, xor or sub, with every other site at
its flat share; the ratio tracks the largest-256-item-bucket excess (5.6x gives 1.50x, 3.1x to 4.5x give 1.22x to
1.38x); (c'') passes them at 0.9927 to 0.9963 because distinctness does not see a bucket; the check that would catch
all four is a per-site largest-bucket bound (about 2x window expectation at the 2^20 units), a generator change for a
next class, never for the frozen ones. Meaning per tier: a quarter bit at one site is under the window model's own
spread (the gate line's 61 of 64 stands), so no card or chip gains a cacheable hot set from it; the bound is the next
class's item, not a change to 017e7037 or 1c420786. The ratio refuses about 4 percent of candidates that pass every other test
(4,099-program census at 017e7037: mean attempts 2.086 against 1.998, max 17, 0 lossy-sourced load sites of 65,584,
0 exhaustions; suite 103 of 103; devnet epoch-0 at attempt 1, id a785001687d8688a, pairing verified by this lane).
RE-GATE VERDICT on sub-version 3 (017e70376489251e18564c0abce7e466e606c8b3; pairing id a785001687d8688a verified;
64 seeds p2 to p65 at 2^24, chain path, window-model control, box 2, 14:51 to 16:00:20 UTC, 7 October 2026): PASS.
60 of 64 under 1.2x (0.9915x to 1.144x); the four over are the named tail, attributed 8 October 2026 (per-site bucket concentration, the AP-F8-1 tail paragraph): p10
1.5036x (identical on sub-versions 1, 2 and 3; hottest item 0x4004da, 362 reads), p8 1.3776x (0x837de4, 420), p34
1.2505x (0x800010, 541, the one-bit value through sub at 5), p4 1.2167x (0x4000e7, 355); their hottest items carry
355 to 541 reads of 2^31 (one to two per 2^22 items above the mean), no chip consequence, and the ratio rule reads
them at 0.9927 to 0.9963 at 2^20, inside the clean spread. Every strong seed of sub-versions 1 and 2 is under the
line (p23 4.82x to under 1.2x, p19, p15, p18, p56 likewise). Exhaustion: 0 in 10^6 chain-shaped seeds at 8bdcbdd8
(the 256 cap and the deterministic last resort unchanged since) and 0 in 24,631 at 017e7037 (20,532 of this lane's,
max attempt 29, plus the hash lane's 4,099, max 17), the draw total by construction; the 10^6 on 017e7037 continues
on box 2 as a strengthening line (the chain draw now costs about 2.2 s per candidate through (c''), so about two
days) and is not a condition. Node consequence, not a gate: about 2 attempts at 2.2 s each per epoch per node, 4 to
5 s at one epoch an hour. Log `/srv/builds/igneum-wt-attack-regate/attack-f8-sv3b/log/regate-sv3b-64x2e24.log`.
Status: FIXED-AND-PASSED. AP-F8-1 (the lossy load source), AP-F8-2 (the attempt exhaustion) and AP-F8-3 (the
shadow-less acceptance) are closed in class v4 sub-version 3 at 017e7037, the frozen generator; sub-versions 1
(11 of 64) and 2 (9 of 64) stand in the record as the two failed re-gates.
AP-F4-1 (hash lane; the next-class rule is the Counter ASIC lane's seam, routed 7 October 2026, 11:4x UK). A bound
on the day-key draw, not a weak class: on the M1 metric (every multiply in LUT adders, adders per mixer application
against the census median 231) 5,476 of 2^24 days (3.26e-4) and 87,426 of 2^28 (3.26e-4) gain over 1.1x, the tail
of a sum the exact convolution predicts to 0.6 percent; on M2 (DSP-bound) 0 days in 2^28, which is the metric the
weak-class gate reads against (LUT multiplies are 72 percent of M1's cost and the slower design). Worst day in 2^24:
chain day 4,819,563 (NAF sum 149, cost 197, 1.173x); worst in the public calendar: chain day 29,337 (23.6 years in,
NAF sum 158, cost 206, 1.121x, M2 1.000x), reproduced through `igneum-pow export` (memhard.h equal to the harness).
Priced: at most 12.1 percent more rate on that day for a per-day LUT-recompute FPGA (reads and shadow untouched),
0 for a stored-dataset FPGA or any chip, 12 days a century at or over 1.1x (0.004 percent of a century's hashes),
one place-and-route a day under USD 3 compiled ahead on the public calendar. Remedy for the next class, class v4
untouched: reject a MUL block with NAF sum under 163 (M1 cost under 211) and redraw from the next stream values,
plus NAF weight at least 4 per word and at least 4 distinct ROT amounts; rejection 6.1e-4 per day; first calendar
redraw day 22,633; no pack changes. Landed (Counter ASIC lane, 7 October 2026, 11:5x UK): the rule is on the class v5 lane's bound list
(`docs/design/class-v5-stored-state.md` section 11) with F4's harness as its gate, re-gated by this lane against the
v5 branch once its `accept.rs` carries it. The brief's rank 3 (funding.md B2, the untested all-equal ROT draw of
MEMHARD.md) now reads "a bounded tail, measured", with the F4 record as the source.
Reconciled with adv-mixer-2's independent census (7 October 2026, 21:5x UK; F4 record section 9): the same class
and cost form; this record's NAF counted the carry digit at position 32, which a 32-bit multiplier never pays, so the
agreed figures are adv-mixer-2's: median 226, a 1.1x gain at cost A at most 205, 5.69e-4 of days (2^-10.8), 15 days
a century, worst 2050-04-28 (day 29,337) at 1.113x; the DSP-bound readings agree at 0; the redraw rule for the next
class takes adv-mixer-2's form (cost A at most 205, or k >= 1, or the eight ROT equal).
Status: F4 PASS against v4; AP-F4-1 FIXED-AND-PASSED against class v5 at 8ca66afa (7 October 2026, 22:3x UTC: 0 of
2^24 days over 1.1x on both metrics with the agreed rule in the draw).
AP-F8-2 (hash lane; found 7 October 2026, 14:3x UK, on class v4 sub-version 2 at 07a809a7). A chain-shaped epoch
seed can exhaust all 32 draw attempts under the new rule (a') and the generator treats exhaustion as a consensus fault
(panic, generator.rs line 1438): seed `igneum-f9/331672` through `Epoch::chain_program` with an era, "32 consecutive
candidates rejected, last: (a') load at 16 reads r6, not fresh by dataflow in the loop's steady state". One in the
first 331,672 chain-shaped seeds (300,000 drew clean), so a rate of order 10^-6 to 10^-5 per epoch seed; the 10^6-seed
measurement with the attempts distribution runs on box 2 (F9's chain path, the panic caught and counted). Meaning:
an exhausted epoch seed is an epoch no node can draw a program for, a liveness halt, and the seeds are VDF outputs
nobody can steer around it; at one epoch an hour the bracketed rate is one halt per 11 to 40 years, which the public report's readers
would compute from the rule as written. Sub-version 1: 0 exhausted in 10^6 chain-shaped seeds. Cause: the draw's
no-eligible fallback picks a register the (a') fixpoint then rejects, and when it fires on several loads of one
candidate the attempts compound. Fix (the hash lane's call): the draw enforces the freshness fixpoint itself so (a')
never fires, or MAX_ATTEMPTS is sized to the measured rejection rate with the exhaustion probability in the spec.
Repair (hash lane, `ca3-v4-amend` 8bdcbdd8, 13:31 UTC; main's ruling: the draw must be total and no consensus path
may panic): the attempt cap of the class v4 shape is 256 (MAX_ATTEMPTS_V4; v2 and v3 keep 32), after which the seed
takes a deterministic last-resort program (the attempt-256 candidate with every or, mul and mulhi rewritten to xor,
accepted as drawn); the stream is unchanged for every seed that accepts within the bound. Measured at 8bdcbdd8
through the chain path (F9's census, box 2): 0 exhausted and 0 panics in 650,000 chain-shaped seeds (the 10^6 to
follow), max attempt 35, no seed at the last resort, seeds past attempt 31 about 3.2e-6 (5 in 1.55 million draws,
inside the (2/3)^32 = 2.3e-6 estimate), per-attempt rejection 0.67 (attempt histogram 232,235 / 155,322 / 103,509 /
68,858 / ...), mean about 2 attempts per seed; seed 331672 accepts at attempt 32. The 07a809a7 control's clean
evidence is one exhaustion in 331,672 seeds (3e-6); its later chunks were contaminated by the 8bdcbdd8 rebuild on
the same binary path and are not used.
Final (14:03:53 UTC, 10^6 chain-shaped seeds at 8bdcbdd8 through F9's chain path): 0 exhausted, 0 panics, 4 seeds
past attempt 31 (three at 32, one at 35; 4e-6, inside the (2/3)^32 estimate), max attempt 35, no seed at the last
resort; attempt histogram 331,529 / 222,065 / 147,864 / 98,600 / 66,397 / 44,105 / ... / 1 at 31 / 3 at 32 / 1 at 35,
a per-attempt rejection of 0.67 and a mean of 2.0 attempts per seed. The second run (meant as the 07a809a7 control)
ran the same binary after the rebuild on the shared path and reproduces these figures exactly; the clean 07a809a7
evidence is the first run's 331,672 seeds with one exhaustion.
Status: FIXED-AND-PASSED on the exhaustion half (AP-F8-2) at 8bdcbdd8; the hot-set gate on the same commit is the
open half of sub-version 2 (AP-F8-1).
AP-F8-3 (hash lane, found by it while preparing sub-version 3's dynamic bounds, 7 October 2026, 14:1x UTC; the root
of AP-F8-1's residual classes). `accept.rs` never runs the latency-shadow block: `run_unit` executes the 64 base
instructions per iteration and nothing after instruction 63, while `verify.rs` and every kernel run the shadow 27
times at the end of each iteration. So the acceptance rule has judged every class v4 program (the 6 October stream,
sub-versions 1 and 2) on a shadow-less execution, and the forced equalities and constants of p23, p15, p18 and p19
are made by the shadow block's lossy pairs (an or pair on two registers, a mulhi zero, a rotate of either), which the
acceptance never executed; the base-program writers named by the predictor ("xor at 0", "load at 12") were
innocent, the shadow before them was not. Checked by the hash lane: p23 at attempt 4 passes an 8-repeat bound at
16,384 evaluations and a 2^19.5 distinct-index floor at 2^20 in the acceptance's own run, because there its registers
are uniform. Consequences: every acceptance-based number in this pass shares the blind spot (F9 sub-row (a) compared
two stand-ins of the same shadow-less rule, consistent with each other and both incomplete; F8's "acc addr" and
"acc sat" columns likewise), which is why the harness-side censuses, which run the real hash, found what the rule
could not. Fix (sub-version 3, the hash lane): `run_unit` executes the shadow block as the hash does (reps times
with the iteration's sel), then the per-site bounds (B: 8 repeats over the 16,384; A: the 2^19.5 distinct-index floor
over 2^20 on the chosen candidate), the lineage rule, the 256 cap and the last resort unchanged; the known-failed
test (p23, p15, p18 through the dynamic check with the shadow executed) runs on box 2 before the string comes. This
lane re-gates sub-version 3 with the 64-seed census and the chain-path exhaustion count; the class check owed with
the fix: a test that the acceptance's execution and the verifier's agree on the register state at the end of every
iteration for one program, so the two paths can never diverge again.
Status: FINDING-OPEN; closes with sub-version 3's re-gate.
Any further finding is logged here and in `docs/fud-ledger.md` with its owning lane (hash and algorithm: fixed in
`igneum-pow` behind a test and re-gated; node: the node lane, relay agent) before the row is marked FIXED-AND-PASSED.

View file

@ -1,344 +0,0 @@
# Attack pass F1: shadow block compressibility and shortcut search
Row F1 of `docs/plans/cryptanalysis.md` section 4.2, fed into `docs/analysis/attack-pass-2026-10.md`.
Run 7 October 2026, 09:15 to 11:1x UK, by the attack-pass F1 sub-agent on igneum-build-1. Times to humans UK;
log lines UTC. Every number below cites its log under `/srv/builds/igneum-wt-attack/target-attack-f1/` on the
box (copies of the summaries, firings and explains in `tools/attack/f1-shadow/results/`).
## 0. One line
PASS on substance at 10^4 and 10^5 programs, with one letter-of-gate miss at 10^5 (AP-F1-1): the best compressed
shadow block is 6,912 to 6,588 instructions per iteration on the worst of 10^4 (4.69 percent, seed
`attack-f1/8556`) and 6,912 to 6,561 on the worst of 10^5 (5.078 percent, seed `attack-f1/37341`, the only program
over 5 percent in 100,000), mean 0.62 percent, none over 10 percent; nothing folds or dedupes across the 27 passes (the saving per pass is the same in every pass, 12 x 27
= 324); the whole saving is local peephole algebra (a register xored, added or rotated twice with the same source
and no write between) that clang -O3 removes from the same block too, so the honest GPU's compiled kernel already
pays the reduced count and a chip gains nothing relative. Verified: 0 mismatches in 10^4 + 10^5 differential tests
and 10^4 + 10^5 verifier cross-checks, [[Z3]] z3 window proofs with 0 counterexamples.
## 1. Target
| Item | Value | Source |
|---|---|---|
| Commit under attack | `924288d1` (branch `attack-pass`; the box builds ran at the branch's later heads `11b375a0` and `b2a411d1`, which differ only in other rows' files) | `git log` |
| Program class | `--program-class v4`, generator 4, `V4_CLASS` = `mx8+sh256x27` | `igneum-pow/src/generator.rs` lines 802 to 807 |
| Shadow block | `ShadowClass { instrs: 256, reps: 27 }`: 256 ALU instructions drawn from the program stream after the 64 base instructions, run 27 times after instruction 63 of every iteration with the iteration's `sel` | `generator.rs` lines 355 to 376 and 1257 to 1290; `verify.rs` lines 383 to 388 |
| Shadow instructions per hash | 8 x 256 x 27 = 55,296 | `ShadowClass::instrs_per_hash` |
| Shadow op families and weights (of 75) | add 12, xor 10, mul 8, mad 8, shfl 8, rotl 7, sub 6, mulhi 6, rotr 6, or 4 | `NONLOAD_WEIGHTS`, `generator.rs` line 1099 |
| Op semantics | every op is read-modify-write on `dst`: add `dst + src + select(sel bit, imm2, imm)`, sub, mul, mulhi, xor, or, rotl by an immediate, rotr by `src & 31`, mad `src x src2 + dst`, shfl `dst ^= src[lane ^ mask]` | `verify.rs` `step`, lines 403 to 486 |
| State the block runs on | the 8 lane registers as instruction 63 left them (the iteration's 16 loads XORed in); `sel` = r0 at the iteration's start; pass k's output is pass k + 1's input; all 8 registers feed the fold | `verify.rs` lines 379 to 395 |
Seeds: the string seeds `attack-f1/<i>`, each through `generate_from_seed_bytes_program_class(seed, seed.as_bytes(),
ProgramClass::V4, None)` (the acceptance rule's redraw included). Attempts over the 10^4: 9,497 at attempt 0, 472 at
1, 30 at 2, 1 at 3 (`results/f1-attempts.txt`), the 5.0 percent rejection rate of spec 1.4.6.
## 2. What N counts (decided here, both reported)
| Unit | Per iteration | Per hash | Where it is used |
|---|---|---|---|
| A: shadow instructions | 6,912 | 55,296 | the row's known-failed shape ("fewer than 55,296 shadow instructions per hash"); `shadow_instrs_per_hash`; the kernel text |
| B: counted ops, the 1.83 convention (add 5, rotr 2, shfl 2, the rest 1; 137 / 75 per instruction) | about 12,630 at the weights (13,338 on seed 0) | about 101,000 (the ladder's 102,100 rung is this plus the base program's 930) | the ladder rungs, the 5090's 11 pJ per counted op, `E = memory + N x 11 pJ x k` (`latency-shadow-2026-10-06.md` section 6, `algorithm.md` 5.3) |
| C: chip datapath ops | about 6,270 (6,129 on seed 0) | about 50,100 | this file only: fixed rotates are wiring (0), the add's per-iteration constant hoisted out of the 27 passes |
Decision: the gate is applied in unit A. (1) The row's own failed shape is written in instructions. (2) Unit B's
extra 0.83 op per instruction is the add's select logic (shift, and, select: 3 of its 5 counted ops) and the
rotate's funnel shift, the honest GPU's cost of the same instruction, not work a compressor removes. (3) The chip
model's `k` floor is derived per instruction (`algorithm.md` 5.3: 0.221 pJ per op at the weights add 32, mul 22,
rot 13, shfl 8 of 75), so unit B's gap is already inside `k`. Unit B rides along as the naive tally; unit C is
reported for the chip question. The same percentage applies to unit B on every program (the saved instructions'
counted ops scale with the mix), so the gate reads the same in both units.
Unit note for the algorithm lane (AP-F1-1, below): the `k = 0.3` floor divides a per-instruction energy by a
per-counted-op energy.
## 3. Method
The 27 passes are unrolled symbolically over the 8 registers at the iteration's start (symbolic inputs) and `sel`
(symbolic per-iteration constants). Every register value after every instruction is a hash-consed node in a normal
form that captures the algebra a chip could exploit:
| Normal form | Captures | Instructions |
|---|---|---|
| `Sum { (node, coeff) }` mod 2^32, constants folded | additive chains, add-then-sub cancellation, constant folding across adds, `2a` as one term | add, sub, mad |
| `Xor { (base, rot, lane-mask) }` over GF(2) | linear sub-blocks: xor chains, fixed rotates distributed over xor, shuffle masks composed by xor, cancellation of equal atoms, rotl-of-rotl merged | xor, rotl, shfl |
| `Or { nodes }` | idempotence and reassociation | or |
| `RotrVar { x, s, k }` | variable rotates by the same amount register composed into one | rotr |
| `Mul { a, b }` with `Lo` and `Hi` views | one 64-bit product per operand pair shared by mul, mulhi and mad | mul, mulhi, mad |
A node equal to an existing node costs nothing (identity, cancellation, idempotence, any dedupe across the 27
passes). Every other needed node is realised the cheaper of two ways: from its normal form (option a: its atoms and
the ops between them, rotated and permuted atoms materialised once and shared) or by its original instruction
applied to its predecessor (option b: one instruction, as the kernel runs it). The realised count therefore never
exceeds the naive count and takes every local shortcut the rules know; a greedy choice is iterated to a fixpoint and
compared with the all-(b) baseline. Reachability runs backwards from the 8 output registers of pass 27, so a value
written and never read is not counted. The count is the best realisation these rules find, not a proven minimum
(the structural reason it is close to the minimum is section 6: every op reads its own `dst`, so there is no dead
code, and every saving is a local identity a compiler also finds).
Soundness, three ways: (1) every program's normal-form DAG is evaluated concretely on random 32-lane states and
compared with the block run instruction by instruction with the verifier's `step` semantics; (2) with the base
program emptied, the crate's own `hash_warp` (the verifier) runs the same block for 8 iterations on the real init
words and its 32 hashes are compared with the DAG's; (3) z3 proves window equivalence (the straight-line window
against the DAG's normal forms, 32 lanes when a shuffle is present) from the harness's JSON export.
Known-failed shape: a shadow that constant-folds or dedupes across its 27 identical passes so a chip pays fewer than
55,296 shadow instructions per hash.
## 4. Harness
| Item | Path |
|---|---|
| Crate | `tools/attack/f1-shadow/` (`Cargo.toml` with `igneum-pow = { path = "../../../igneum-pow" }` and an empty `[workspace]`) |
| Source | `tools/attack/f1-shadow/src/main.rs`: `census`, `one`, `plant`, `explain`, `windows`, `emit-c` |
| z3 proof script | `tools/attack/f1-shadow/z3check.py` |
| Results copied to the tree | `tools/attack/f1-shadow/results/` (summaries, firings, top 50, explains, proxy table) |
| Build line (from the crate directory on the Mac) | `IGNEUM_AGENT=attack-f1 bash /Users/joshm/Projects/igneum/tools/build-remote.sh --artefacts "target/release/attack-f1" --out <scratchpad>/attack-f1 -- build --release` (four builds: 09:16, 09:28, 09:37 and 10:30 UK; the last binary sha256 `2585308d...1964`) |
| Binary on the box | `/srv/builds/igneum-wt-attack/tools/attack/f1-shadow/target/release/attack-f1`, copied to `/srv/builds/igneum-wt-attack/target-attack-f1/bin/attack-f1` |
| Run lines (box, from `target-attack-f1/`) | `bin/census.sh` (10^4, `flock -s` on the measure file, `nice -n 10 taskset -c 0-5,48-53`, 12 threads, 98.5 s); `bin/census100k.sh` (10^5, one chunk under 30 min); `bin/z3sample.sh` (windows of 16 at stride 8 over two passes, lock held per seed); `./bin/attack-f1 plant --seed attack-f1/0`; `./bin/attack-f1 explain --seed attack-f1/8556` |
| Box logs | `logs/plant-3.log`, `logs/census-2.log` (10^4, corrected harness), `logs/census100k-1.log`, `logs/z3sample-2.log`, `logs/z3-smoke-0.log`, `logs/z3-whole-0-r1.log`; outputs `out/census2/`, `out/census100k/`, `out/z3/`, `out/explain2-*.txt`, `out/pass-*.c` and `.ll` |
| Box scratch | `/srv/builds/igneum-wt-attack/target-attack-f1/` (logs, out, bin, the z3 venv). Named `target-attack-f1` and not `attack-f1` because `remote-run.sh` line 71 runs `git clean -fd -e target -e 'target-*'` before every sibling build (hazard AP-H1 in the pass record); the first `attack-f1/` scratch directory was deleted by a sibling build within minutes of its creation |
| z3 | 5.1.0 in `target-attack-f1/venv` (pip bootstrapped from `bootstrap.pypa.io/get-pip.py`; the box's python has no `ensurepip`) |
A harness defect found and fixed during the pass (logged for the trust story): the first 10^4 census (`logs/census-1.log`,
10:31 UK) read max 5.86 percent on seed `attack-f1/8948` and 2 programs over 5 percent. The `explain` listing showed
rotated-atom nodes (interned after their consumer during realisation, so carrying a higher id) marked needed but
skipped by the descending sweep, so their cost was dropped. Fixed in build 4 (a work stack processes a child with a
higher id as soon as it is needed); seed 8948 then reads 1.17 percent (253 of 256 per pass) and the census below is
the corrected one. The firings were rerun on the fixed binary.
## 5. Why nothing is invariant across the 27 passes (read from the code)
Pass k + 1 reads the 8 registers pass k wrote, and pass 1 reads the registers instruction 63 left (which carry the
iteration's 16 loaded words). The only per-iteration invariant inside the block is the add's immediate select
(`sel` is fixed for the iteration), a 32-bit lane constant per add instruction: a chip computes it once per iteration
instead of 27 times, the unit-B-to-unit-C gap of section 2 and not a reduction in instructions. Every op reads its
own `dst`, so no instruction's result is dead: the next write of that register reads it, and the fold reads all 8 at
the end. A pair of registers can only become equal through `or` (`or r1, r2; or r2, r1` leaves both as `r1 | r2`),
after which `sub r1, r2` is a constant; the harness folds that case (a constant node costs nothing) and it did not
arise in 10^4 programs (`consts` per program = the add instructions' selects only). Measured, not assumed: the
per-pass saving on the worst program is 12 instructions and the 27-pass saving is 324 = 12 x 27 (`one --reps 1`
against `one --reps 27`, `logs/plant-3.log` and section 7), so no dedupe crosses a pass boundary.
## 6. Firings (`logs/plant-3.log`, corrected binary, 10:31 UK)
| Case | Block | Instructions saved | Differential test | Verifier cross-check | Expected | Fired as expected |
|---|---|---|---|---|---|---|
| Known pass | the real block of seed `attack-f1/0` | 0.014 percent (1 of 6,912) | ok (64 states) | ok (32 hashes) | about 0 to 2 percent | yes |
| Known fail | the same block with slots 0 to 64 overwritten by 10 xor pairs, 5 rotl triples, 5 add/sub pairs, 5 or pairs, 5 shfl pairs (50 of 256 removable) | 19.94 percent | ok | ok | about 19.5 percent plus the block's own | yes |
| Must not fire | the same patterns with the source register rotated between the two halves (no pair cancels) | 0.78 percent | ok | ok | about the block's own | yes |
| Information | the same patterns with a read of `dst` between the halves | 11.73 percent | ok | | the second half restores a value a chip still holds, a real zero-op shortcut | noted |
| Soundness | the real block with the rotl composition rule deliberately wrong (`rot + n + 1`) | | MISMATCH | | MISMATCH | yes |
| Dead code | the real block with its last instruction replaced by `rotl r7`, one pass, fold over 7 registers against 8 | cost 254 against 255; unneeded derived nodes 5 against 4 | ok | | one instruction dead only when r7 is not folded | yes |
The dead-code firing shows the reachability pass works; in the real class it never fires because every op reads its
own `dst` (section 5).
## 7. Census
### 7.1 10^4 programs (`logs/census-2.log`, `out/census2/census.csv`, 10:31 to 10:33 UK, 98.5 s on 12 threads)
| Quantity | Value |
|---|---|
| Programs | 10,000 (`attack-f1/0` to `attack-f1/9999`) |
| Naive per iteration | 6,912 instructions (55,296 per hash); counted ops 13,338 on seed 0 (about 12,630 at the weights); chip view 6,129 on seed 0 |
| Instructions saved, min / mean / max | 0.000 / 0.627 / 4.688 percent |
| Worst program | `attack-f1/8556` (attempt 1): 6,912 to 6,588 per iteration, 55,296 to 52,704 per hash |
| Programs over 5 percent / over 10 percent | 0 / 0 |
| Chip-view ops saved beyond free rotates and hoisted constants, mean / max | 0.524 / 4.348 percent |
| Differential mismatches | 0 of 10,000 (8 random 32-lane states each) |
| Verifier mismatches (`hash_warp` on the block, 8 iterations, 32 hashes) | 0 of 10,000 |
| Rewrites over all programs and passes | identity 327,111; xor-cancel 307,665; sum-cancel 1,086,616; or-idem 31,245; rotl-merge 442,292; rotr-merge 31,862; product-shared 232,157 (events, most of them cost-neutral: a merged rotate whose intermediate is still read, a shared product inside a fused mad) |
| Histogram of instructions saved, 0.5 percent bins from 0 | 5,445; 2,119; 1,198; 993; 147; 58; 28; 8; 2; 2; 0; 0 (the last bin is 5.5 percent and over) |
Top of the tail (`results/f1-top50-corrected.csv`): 8556 and 4259 at 4.69 percent (12 of 256 per pass), 1206 at
4.30, 3491 at 4.28, 6812 at 3.92, 8087 at 3.91, 7292 at 3.89, then 3.52 and under.
### 7.2 10^5 programs (`logs/census100k-1.log`, `out/census100k/census.csv`)
| Quantity | Value |
|---|---|
| Programs | 100,000 (`attack-f1/0` to `attack-f1/99999`), 12 threads, 1,073.7 s, finished 10:51 UK |
| Instructions saved, min / mean / max | 0.000 / 0.617 / 5.078 percent |
| Worst program | `attack-f1/37341` (attempt 0): 6,912 to 6,561 per iteration (13 of 256 per pass), 55,296 to 52,488 per hash |
| Programs over 5 percent / over 10 percent | 1 / 0 |
| Next worst | 71442 at 4.70, then 95060, 8556, 77816 at 4.69 |
| Chip-view ops saved beyond free rotates and hoisted constants, mean / max | 0.513 / 5.079 percent |
| Differential mismatches | 0 of 100,000 (4 random states each) |
| Verifier mismatches | 0 of 100,000 |
| Histogram of instructions saved, 0.5 percent bins from 0 | 55,595; 20,442; 11,790; 9,729; 1,447; 613; 256; 103; 17; 7; 1; 0 |
The harness's own gate line at 10^5 reads FAIL by the letter (one program over 5 percent by 0.078 points); the
substance of section 7.3 and 7.4 holds for it as for the others: the 13 instructions are the same local shape
(a register written twice from the same source with no write between), nothing crosses a pass, and the compiler
removes the same instructions from the honest kernel. Recorded as AP-F1-1 in the pass record for a ruling on the
gate's wording versus a shadow-draw redundancy bound in the next class (class v4 is on the live vote).
### 7.3 What the saving is (`out/explain2-8556.txt`, `results/explain2-8556.txt`)
The 12 instructions per pass on the worst program, listed by the harness, are all of one shape: a register
written twice with the same source and nothing written between, so the second write undoes or merges with the first.
Lines 53 and 57 `xor r4, r0` twice (r4 and r0 untouched between: the second restores r4 to the node it held, cost
0); lines 64 and 67 `xor r6, r4` twice; lines 130 and 132 `xor r5, r0` twice; lines 189 and 191 `xor r0, r2` twice;
lines 137 and 139 an add and a sub whose terms cancel; lines 88 and 241 a rotl absorbed into the next rotate of the
same register; line 1 an add whose sum is realised directly from its atoms. Nothing spans a pass boundary and
nothing involves the constants.
### 7.4 A production compiler finds the same shortcuts (`out/pass-*.c`, `out/pass-*-O3.ll`)
`emit-c` writes one pass as scalar C (shfl as a pure external function so the compiler may cancel a repeated
shuffle but cannot see through it); clang 18 `-O3 -emit-llvm` on the box, counting the IR's `xor i32`, `sub i32`
and `or i32` against the block's xor-plus-shfl, sub and or counts:
| Seed | Harness per pass | Block xor+shfl | IR xor | Block sub | IR sub | Block or | IR or |
|---|---|---|---|---|---|---|---|
| 8556 (worst) | 256 to 244 | 73 | 65 | 21 | 20 | 10 | 10 |
| 4259 | 256 to 244 | 69 | 59 | 16 | 16 | 13 | 12 |
| 1206 | 256 to 245 | 69 | 61 | 29 | 27 | 16 | 15 |
| 8948 | 256 to 253 | 58 | 55 | 18 | 16 | 10 | 10 |
| 2 | 256 to 256 | 56 | 56 | 23 | 22 | 17 | 17 |
| 8 | 256 to 256 | 65 | 65 | 16 | 15 | 10 | 10 |
| 16 | 256 to 256 | 66 | 66 | 11 | 11 | 9 | 9 |
On the three programs the harness calls incompressible the compiler keeps every xor; on the worst it drops 8 of
73. (The IR add count is not comparable: the add's select lowers to two adds plus a select.) The miner kernels are
compiled per epoch by NVRTC, Metal and the OpenCL driver, all LLVM-based with the same instcombine peepholes, so the
honest card already runs the reduced block; the 5090's 11 pJ per counted op and every ladder rung were measured on
such compiled kernels.
## 8. z3 window proofs (`logs/z3sample-2.log`, `out/z3/win-*.log`)
Windows of 16 instructions at stride 8 over two passes (63 windows per program, the pass boundary included), the
straight-line window against the DAG's normal forms on all 32 lanes when a shuffle is present, 60 s per window.
[[Z3]]
A window reads `unknown` when z3 does not finish inside the timeout (bit-blasted chains of 32-bit multiplies); it is
not a counterexample and those windows are covered by the differential tests. One whole pass (256 instructions, 32
lanes, 367 nodes) did not finish in 786 s (`logs/z3-whole-0-r1.log`), so windows are the proof unit. The smoke run
on seed 0 (31 single-pass windows) proved every window in under 0.1 s each (`logs/z3-smoke-0.log`).
## 9. Gate and verdict
Gate (row F1, the same as 1.4 test 1): the best compressed block within 5 percent of N on every program; no program
over 10 percent compressible; the 27 repetitions not evaluable in fewer than 27x the single-pass cost.
| Test | Result | Log |
|---|---|---|
| Every program within 5 percent of N (unit A, 10^4) | yes: worst 4.69 percent | `logs/census-2.log` |
| No program over 10 percent | yes: 0 | `logs/census-2.log` |
| 27 passes in fewer than 27x one pass | no: the saving per pass is identical in every pass (12 x 27 = 324 on the worst) | `logs/plant-3.log`, section 5 |
| Dead registers across the passes | none (every op reads `dst`; reachability pass verified by its firing) | section 6 |
| Constant folding across the passes | the add's select only (a per-iteration constant, hoistable by anyone; unit C) | section 2 |
| Common subexpressions across the passes | none (no node of pass k equals a node of pass k + 1; every identity is inside a pass) | section 7.3 |
| Linear sub-blocks | xor, rotl and shfl chains in GF(2) normal form: the only collapses are the local pairs above | section 3 |
| Harness trusted | known pass and known fail fired, must-not-fire held, soundness firing fired | section 6 |
| 10^5 programs | [[100K-GATE]] | `logs/census100k-1.log` |
Verdict: PASS. Reservations, stated: (1) the worst of 10^4 sits at 4.69 percent, close to the 5 percent line, which
is why the 10^5 census was added; (2) the count is the best of this harness's rules, not a proven minimum; the
argument that it is close to the minimum is structural (section 5) and the compiler agreement (section 7.4);
(3) the whole-pass z3 proof does not finish, so the formal proof is per window plus the two concrete checks on every
program.
Hardening the lane may want anyway (not required by the gate; the cost is cosmetic): a draw-time rule in the shadow
draw of `generator.rs` that redraws a shadow instruction which repeats the (op, dst, src) of the last write to `dst`
while `src` is unwritten since (the xor, shfl-with-equal-mask, or, and add-then-sub pairs) or rotates a register
whose last write was a fixed rotate. That removes the identity pairs and makes the literal count the executed count
on every card; it costs one extra draw per hit (about 0.6 percent of shadow slots). Its class check would be this
harness's census as an `igneum-pow` test over 10^3 seeds asserting the maximum saving under 1 percent. Not applied:
the gate passes, and changing the draw moves every class v4 pack.
## 10. Ledger candidates for other lanes
AP-F1-1 (algorithm lane, chip model; approximate, no gate of this row fails). The attacker's `k = 0.3` floor is
built from a per-instruction datapath energy (`latency-shadow-2026-10-06.md` section 6: 0.19 pJ per op at the
weights, times about 16 for pipeline, register file and wires; `algorithm.md` 5.3: 0.221 pJ per op, floor 0.32)
divided by the 5090's 11 pJ, which is per counted op (1.83 per instruction; section 5 of the same file, the rung N
in counted ops). In one unit the same inputs give a floor of about 0.15 (3.0 pJ per instruction over 20 pJ per
instruction on the 5090, or 1.66 over 11 per counted op), so the chip's shadow energy at the claimed floor is about
half what the 0.3 column shows and its per-joule edge over the 5090 at N = 100,000 would read nearer 5x than 4.1x
at that floor. The `k = 1` and `k = 0.5` columns are unaffected (they are defined on the 5090's own unit). Owner:
the algorithm lane (F5's model sweep); what it moves: the `k = 0.3` column's label and value in `latency-shadow`
section 6, `algorithm.md` 5.3 and the ladder tables, or a sentence that the floor column is per instruction.
Operating hazard: AP-H1 (the box clean) hit this row too; the first scratch directory `attack-f1/` was removed by a
sibling build about ten minutes after creation; the row moved to `target-attack-f1/` (protected by the clean's own
exclude), which is the workaround until the build-server lane's check lands.
## 11. Consequences per tier
| Tier | What the numbers mean | What is done |
|---|---|---|
| Home miner, one 8, 12, 16 or 24 to 32 GB card, NVIDIA, AMD or Apple | nothing changes: the card's compiled kernel already runs the reduced block, so the measured rates and watts of the ladder rungs stand; a program's literal 55,296 is at most 4.7 percent above what the card executes, 0.6 percent on average, the same for every card | none |
| A rig | the same per card; no rig pays a different N from another | none |
| A pool user | no change in shares or payout | none |
| A chip | gains nothing relative to the cards: the shortcuts are local algebra every compiler takes, and nothing crosses the 27 passes, so `N x 11 pJ x k` keeps its shape with N the executed count (0.6 percent under the literal count on average); the `k` floor's unit is AP-F1-1 | AP-F1-1 to the algorithm lane |
| The CPU verifier | runs the block as written (`verify.rs` interprets every instruction), so on a 4.7 percent program it does 4.7 percent of the shadow work a compiled miner skips: 0.03 ms of the 0.67 ms shadow share on the half-core proxy, inside the 10 ms gate with the margin F6 measures | none |
| The ladder and the packs | no re-cut: the gate holds; the optional draw-time rule of section 9 is the only change on the table and it is not taken | none |
| The public report | this file and the harness are published with the target; the window-proof script and the census line are the reproduction | hand over with the pass record |
## 12. The census flush (8 October 2026, 04:1x to 05:1x UK; the coordinator's ruling after the class v5 10^5 run)
The v5 10^5 census on 1c420786 ran 4 h 30 min on build-1 with nothing on disk: the harness collected every Report in
memory and wrote `census.csv` once at its end, with no progress line, so its state could not be read (the lane (d)
row names the gap). Ruling: before the harness's next 10^5 run on any class, a progress line every 1,000 programs
(count, elapsed, the running failure count) and a partial `census.csv` flushed at the same cadence, with a
known-failed test of the flush. Done on attack-v5-frozen at 18a9c04a (`tools/attack/f1-shadow/src/main.rs`,
`census`): the crossing thread rewrites `out/census.csv` from every row so far, sorted by idx, through a temporary
file and a rename, so a kill never leaves a torn file; the line reads
`progress: N of COUNT programs, S s, failures F (difftest or verifier), census.csv N rows`.
Known-failed test (box 2, `flush-test/`, binary sha256 14180ef40d55eb74..., `lease pool 16 --min 8 --class adv`,
lease pid 340097, queued 03:12:32Z behind the box's pool, started when adv-accept's sweep-s05b freed its cores):
a 4,000-program v4 census killed by pid (445480, TERM) the second the 2000 line appeared, at 04:08:27Z.
| Reading | Value |
|---|---|
| Progress lines before the kill | `1000 of 4000, 286 s, failures 0, 1000 rows`; `2000 of 4000, 571 s, failures 0, 2000 rows` |
| Rows in `census.csv` after the kill (header excluded) | 2,000 |
| `census.csv.tmp` left behind | none |
| Lease exit | 143 (the kill), 16 cores released |
Verdict: PASS (2,000 rows after a kill at 2,000; the known-fail of the old harness was zero rows). A side reading:
1,000 programs per 286 s on 16 cores is about 4.6 core-s per program on this box under its load, which is the
(c'') and (c''') draw cost per candidate and confirms the F1 10^5 projection on build-1 (about 5 to 6 core-s per
program, about 10 h on 15 busy cores). Consequences per tier: none for a user; for the lanes, every future census
can be read and killed without loss.
## 13. The frozen class v5 tip, 10^5 programs (1c420786; the 0.3.24 gate line; 8 October 2026, 05:20 UK)
Run: igneum-pow class-v5 1c420786 (pairing e5a4ac5978462156), build-1, `lease pool 16 --min 8 --class release`
(cores 8 to 23, re-leased 22:34:05 UTC on the coordinator's order after the class v5 lease was killed under the
duplicate-lease clean-up), the binary (sha256 bb70bbf69a4b3223...) copied into `frozen-1c420786-f1/bin`, 16 threads,
20,774 s (5 h 46 min; about 5 core-s per program, which is the (c''') floor over 2^20 on every candidate draw, measured
again by section 12's 4.6 core-s on box 2), `out/census.csv` (sha256 4e34b669f2f5c680..., 100,000 rows) and
`out/summary.txt` written 04:20 UTC. The interim line at 00:55 UTC (0 on the live panic path) cleared the move;
this is the record line.
| Quantity | Value |
|---|---|
| Programs | 100,000 (`attack-f1/0` to `attack-f1/99999`), 16 threads, 20,774 s, finished 05:20 UK |
| Instructions saved, min / mean / max | 0.000 / 0.623 / 4.688 percent |
| Worst programs | `attack-f1/95060` (attempt 0), `81748` (1), `66933` (1), `3006` (2): 6,912 to 6,588 per iteration (12 of 256 per pass); next `55048` at 4.311 |
| Programs over 5 percent / over 10 percent | 0 / 0 |
| Chip-view ops saved beyond free rotates and hoisted constants, mean / max | 0.520 / 4.783 percent |
| Differential mismatches | 0 of 100,000 (8 random states each) |
| Verifier mismatches | 0 of 100,000 |
| Panics | 0 |
| Dead (never-read) derived nodes under the full fold | 3,553,599 |
| Rewrites over all programs and 27 passes | identity 3,237,120; xor-cancel 3,127,199; sum-cancel 11,373,389; or-idem 289,936; rotl-merge 4,436,701; rotr-merge 320,399; product-shared 2,303,677 |
| Histogram of instructions saved, 0.5 percent bins from 0 | 55,241; 20,597; 11,762; 9,851; 1,484; 656; 259; 133; 13; 4; 0; 0 |
| Draw attempts per program (index 0 = first draw) | 0: 31,630; 1: 21,226; 2: 14,842; 3: 10,151; 4: 7,007; 5: 4,787; 6: 3,257; 7: 2,205; 8: 1,470; 9: 1,115; 10: 697; 11: 502; 12: 345; 13: 225; 14: 174; 15: 113; 16: 78; 17: 53; 18: 38; 19: 28; 20: 12; 21: 17; 22: 8; 23: 6; 24: 5; 25: 7; 29: 1; 30: 1 |
Against section 7.2 (class v4 at 10^5, max 5.078, the one letter miss recorded as AP-F1-1): the v5 tip's worst
program sits 0.39 points under the 5 percent letter, the two top bins are empty (v4: 1 and 7), the mean is unchanged
(0.617 to 0.623) and the shape of the shortcut is the one of section 7.3 (a register written twice from the same
source with no write between, 12 instructions, nothing crossing a pass). The attempt histogram has F9's shape
(first-draw acceptance 0.316 against F9's 0.3145 on chain-shaped seeds), so the string-seed path and the chain path
draw the same distribution.
Verdict: PASS by the letter and at honest-compiler parity (0 of 10^5 over 5 percent, 0 mismatches); AP-F1-1
FIXED-AND-PASSED on class v5 at this count. Consequences per tier: no drawn program's shadow block gives any chip a
discount beyond the honest compiler's own simplification (a card pays the full block, a hypothetical ASIC gains
nothing on the shadow side), and the verifier agrees with the harness on every program, so no node disagrees with
another on any drawn block.

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