Compare commits
3 commits
master
...
ca3-v4-ame
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
063829873a | ||
|
|
4ba394fa93 | ||
|
|
450518f95b |
1933 changed files with 5487 additions and 465002 deletions
|
|
@ -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. -->
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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`);
|
||||
}
|
||||
|
|
@ -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')));
|
||||
});
|
||||
490
app/igneum-common/Cargo.lock
generated
490
app/igneum-common/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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" };
|
||||
|
|
@ -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"), "");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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('&', "&").replace('<', "<")))
|
||||
.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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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}") })
|
||||
}
|
||||
5207
app/igneum-wallet/Cargo.lock
generated
5207
app/igneum-wallet/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
@ -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).
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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, ¶ms);
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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">×</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 · 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>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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 |
|
|
@ -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;
|
||||
|
|
@ -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' });
|
||||
});
|
||||
|
|
@ -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.');
|
||||
});
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))));
|
||||
8
contracts/bridge/test/vectors/.gitignore
vendored
8
contracts/bridge/test/vectors/.gitignore
vendored
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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); }
|
||||
|
|
@ -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"
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))));
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
# Binding review: template, nonce, work and result (October 2026)
|
||||
|
||||
Igneum 2.0 register row (plan p. 10, missed pins): "Cryptographic review of the template, nonce, work and result
|
||||
binding: no expensive intermediate reused across cheap winning attempts." Adversarial seat, 8 October 2026 (evening,
|
||||
UK), with the hash lane for the generator's side. Sources: the crate (`igneum-pow` at the mirror's master 494fb981),
|
||||
the fork's node line (`release-2.0.0-node` 9fc9f42a on the box mirror; the Mac's vendor copy is the 7 October master
|
||||
and is not used where the two differ), spec 01, 02, 04 and 09, and the attack pass of 7 to 8 October
|
||||
(`docs/analysis/attack-pass-2026-10.md`, rows F3, F8, F9, F1 and their records). Every number is either from a
|
||||
record named beside it or marked Designed. No consensus code is changed by this document.
|
||||
|
||||
The question in one line: for every quantity that is expensive to compute, which inputs is it a function of, and
|
||||
can a miner hold it fixed while it varies something cheap that still produces distinct winning attempts? A "winning
|
||||
attempt" is a 64-bit lane hash at or below the target for a header the chain accepts.
|
||||
|
||||
## 1. What the hash commits to, from the code
|
||||
|
||||
### 1.1 The chain of bindings
|
||||
|
||||
| Step | Function of | Where | Cost |
|
||||
|---|---|---|---|
|
||||
| The pre-PoW hash `H` | every header field but the nonce: version, parents (every level), `hash_merkle_root`, `accepted_id_merkle_root`, `utxo_commitment`, timestamp, bits, `daa_score`, `blue_score`, `blue_work`, `pruning_point`, `vote_key_hash`; the nonce field zeroed | fork `consensus/core/src/hashing/header.rs:7` (`hash_override_nonce_time(header, 0, header.timestamp)`), `consensus/pow/src/igneum.rs:115` (`header_prehash`) | one BLAKE2b over about 200 bytes per template |
|
||||
| The init words `I[0..7]` | `seed_words_from_bytes("igneum-block/" || H || nonce_hi_le32)` | `igneum-pow/src/bind.rs:48` (`block_init_bytes`, 49 bytes), `:57` | four FNV-1a-64 passes over 49 bytes per `(H, nonce_hi)`: about 200 integer ops |
|
||||
| The lane registers at start | `r[i] = splitmix32((n XOR I[i]) + 0x9e3779b9 (i + 1)) XOR I[(i + 1) AND 7]` for lane nonce `n` (the low 32 bits of the header nonce) | `verify.rs:570` (`interpret_warp_core`); spec 1.6 | 8 splitmix32 per lane |
|
||||
| The program | the epoch seed: on the node line, the hash of the last selected-chain block below `epoch x 3,600 - 600` on the header's own selected-parent chain (`pow_epoch_seed_score`, `epoch_seed`), attempt by attempt through acceptance (spec 1.4.6.6); the era seed from the era VDF (spec 4.4) | fork `consensus/core/src/igneum.rs:343`, `consensus/src/pipeline/header_processor/processor.rs:365` (release-2.0.0-node); `generator.rs`, `accept.rs` | once per epoch per node: the draw plus acceptance, about 1.8 core-s per candidate at (c'') and (c''') (attack pass, F9 lane (d)), 3.2 candidates per seed |
|
||||
| The dataset | the day key `seed_words_from_bytes("igneum-day/" || day_le64)` with `day = header.timestamp_ms / 86,400,000` (class v5 adds the epoch's state leaves) | `bind.rs:62`, fork `pre_ghostdag_validation.rs:147` (`day: day_index(header.timestamp)`); spec 1.8 | once per day per node: the 256 MiB cache fill (175 ms on one core) and, for a miner, the 1 to 2 GiB dataset |
|
||||
| One lane hash | the program applied to the 32 lanes of the aligned group `n AND NOT 31`, 8 iterations of 64 base instructions plus the 256-instruction shadow block 27 times, 16 loads per iteration reading `dataset[load_index(x)]`, the output fold over `r0..r7` | `verify.rs:555` to `:654` | 55,296 instructions and 128 dependent dataset reads per lane; the warp is the unit |
|
||||
| The verdict | `hash_bound(H, nonce) <= target64(bits)`, `target64` the top 64 bits of the 256-bit target | `bind.rs:128`, `igneum.rs:120`, `:173` | one 64-bit compare |
|
||||
|
||||
So a winning attempt is a function of `(H, nonce_hi, n, program(e), dataset(day), target)`, and every one of `H`,
|
||||
`e` (through `daa_score` and the parents, both in `H`) and `day` (through the timestamp, in `H`) is under proof of
|
||||
work. The expensive quantities are three: the program (per epoch), the dataset (per day), and the hash itself (per
|
||||
warp). The cheap variables are the nonce's two halves and anything in the template the miner may change.
|
||||
|
||||
### 1.2 What is in the template that a miner may change, and what each change costs
|
||||
|
||||
| Field | Who sets it | Freedom | What changes downstream |
|
||||
|---|---|---|---|
|
||||
| nonce, low 32 bits (`n`) | the worker | 2^32 per `I`, iterated 32 at a time | the lane registers only; the warp's 32 lanes are hashed together |
|
||||
| nonce, high 32 bits | the miner (`main.rs:565`, a random `hi`, incremented when the lane space wraps) | 2^32 per template | `I`, hence every register of every lane: a new warp space |
|
||||
| timestamp | the miner, within Kaspa's bounds (10 s future tolerance, above the sampled past median) | about 10 s of milliseconds, 10,000 values | `H`, hence `I`; and the DAY at a day boundary (1.4) |
|
||||
| the coinbase and transactions | the miner (mode A or C) or the pool | unbounded | `hash_merkle_root`, hence `H` |
|
||||
| parents | the miner, among known tips within the merge bound | a few | `H`, `daa_score`, `blue_work`, `blue_score`, and the epoch seed if the selected parent's chain differs below the seed score |
|
||||
| `vote_key_hash` | the member's client (spec 9.4.1 item 1) | one per key the miner holds | `H` |
|
||||
| bits, `daa_score`, `blue_score`, `blue_work`, `pruning_point`, `accepted_id_merkle_root`, `utxo_commitment` | the node from the parents | none | |
|
||||
|
||||
Every one of these is an "extra nonce": it changes `H`, so `I`, so every register from the first instruction. None
|
||||
of them is free of the hash: a template change costs the whole warp again.
|
||||
|
||||
## 2. The nonce's place in the chain and the warp as the unit
|
||||
|
||||
The lane nonce enters once, at register initialisation, XORed with each init word before splitmix32 (`verify.rs:570`).
|
||||
Nothing later reads the nonce. So the hash of lane `n` is the composition `fold(program^8(init(n, I)))` and the only
|
||||
state shared across lanes is what `shfl` moves (lane `l` reads lane `l XOR mask`), which couples the 32 lanes of the
|
||||
aligned group into one computation. The consequences:
|
||||
|
||||
- A warp's 32 hashes are 32 attempts for the price of 32 lanes of work; no lane's result is reusable for another
|
||||
nonce because every register of every lane starts from the nonce.
|
||||
- The high half of the nonce is one `I` per 2^32 lanes. A miner that changes `nonce_hi` pays one FNV over 49 bytes
|
||||
(about 200 ops, `bind.rs:48`) and gets a fresh warp space; it does not need to, since 2^32 lane nonces at 141.8
|
||||
MH/s on an RTX 5090 (attack pass F9, the card row) is 30 s of work per `I`, 30 templates' worth at 1 block per
|
||||
second. The crate's own test `bind::bound_hash_properties` pins that two `nonce_hi` values give different warps
|
||||
under one `H` and that the lane hash depends on `H` (the fork's engine test pins the timestamp, `igneum.rs:237`).
|
||||
- The pool's share check recomputes `hash_bound(job.prehash, nonce)` for the claimed nonce (`pool/src/verify.rs:52`),
|
||||
so a share binds the whole template through `H`; a nonce under another template "hashes differently and is
|
||||
refused as a wrong hash" (the crate's test `the same nonce on another template`, `verify.rs:101`).
|
||||
|
||||
## 3. What the 256 loads and the shadow block compute from
|
||||
|
||||
### 3.1 The loads
|
||||
|
||||
Each of the 16 load sites per iteration (128 per hash) reads `dataset[load_index(era, site, x)]` where `x` is the
|
||||
source register's value in that lane at that instruction (`verify.rs:759`): `idx = x AND MASK` without an era, or
|
||||
`rotl(x M, R)` masked into the site's window under an era (`load_index`, `:18`). The address is therefore a function
|
||||
of the lane's register state, which from the first instruction is a function of `(I, n)` and, after the first load,
|
||||
of dataset words too. The attack pass measured the part that is a function of `(I, n)` alone (F9, sub-row (c), taint
|
||||
analysis `init_determined_sites`): on the devnet epoch-0 program the loads at instructions 7, 8, 9, 10 and 31 of
|
||||
iteration 0; over 2,000 seeds 1 to 7 sites per program, median 3, always in iteration 0 only. Everything after the
|
||||
first dataset word is data-dependent.
|
||||
|
||||
The 256 loads of a hash's "working set" are the 128 reads of its own lane plus the reads the warp's other lanes
|
||||
make, and they are distinct per hash by construction: the acceptance rule refuses a program whose site reads fewer
|
||||
than 120 distinct addresses per hash on average (`MIN_DISTINCT_SUM`, spec 1.4.6.4), whose 32 lanes compute one
|
||||
address at any site (`LaneConstantSite`), whose site's distinct-index ratio over 2^20 evaluations falls under 0.98
|
||||
(class v4, (c'')) or 0.995 (class v5, (c''')).
|
||||
|
||||
### 3.2 The shadow block
|
||||
|
||||
The shadow block is 256 ALU instructions from the ten non-load families (`generator.rs:413`), applied 27 times after
|
||||
instruction 63 of every iteration with the iteration's `sel` (`r0` at the iteration's start; `verify.rs:609`,
|
||||
`:631`). It has no loads. Its input is the lane's eight registers at that point and `sel`; its output is the
|
||||
registers for the next iteration. It is therefore a per-lane function of the lane's state, with `shfl` coupling
|
||||
lanes inside it as in the base block. Nothing in it is shared across nonces, iterations or templates: the same block
|
||||
applied 27 times to a different register state each time. The attack pass's F1 row measured what an honest compiler
|
||||
can remove from it (at most 4.688 percent of its instructions on the frozen class v5 tip at 10^5 programs, two
|
||||
programs at 5.078 percent in the 2.2 x 10^5 partial, both at the honest-compiler parity the ruling AP-F1-1 names), so
|
||||
the block's cost per application is fixed to within that margin for every implementer.
|
||||
|
||||
## 4. Where a result could be reused, each with its cost and the rule or the finding
|
||||
|
||||
The seven places an expensive intermediate could be shared across cheap attempts, in the order a miner would try
|
||||
them. "Rule" names what forbids the reuse or makes it worthless; "cost" is what the reuse would save against what it
|
||||
costs.
|
||||
|
||||
| # | Reuse | What is shared | Across what | Cost to the attacker | Rule or finding |
|
||||
|---|---|---|---|---|---|
|
||||
| R1 | The dataset | the day's 1 to 2 GiB and its 256 MiB cache | every hash of the day, every miner | nothing: this is the design (the memory-hard dataset is the shared intermediate on purpose) | Intended. The question is whether a SMALLER shared structure serves: the hot set (R5) and partial storage (R6) |
|
||||
| R2 | The compiled program | the epoch's kernel | every hash of the epoch, every miner | nothing: intended; one compile per epoch per card | Intended. A miner that grinds the epoch seed to get a favourable program is R7 |
|
||||
| R3 | The init words `I` | one FNV result | 2^32 lane nonces | nothing to gain: `I` costs about 200 ops and a warp costs 1.8 million lane-instructions (F9's count) | No rule needed; the ratio is 10^-4 |
|
||||
| R4 | The register prefix before the first load | the first instructions of iteration 0, a function of `(I, n)` | nothing: `n` is per lane, so there is no second attempt with the same prefix | zero reuse: each lane's prefix is its own | Bound by construction (spec 1.6: the nonce enters every register) |
|
||||
| R5 | A hot set of items | a few MB that serves a large share of reads | every hash of the epoch | the attack pass's F8 and F9 (b): at the frozen tip no program's top 0.1 percent of items carries more than 0.23 percent of reads (256 seeds, 245 within 1.2x of the window model), hot-set verdict clear on every seed; the exemplar that reads 3.35 percent (class v4 seed 100767) is refused by (c''') | AP-F8-1: the residue is a quarter-bit bucket concentration at one narrow-window site, named by mechanism, no cacheable hot set; the per-site largest-bucket bound is a next class's item |
|
||||
| R6 | A partial dataset or cache | a stored fraction `f` of the cache lines, the rest recomputed | every hash | F3: every cache line costs exactly `j + 1` block evaluations from nothing (0 of 1,024 lines under the bound), the storage-against-recompute curve at f = 1/8 is 3.17 ops per read optimal against the 3.5 the funding note assumed, and a partial-cache chip is worse than the full mirror at every f below 1 | F3 PASS; the chip edge is the latency ladder's business, not a binding hole |
|
||||
| R7 | The epoch seed | the program of epoch `e + 1` | every hash of that epoch | a miner who produces the seed block (the last selected-chain block below `3,600 (e + 1) - 600`) picks among the candidate blocks it can produce; each candidate costs a full block's work, and the 600-score lead gives every miner the same 10 minutes to compile; under the devnet stand-in there is no VDF on the epoch seed (spec 4.3 Designed, the era VDF Implemented) | Spec 4.3 (the 1,200-s lead and the 600-s VDF) is the rule; on the node line the lead is 600 and the VDF is not in the node. The grind's value is bounded by the program-to-program variance of hash rate: the attack pass's F8 and F1 rows put every accepted program within a few percent of the mean on the card (the per-program spread is the F10 ladder's reading), so a candidate block burned to pick a 2 percent better program is a bad trade at any hashrate under 50 percent. Finding, minor: the VDF of 4.3 is the stated defence and is not implemented; the lead alone does not stop the pick. Routed to the Counter ASIC lane's D1 list as a 2.0 item (6) |
|
||||
| R8 | The day | the dataset of day `d` | every hash of the day | the day is keyed on the header TIMESTAMP (`pre_ghostdag_validation.rs:147`), which the miner sets within about 10 s; at a day boundary a miner may hash on either day's dataset for about 10 s, and a verifier must hold both caches across the boundary (the engine's `note_chain_day`, `:139`, builds the next in the background) | No gain: both datasets are full datasets. Observation: spec 1.12 keys the day on DAA score and the node keys it on the timestamp; one of the two texts moves (6) |
|
||||
| R9 | The attempt chain | the acceptance work of attempts 0 to k-1 | every node and miner of the epoch | nothing to a miner: attempts are deterministic from the seed; a miner cannot choose the attempt. A verifier's cost is the row below | No rule needed |
|
||||
| R10 | The warp | one hash computation | 32 nonces | intended: the 32 lanes are 32 attempts for 32 lanes of work, coupled by `shfl` so no lane is computable alone | Intended (spec 1.9). The verifier dedupes items within a warp (`verify.rs`, the 4,096 derivations bound); a miner that finds warps whose loads coincide is F9 (c): one coalesced pair per 3,400 tries is worth 0.2 percent of one warp and costs 2.4 hashes of search; five sites fully coalesced would be worth 43 percent and has probability 2^-115 per draw |
|
||||
| R11 | The template | one `H` | 2^64 nonces | intended; a template change is a new `H` and costs nothing but one BLAKE2b; it buys nothing, since the nonce space under one `H` is 2^64 | No rule needed |
|
||||
| R12 | The vote key | one `vote_key_hash` | every block of the key | a miner with many keys hashes under one `H` per key; keys are free (spec 03 W6) and weight is blocks, so more keys is more templates, not more hashes per hash | No gain; the pool design document (pool-vote-key-commitment.md) covers the pool case |
|
||||
|
||||
The cheapest reuse that survives as a gain is R10's coalesced pair at 0.2 percent of one warp per 2.4 hashes of
|
||||
search, which is a loss, and R5's quarter-bit bucket, which is under the window model's own spread. No path was found
|
||||
by which an intermediate computed once serves a second winning attempt at less than the honest cost of that attempt.
|
||||
|
||||
## 5. Known-failed tests and measured lines
|
||||
|
||||
| Test | Known-pass | Known-fail | Where | Result |
|
||||
|---|---|---|---|---|
|
||||
| The lane hash depends on `H` and on `nonce_hi` | two headers differ in one field: different warps | the same `(H, nonce_hi)`: the same warp, bit for bit | `igneum-pow/src/bind.rs`, `bound_hash_properties`, `bound_vectors`; the fork's `igneum.rs:200` smoke test (timestamp bound, `:237`) | see the run line below |
|
||||
| A nonce on another template is refused | the pool's check against its own job | the same nonce on another template: `wrong_hash` | `pool/src/verify.rs:101`, `the same nonce on another template hashes differently` | in the pool crate's suite (pool.md section 4) |
|
||||
| A stateless hasher is wrong on every class v5 item | the dataset built with the leaves | the dataset built without them: every item differs | `igneum-pow/src/state.rs:200`, `a_stateless_hasher_is_wrong_on_every_item` | see the run line below |
|
||||
| No cache line costs less than `j + 1` | the real chain | a flattened chain (the F3 harness's known-fail) | `docs/analysis/attack-pass/f3-cache.md`, the two firings | PASS, 7 October |
|
||||
| No hot set on the frozen tip | the window-model control | a planted hot site (F8's known-fail) | `f8-uniform.md` sections (d) and (e) | PASS, 61 of 64 and 245 of 256 within 1.2x |
|
||||
| Init-determined sites are in iteration 0 only | the taint trace | a program with a load whose address never absorbs a word (F9's planted case) | `f9-grind.md` sub-row (c) | 1 to 7 sites, iteration 0 only, 2,000 seeds |
|
||||
|
||||
Run line (box 2 through `tools/build-remote.sh`, `cargo test --release -p igneum-pow -- bind::
|
||||
state::a_stateless_hasher_is_wrong_on_every_item`): 8 October 2026, 19:0x UK, box 2 (igneum-build-2) through `tools/build-remote.sh --box 2`, the crate at the mirror's master 494fb981: `bind::tests::bound_hash_properties` ok, `bound_vectors` ok, `init_bytes_layout` ok, `day_bytes_layout` ok, `pow256_and_target64` ok, `closed_form_bound_also_works` ok (6 passed, 0 failed, 0.43 s); `state::tests::a_stateless_hasher_is_wrong_on_every_item` ok (1 passed, 0 failed, 2.15 s). The pool crate's `the same nonce on another template hashes differently` and the fork engine's timestamp-bound test are in their crates' suites (pool.md section 4; `igneum.rs:200`), not re-run here.
|
||||
|
||||
## 5a. Finding A05 of the external review: the FNV initialisation and the 64-bit fold
|
||||
|
||||
The external review's finding A05 (`docs/analysis/review-2026-10-08/evidence.md`): the seed and the header-bound
|
||||
initialisation are four salted FNV-1a-64 passes with a final mix (`seed.rs:37`, `bind.rs:57`), the final state
|
||||
reduces by rotated XOR to 64 bits placed in the top 64 bits of the 256-bit proof-of-work value (`verify.rs:649`,
|
||||
`bind.rs:76`), FNV is non-cryptographic by its own specification, and ProgPoW's keccak-f800 at both ends of a cheap
|
||||
inner mix is the pattern to measure against. Two questions: can one expensive intermediate be reused across cheaper
|
||||
attempts through the initialisation or the fold, and is the binding to job and nonce complete. The argument, then
|
||||
the measured lines, then what a change would be if one were wanted.
|
||||
|
||||
### 5a.1 What the initialisation has to do, and what it does not
|
||||
|
||||
ProgPoW's keccak at the start exists because its inner mix is a fixed structure for a 50-block period and a hasher
|
||||
must not be able to choose or correlate its inputs cheaply; the keccak at the end exists because the inner mix is
|
||||
not a cryptographic function and its output must not be shaped. Igneum's initialisation does not carry that burden
|
||||
alone, for three reasons that are in the code:
|
||||
|
||||
1. The attacker does not choose the FNV input. The 49 input bytes are the tag, `H` and `nonce_hi` (`bind.rs:48`). `H`
|
||||
is a BLAKE2b-256 over the template (the chain's `BlockHash`, `hashing/header.rs:7`); the only way to change it is
|
||||
to change a header field, which gives a fresh 256-bit value the attacker cannot steer. The attacker's whole
|
||||
freedom at the FNV input is the 32 bits of `nonce_hi` under a fixed `H`.
|
||||
2. FNV-1a with the final mix gives no control over its output beyond brute force over those 32 bits. Each pass is
|
||||
`h = (h XOR byte) x P` over the bytes, then `h ^= h >> 33; h *= C; h ^= h >> 33`. The mix is a bijection, so a
|
||||
target output pins the pre-mix state; the last four bytes of the chain are then four steps `s_{k+1} = (s_k XOR
|
||||
b_k) x P`, each inverted by `b_k = (s_{k+1} x P^-1) XOR s_k`, which must land in 0..255: probability 2^-56 per
|
||||
byte for a random target. So hitting a chosen 64-bit pass output costs the full 2^64 and hitting a chosen 32-bit
|
||||
word of `I` costs 2^32, the brute-force figures. The measured control line below reads this at the byte scale:
|
||||
256 tries of `nonce_hi` hit a chosen byte of `I[7]` at the random rate.
|
||||
3. What a weak initialisation would give is an `I` correlated across `nonce_hi` values or across templates, so that
|
||||
the prefix of the program (the init-determined loads of F9, 1 to 7 sites in iteration 0) could be shared. The
|
||||
avalanche lines below read 128 of 256 bits flipped per input bit, with the min and max inside the binomial's
|
||||
range; no two of 2^18 `nonce_hi` values under one `H` share an `I`, nor a single 64-bit pass.
|
||||
|
||||
The non-cryptographic property FNV lacks, preimage resistance against a chosen input, is not used: nothing in the
|
||||
chain asks that `I` be hard to invert, only that it be a near-uniform function of `(H, nonce_hi)` that the attacker
|
||||
cannot steer, and the steering is bounded by BLAKE2b on one side and by 32 bits on the other.
|
||||
|
||||
### 5a.2 The fold
|
||||
|
||||
The fold is linear over GF(2): `lo = r0 ^ rotl(r1,7) ^ rotl(r2,14) ^ rotl(r3,21)`, `hi = r4 ^ rotl(r5,9) ^
|
||||
rotl(r6,18) ^ rotl(r7,27)`, and the verdict reads `hash <= target64` with `hash = hi || lo` (`bind.rs:83`,
|
||||
`target64_from_le256`). What a hasher would want from a linear fold is to shape the top bits of `hi` cheaply at the
|
||||
end. Three facts close that:
|
||||
|
||||
- Nothing cheap varies at the end. The nonce enters once, at initialisation (section 2); the last instructions
|
||||
before the fold are the shadow block's 27th application in iteration 7, pure ALU on the lane's eight registers,
|
||||
and every register at that point is a function of the whole program over the dataset words the lane read. There
|
||||
is no second cheap input to vary after the expensive part; the warp is recomputed from the start for every nonce.
|
||||
- The top 64 bits need `r4..r7` in full and those depend on `r0..r3` throughout the program (every register is the
|
||||
`dst` of an injecting write, part (b), and the draw's source rule keeps the dataflow fresh, part (a')), so no
|
||||
register is skippable.
|
||||
- The fold's output is tested per program: part (c) of acceptance refuses a program whose output bits' ones-count
|
||||
over 2,048 hashes falls outside 1,024 plus or minus 136 (`OutputBias`, `accept.rs:68`, 6 sigma), and F8's
|
||||
cross-hash histogram rows read the 64-bit outputs uniform at 2^24 nonces on every accepted seed. A program whose
|
||||
final state left the fold biased is refused before it is the epoch's program.
|
||||
|
||||
Where ProgPoW needs keccak at the end is that its mix is fixed and public for a period, so an output-shaping
|
||||
structure could be found once and used for 50 blocks; Igneum's program is a fresh random draw per epoch that is
|
||||
itself tested for output bias, so the fold's linearity has no fixed structure to exploit. Against a hasher that
|
||||
tries anyway: the cost of computing `r4..r7` is the hash, and the comparison is one 64-bit compare after it.
|
||||
|
||||
### 5a.3 Is the binding to job and nonce complete
|
||||
|
||||
A job is `(prehash, target64, share_target64, nonce_start, nonce_count)` (spec 9.5); a share is a nonce under that
|
||||
prehash. The lane hash is `fold(program(init(n, I(H, nonce_hi))))` with `H` the template's pre-PoW hash, so a share
|
||||
commits to every header field (the coinbase and the transactions through `hash_merkle_root`, the key through
|
||||
`vote_key_hash`, the epoch through `daa_score` and the parents, the day through the timestamp) and to both halves of
|
||||
the nonce. Two nonces give two warps (`bound_hash_properties`); two templates give two `I` (the fork's timestamp
|
||||
test); a nonce moved to another template fails as `wrong_hash` (the pool's test). Nothing in the computation reads
|
||||
anything outside `(H, nonce, program, dataset)`, so there is no unbound input a hasher could hold fixed. The 2.0
|
||||
wire shape adds the key to the job and share lines (spec 09 at master 2e9b3e73) for the pool's evidence, not for the
|
||||
binding, which the prehash already carries.
|
||||
|
||||
### 5a.4 Measured lines (8 October 2026, 19:5x UK, the spec-exact replica `a05/fnv.py` on the Mac, integer arithmetic, seconds of CPU)
|
||||
|
||||
| Line | Known-pass | Known-fail | Result |
|
||||
|---|---|---|---|
|
||||
| The replica is the spec's function | `seed_words("igneum-genesis")` equals the spec 1.3.1 vector `67a9a7be 1a155b25 fddfb732 4b5af2e8 c55caf33 a27c13b7 06628a48 03852469` | the replica without the final mix must differ | OK; FIRED (differs) |
|
||||
| Avalanche over `nonce_hi` (one random bit flipped, 20,000 trials) | | | mean 128.02 of 256 bits, min 94, max 160 (binomial expectation 128, sd 8) |
|
||||
| Avalanche over `H` (one random bit flipped, 20,000 trials) | | | mean 128.01 of 256 bits, min 96, max 158 |
|
||||
| Collisions over 2^18 `nonce_hi` values under one `H` | | | 0 equal `I` (256 bits), 0 equal pass-0 outputs (64 bits; birthday expectation 1.9e-9) |
|
||||
| Control: a chosen value for the top byte of `I[7]` reached by one of 256 `nonce_hi` tries, 2,000 trials | | | 1,280 of 2,000 (0.640) against the random expectation 0.633 |
|
||||
|
||||
The crate's own lines of section 5 stand beside these (`bound_hash_properties`, `bound_vectors`, the timestamp
|
||||
test, the pool's `wrong_hash` test).
|
||||
|
||||
### 5a.5 Verdict and what a change would be
|
||||
|
||||
Verdict: the argument holds; no attack. The initialisation is a near-uniform, unsteerable function of a BLAKE2b
|
||||
hash and 32 free bits, the fold follows a per-epoch random program that acceptance tests for output bias, and the
|
||||
binding to the job and both nonce halves is complete through the prehash. No consensus change is named for 2.0.
|
||||
|
||||
If main wants the ProgPoW shape regardless, the change is a class version, not a patch, because every vector moves:
|
||||
|
||||
| Change | What it buys | Cost | Vectors |
|
||||
|---|---|---|---|
|
||||
| Init: `I = BLAKE2b-256("igneum-block/" || H || nonce_hi)` as eight words (`blake2b.rs` exists in the crate) | a cryptographic initialisation by its own specification; no change to the argument above | one BLAKE2b per `nonce_hi` per warp space, about 1 microsecond on a CPU core, nothing on the card (the init is a kernel argument) | every pack's vectors, the acceptance stream (`"igneum-accept/"` is FNV too), the day key, the program seed: a new generator version with a new vector set and a new frozen object |
|
||||
| Finalisation: BLAKE2b over the 256-bit final state before the fold | removes the fold's linearity as a question | one BLAKE2b per hash on the card, about 2 to 5 percent of hash rate (Designed, unmeasured; ProgPoW pays keccak-f800 per hash) | the same new version |
|
||||
|
||||
The seat's recommendation is neither for 2.0: the measured lines and the argument close A05 as written, and the
|
||||
cost of the second row is paid by every honest card for every hash. The first row is cheap and could ride the next
|
||||
class version whenever one is cut for another reason; it is named here so the decision is main's, with the vectors
|
||||
as the price.
|
||||
|
||||
## 6. Open questions, named for main
|
||||
|
||||
| Id | Question | What closes it |
|
||||
|---|---|---|
|
||||
| B1 | The epoch-seed VDF (spec 4.3, 600 s of delay on the seed block's hash) is Designed and not in the node line; the era VDF is. Without it the seed-block producer picks among the candidates it can afford to burn (R7). Is the epoch VDF a 2.0 item, or does the measured program-to-program hash-rate spread (the F10 ladder's reading) close it as not worth a block? | A ruling, with the F10 spread as the number |
|
||||
| B2 | The day is keyed on the header timestamp in the node (`day_index(header.timestamp)`) and on DAA score in spec 1.12 (R8). Which text moves? The timestamp key makes the day boundary a miner's choice within 10 s and costs the verifier two caches across it; the DAA key makes it a function of the past alone | A ruling; a one-line spec or code change |
|
||||
| B3 | `seed_source` and `proof_ref` (spec 2.4, fork point a5) are not in the node line's header; the epoch seed is bound through `daa_score` and the parents instead, which is sufficient for binding but leaves the spec's field table ahead of the code | The spec's table marked to the code, or the fields added when the chunked proving protocol needs `proof_ref` |
|
||||
| B4 | A verifier syncing from genesis derives every epoch's program (the draw plus acceptance, about 6 core-s per epoch at 3.2 candidates): about 15 core-hours per year of chain on one core, parallel across epochs | The IBD cost measured on the node line with the memo (`epoch_seed_memo`) and a per-epoch program cache; a number, not a rule |
|
||||
| B5 | R10's coalesced-pair search (F9 (c)) is measured as a loss on the RTX 5090; it is not measured on a card whose load completes per lane rather than per warp (an AMD wave64 under the local-memory exchange) | One run of the F9 `grind` per-warp table on a 9070 XT |
|
||||
|
|
@ -365,18 +365,7 @@ The k column. k is the chip core's energy per op over the GPU's at the same oper
|
|||
|
||||
Correction, 8 October 2026 (the research lane's microbench on the 5090, counter-asic-4-research.md 15.1a and the corrected 20.3 and 20.4 at 71fd465b): the per-MAC figures above are wrong by a factor of 32. A `mma.m8n8k16` tile is 1,024 multiply-adds per warp, 32 per lane, so a hash does 32 MACs per tile, not 1,024; the 4090's "0.056 pJ per MAC" is 1.8 pJ, and the 5090 at the ALU shadow's premium reads 2.9 pJ per MAC unlocked and 1.5 pJ at the 1,300 MHz lock (the packs job, 366,080 MACs per hash; the microbench's dependent u8 tile 4.1 and 2.2, the wide s8 m16n8k32 tile 1.36 and 0.83). Against the same 5 nm MAC array figures (0.04 to 0.4 pJ per INT8-class MAC, claimed) a chip's k on tile work is therefore 0.03 to 0.3, below the ALU shadow's 0.3 to 0.8, not near 1: at the same premium a tensor-shaped shadow leaves the chip 3.5x to 6.7x where the ALU shadow leaves it 2.1x to 3.5x. The tensor-tile column (2.1x at k = 1, 1.6x at k = 1.5) is withdrawn as a candidate; its premise, that a chip's MAC is no cheaper than the GPU's, is false by 4x to 30x on the public figures. The shipped row is unchanged: 2.1x at k = 1 and 3.4x at k about 0.33, the ALU shadow at the operating point's knee, measured four times at 82 to 90 W.
|
||||
|
||||
The capex column. The `f = 1` GDDR7 chip of 5.5 is USD 2.8 per MH/s of silicon and memory, which is USD 0.00016 per MH/s-hour of capex over two years against USD 0.000023 of electricity: capex-dominated 7x, as the 5090 is (USD 14.7 per MH/s at MSRP, 10x). A 64 MiB hot table adds about USD 15 of N5 die, the shadow core USD 25 to 40, an interposer USD 200, so the chip's capex reaches at most about USD 4.3 per MH/s: the per-unit capex wall is unreachable by 3x to 7x, and the break-even market cap moves only through the project cost (the mission lane's model: about USD 100 M with the N5 shadow core, about 200 M if the shadow runs per load and forces one die or an interposer; the per-load form behind that figure is REOPENED (8 October 2026, 11:0x UTC): the 16 x 27 iterated placement is dead on the no-era census (0.986 to 0.990 rejection per candidate on both instruments), the 7 October night's drawn-era death was an instrument artefact (the per-load acceptance test counted the era window's fixed top index bits as biased, fixed on counter-asic-4 at 10:5x UTC), and the sound form, one pass of a 256-instruction sub-block per load (`mx8+shl4096x1`), accepts 234 of 256 seeds on the no-era census with its drawn-era verdict the invention lane's 15:00 BST read; the 200 M row rests on that form, class v6's layer 5). Every figure here is modelled on cited or claimed parts; the research lane's microbench (20 probes, the mma_u8 and l2 rows the ones this model would take) is on PC 1's queue after the hot-table job.
|
||||
|
||||
### 5.12 Two chips from the hardware-future lane, and the honest denominator (8 October 2026, 11:3x UK, main's order; the figures lane B's, `docs/analysis/class-v6/hardware-future.md` at master 34f63b3c, modelled on this file's method unless marked claimed; the research lane carries them here as the model's own rows)
|
||||
|
||||
| Chip | When | Energy per random read | Rate per die | Energy per hash at zero shadow | Edge per joule against the 5090 at 2.40 microjoules | Against the M5 Max at 0.78 | With the class v4 shadow (F = 1.10 microjoules) at k = 0.5 / k = 1 | Silicon and project | What the four layers of class v6 do to it | Labels |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| **A 2 GiB SRAM full store on one N2 reticle** (TSMC N2 38 Mb/mm^2 HD SRAM, +11 percent over N3E, claimed, IEEE Spectrum, 12 December 2024; 2 GiB is about 452 mm^2 of macro under the 858 mm^2 reticle) | 2027 to 2028 (an N2 project; wafers about USD 30,000 and booked to 2028, claimed) | 1.0 nJ for a 64-byte read across the array (0.5 to 2.0: the wire term dominates, this file's 5.1 convention) | power-bound: about 2,100 MH/s at 300 W (0.14 microjoules per hash) | 0.14 | **17x (8x to 30x)** | 5.6x | **4.8x / 2.7x**; at the 5090's whole latency shadow (F about 2.0) 3.7x / 2.0x | about USD 400 to 600 of silicon per die, USD 0.25 to 0.4 per MH/s; an N2 project USD 100 M to 500 M and 18 to 24 months (claimed, the 2.5 economics; the mission lane's break-even model then reads a cap of about USD 330 M to 1.7 B in years 1 to 2 at s = 0.30) | layers 1, 3 and 4: nothing (the dataset is the SRAM's content, every draw firmware; the core an N2 core, k 0.3 to 0.5); layer 2 moves its CAPEX, not its joules: two dies at 4 GiB 15x and about USD 1,000, four at 8 GiB 13x and USD 2,000 to 2,500 | modelled; the N2 density and price claimed |
|
||||
| **A custom HBM4E base die** (the controller and PHY in the stack on N3P at 0.75 V, "2x the power efficiency", claimed, TrendForce, 1 December 2025; Micron 2027, SK hynix 2026; a non-hyperscaler customer from about 2028) | 2028 or later | 0.9 to 1.0 nJ | 166 MH/s per stack at the model's activate ceiling (36 at the JEDEC tFAW: the ceiling is unmeasured, 5.3) | 0.18 (0.37) | **14x (6.5x)** | 4.4x (2.1x) | 4.4x / 2.6x | about USD 5 per MH/s (approximate); the stack at a premium: Samsung asking about USD 4 to 5 per Gbit for HBM4 against 1.5 for HBM3E (claimed, 2 October 2026) | untouched by all four layers; the brake until about 2028 is HBM allocation and price, not the hash | modelled; the die and price claimed |
|
||||
| Per-bank processing in memory (Samsung HBM-PIM, SK hynix GDDR6-AiM and AiMX, Samsung LPDDR5X-PIM at Hot Chips 25 August 2026, LPDDR6-PIM in JEDEC work) | now to 2027 | the host memory's own | | | **under 1x: structurally blind to the hash's dependent random reads** | | | | a per-bank unit sees its own bank only: 1.6 percent of a hash's reads land in-bank at a 2 GiB dataset on a 32 MB bank, 0.4 percent at 8 GiB; every other read crosses the channel as it does for a controller chip; layer 2's growth lowers the share further | modelled (arithmetic on the bank size) |
|
||||
| The honest denominator | | | | the M5 Max at 0.78 microjoules per hash (the GPU and DRAM channels, measured 6 October), 3.1x the 5090 per joule; the 5090 at its knee 1.67 (measured 7 October) | | | | | against the best honest joule every chip edge is 2x to 3x smaller than against the 5090's stock point: the SRAM die 5.6x and the base die 3.6x to 4.4x at zero shadow, about 3x with the shadow at k = 0.5 | measured cards |
|
||||
|
||||
Reading, as the research lane reads it for the class v6 synthesis: the number this file's 5.6 verdict carried ("over 2x", the GDDR7 board at 5.1x) is no longer the strongest chip in the five-year window. The SRAM full store on merchant N2 reaches 17x at zero shadow and 2.7x to 4.8x with the class v4 shadow, and none of the rotating layers reaches it; what holds it is money and time (an N2 project at USD 100 M to 500 M, 18 to 24 months, the break-even cap in the hundreds of millions to about USD 1.7 B) and the one lever the chain has on it is capex through the dataset's size (layer 2's floor and its schedule, priced in the class v6 synthesis). The served chip line is reviewed against these rows at 20:00 UK today in the synthesis's last section; nothing served moves on this section.
|
||||
The capex column. The `f = 1` GDDR7 chip of 5.5 is USD 2.8 per MH/s of silicon and memory, which is USD 0.00016 per MH/s-hour of capex over two years against USD 0.000023 of electricity: capex-dominated 7x, as the 5090 is (USD 14.7 per MH/s at MSRP, 10x). A 64 MiB hot table adds about USD 15 of N5 die, the shadow core USD 25 to 40, an interposer USD 200, so the chip's capex reaches at most about USD 4.3 per MH/s: the per-unit capex wall is unreachable by 3x to 7x, and the break-even market cap moves only through the project cost (the mission lane's model: about USD 100 M with the N5 shadow core, about 200 M if the shadow runs per load and forces one die or an interposer; the per-load form behind that figure, the 16 x 27 placement, was closed on 7 October 2026 at night when it failed the value-level acceptance test across drawn eras, so the 200 M row rests on no construction shown to exist until a sound per-load class, one pass of a 432-instruction sub-block per load, is drawn, accepted and measured). Every figure here is modelled on cited or claimed parts; the research lane's microbench (20 probes, the mma_u8 and l2 rows the ones this model would take) is on PC 1's queue after the hot-table job.
|
||||
|
||||
## 6. The per-day derivation (item 2)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
# The int8-dominated class and the ARX-only class: a research record (9 October 2026, CLOSED)
|
||||
|
||||
RESEARCH RECORD ONLY. The founder asked at 11:0x UK for "the int8-dominated class"; main closed the build at 11:1x UK
|
||||
(the coordinator's line: "the int8 class build is CLOSED now ... the frozen v6 stays the algorithm"). This page records
|
||||
what was cut before the stop, so nobody has to redo it. Nothing here activates. The census, the kit, the GPU self-test,
|
||||
the J/hash rows and the OpenCL and Metal runs were NOT RUN. The card's measured int8 and ARX microkernel rows are the
|
||||
hash lane's, on the pods.
|
||||
|
||||
## What was cut
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Branch, sha | `1p5x-int8` on the box mirror (build-1:/srv/igneum.git), `0592e48e8fbbf37ebb162915b35294dc21fb19a7`, on X1's `a99de07cd` (on the frozen tree `1a938abe4`) |
|
||||
| igneum-pow/src fingerprint | `9e8fb3c4b987f9b149ab0d9eb3efc8bcc401fe99faad84e7b1a62e6ac897352f` (recipe `cd igneum-pow && find src -type f \| LC_ALL=C sort \| xargs shasum -a 256 \| shasum -a 256`); the frozen `5f4d6dc6...` is untouched at `1a938abe4` |
|
||||
| The op | `Op::Dp4a`: `d = d + dot4_u8(a, b)` mod 2^32, `dot4_u8` = the sum of the four unsigned byte products (at most 260,100), the form of `docs/analysis/int8-matrix-family.md` section 2.1. CUDA `__dp4a` on unsigned operands (PTX `dp4a.u32.u32`); OpenCL and Metal the exact integer helper `dot4_u8`; the CPU reference `verify::dot4_u8`. Integer only: no rounding mode or fast-math switch can move a bit. `src2 != dst` by construction (the A08 rule's twin) so the op is a bijection in `d`. Injection is by the accumulator (an add), not by xor: an xor of `d` with a function of `d` is not a bijection, and the add uses the MAC's own accumulate |
|
||||
| The flag | `LoadClass::fam8` (1 to 4), class suffixes `+int8w50r32`, `+int8w75r32`, `+int8w90r32`, `+arxr16`, `+arxr32`, program-id literal `fam8/` with the table row and the load slots; the re-weight table is off (rw 0), no A02 shuffle reservation (no shfl exists); generator 6 pack shape; the CLI `export ... --program-class v6 --era-hex <era> --fam8 <suffix>` |
|
||||
| The tables (`FAM8_WEIGHTS`, sum 100, the base program and the shadow block both) | int8w50: dp4a 46, add 11, sub 11, xor 11, rotl 11, rotr 10; int8w75: dp4a 72, then 6, 6, 6, 5, 5; int8w90: dp4a 89, then 3, 2, 2, 2, 2; arx: add, sub, xor, rotl, rotr at 20 each; mul, mulhi, mad, or, shfl at 0 everywhere |
|
||||
| The energy weighting | the record's 5090 microbench (`docs/analysis/counter-asic-4-research.md`): an ARX op 11.3 pJ (measured); a dp4a instruction 13.1 pJ (APPROXIMATE: 11.3 times the `__dp4a` step-cost ratio 1.16 of the family probe in `docs/bench-log.md`, a time ratio taken as the energy ratio, since no dp4a energy row exists). Count share for energy share s: (s / 13.1) / (s / 13.1 + (1 - s) / 11.3), so 46.3, 72.2 and 88.6 percent |
|
||||
| Reads per hash | `load_slots x 8 iterations x 2` (the reg64 window runs every instruction twice): r32 = 2 load slots, r16 = 1, over the same 4 GiB dataset (the frozen object reads 256) |
|
||||
|
||||
## The five programs on the signing pair (epoch seed af89be5d..., era 0:edc4fa84..., day 20730, node1 state, 4 GiB)
|
||||
|
||||
Exported on build-9 with the branch's binary at 10:01 to 10:07 UTC. Every export printed `OVERALL: PASS (pack written)`,
|
||||
which covers the CPU reference's own vectors and the cache FNV (448274a57f508cbc). The logs are under `rows/`.
|
||||
|
||||
| Class string | Program id | Attempt | Base-program op mix (64 instructions) | kernel.cu blake2b-256 | CPU vector, warp 0, lane 0 |
|
||||
|---|---|---|---|---|---|
|
||||
| mx8-erad810f22d+sh256x27+state+reg64c+fold+int8w50r32 | e74e5ed6b9686781 | 0 | dp4a 20, add 13, rotl 9, sub 8, rotr 6, xor 6, load 2 | 7bf3072b... | see rows/export-int8w50r32.txt |
|
||||
| mx8-erad810f22d+sh256x27+state+reg64c+fold+int8w75r32 | 445765984996028a | 0 | dp4a 44, xor 5, rotr 4, sub 4, add 3, rotl 2, load 2 | 76c23add... | rows/export-int8w75r32.txt |
|
||||
| mx8-erad810f22d+sh256x27+state+reg64c+fold+int8w90r32 | 0d438354ca69b247 | 0 | dp4a 55, rotr 2, sub 2, add 1, rotl 1, xor 1, load 2 | 0913390e... | rows/export-int8w90r32.txt |
|
||||
| mx8-erad810f22d+sh256x27+state+reg64c+fold+arxr16 | ffc2516557f356be | 0 | add 17, rotr 17, rotl 11, sub 10, xor 8, load 1 | cbfcb244... | rows/export-arxr16.txt |
|
||||
| mx8-erad810f22d+sh256x27+state+reg64c+fold+arxr32 | ad3b3faaf6ac1ad6 | 1 | xor 16, rotl 15, add 12, sub 11, rotr 8, load 2 | 11cc0bfc... | rows/export-arxr32.txt |
|
||||
|
||||
The packs stay on build-9 at /srv/builds/int8/packs-out/ (not copied to build-1; no kit was cut).
|
||||
|
||||
## State at the stop, and the rows NOT RUN
|
||||
|
||||
| Row | State |
|
||||
|---|---|
|
||||
| `cargo check --all-targets` and the release build on build-9 | clean (the binary that exported the packs) |
|
||||
| The pow suite with `tests/fam8.rs` | NOT RUN (stopped by main's ruling before it started) |
|
||||
| The CUDA emulation `--check` (the shim gained `__dp4a`) | NOT RUN |
|
||||
| The CUDA emitter's bit-exact self-test on a card (fk-int8-5090a was handed at 11:05 UK) | NOT RUN: the pod was released unused |
|
||||
| OpenCL and Metal bit-exactness | NOT RUN |
|
||||
| The census instruments (hot set, bit bias, exhaustion, the family predicate) | NOT RUN; acceptance on the signing pair took attempt 0 or 1 for all five, the only reading |
|
||||
| J/hash, rate, memory share on the 5090 and 4090 | NOT RUN here (the hash lane's microkernel rows are the data) |
|
||||
| Registry | `tools/ci/batches/1p5x-int8-20261009-01.json`, NOT RUN |
|
||||
|
||||
Two open design points, if the class is ever re-opened: the dp4a energy is an approximation (a measured dp4a row on
|
||||
the card settles it), and dropping from 256 to 32 or 16 reads per hash changes the memory share of the hash by an order
|
||||
of magnitude, so each class needs its own dataset-floor reading before any comparison with the frozen object.
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
# 2026-10-09T10:02:30Z host igneum-build-9 cmd: ./bin-int8-wip export --out packs-out/arxr16 --epoch-hex af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3 --day-hex 69676e65756d2d6461792ffa50000000000000 --program-class v6 --era-hex edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07 --state node1-state.igsd1 --dataset-log2 30 --fam8 arxr16
|
||||
state stream node1-state.igsd1: chain block 159357 af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3, root 1c583d352bb9c75a06dadb8d82d42836ebe8afa82d0b413be87bf921741f1526, 93 records, 93 leaves
|
||||
igneum-pow export packs-out/arxr16
|
||||
seed "igneum-epoch/af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3/day/69676e65756d2d6461792ffa50000000000000", day "bytes:69676e65756d2d6461792ffa50000000000000", dataset 2^30 words (memory-hard), generator v6 attempt 0 program id ffc2516557f356be, loads/hash 8; epoch built in 4111.4 ms
|
||||
op mix: add=17 rotr=17 rotl=11 sub=10 xor=8 load=1; class mx8-erad810f22d+sh256x27+state+reg64c+fold+arxr16, 32 bytes/hash, widths (1,4,16 words) [1, 0, 0]
|
||||
seed words 0x9c8caa3d 0x83537296 0xf21169a4 0x12d4d157 0x66e0ce33 0xcc049080 0x8579c352 0x4c6e4ac9
|
||||
wrote packs-out/arxr16/program.json (57080 bytes)
|
||||
wrote packs-out/arxr16/identity.json (999 bytes)
|
||||
wrote packs-out/arxr16/vectors.json (6458 bytes)
|
||||
wrote packs-out/arxr16/kernel.cu (33724 bytes)
|
||||
wrote packs-out/arxr16/kernel.cl (41561 bytes)
|
||||
wrote packs-out/arxr16/program.h (7302 bytes)
|
||||
wrote packs-out/arxr16/vectors.h (5942 bytes)
|
||||
wrote packs-out/arxr16/program.metal (29712 bytes)
|
||||
wrote packs-out/arxr16/program_bound.metal (29864 bytes)
|
||||
wrote packs-out/arxr16/kernel_bound.cu (31499 bytes)
|
||||
wrote packs-out/arxr16/kernel_bound.cl (70738 bytes)
|
||||
wrote packs-out/arxr16/memhard.h (7369 bytes)
|
||||
wrote packs-out/arxr16/memhard.metal (7258 bytes)
|
||||
vector warp base 0: lane0 83009d56db7af885 lane31 0e2fbfa86964174e
|
||||
vector warp base 4096: lane0 84d2a1811e44ad82 lane31 2bb0af48de71546a
|
||||
vector warp base 1000000: lane0 935efac70dceb4b5 lane31 2686973018dc3407
|
||||
cache FNV-1a 64 448274a57f508cbc
|
||||
OVERALL: PASS (pack written)
|
||||
# end 2026-10-09T10:02:35Z rc 0
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
# 2026-10-09T10:02:30Z host igneum-build-9 cmd: ./bin-int8-wip export --out packs-out/arxr32 --epoch-hex af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3 --day-hex 69676e65756d2d6461792ffa50000000000000 --program-class v6 --era-hex edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07 --state node1-state.igsd1 --dataset-log2 30 --fam8 arxr32
|
||||
state stream node1-state.igsd1: chain block 159357 af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3, root 1c583d352bb9c75a06dadb8d82d42836ebe8afa82d0b413be87bf921741f1526, 93 records, 93 leaves
|
||||
igneum-pow export packs-out/arxr32
|
||||
seed "igneum-epoch/af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3/day/69676e65756d2d6461792ffa50000000000000", day "bytes:69676e65756d2d6461792ffa50000000000000", dataset 2^30 words (memory-hard), generator v6 attempt 1 program id ad3b3faaf6ac1ad6, loads/hash 16; epoch built in 5607.4 ms
|
||||
op mix: xor=16 rotl=15 add=12 sub=11 rotr=8 load=2; class mx8-erad810f22d+sh256x27+state+reg64c+fold+arxr32, 64 bytes/hash, widths (1,4,16 words) [2, 0, 0]
|
||||
seed words 0xe60ed2a2 0x883319ea 0x6b6aa4f7 0xc4c7864e 0x5d7f9712 0x6027eee6 0x37f56d14 0x9555c104
|
||||
wrote packs-out/arxr32/program.json (57042 bytes)
|
||||
wrote packs-out/arxr32/identity.json (999 bytes)
|
||||
wrote packs-out/arxr32/vectors.json (6458 bytes)
|
||||
wrote packs-out/arxr32/kernel.cu (36687 bytes)
|
||||
wrote packs-out/arxr32/kernel.cl (44524 bytes)
|
||||
wrote packs-out/arxr32/program.h (7310 bytes)
|
||||
wrote packs-out/arxr32/vectors.h (5942 bytes)
|
||||
wrote packs-out/arxr32/program.metal (32681 bytes)
|
||||
wrote packs-out/arxr32/program_bound.metal (32833 bytes)
|
||||
wrote packs-out/arxr32/kernel_bound.cu (34462 bytes)
|
||||
wrote packs-out/arxr32/kernel_bound.cl (76664 bytes)
|
||||
wrote packs-out/arxr32/memhard.h (7369 bytes)
|
||||
wrote packs-out/arxr32/memhard.metal (7258 bytes)
|
||||
vector warp base 0: lane0 9580077ff8ede4e9 lane31 c860805edb3d7d65
|
||||
vector warp base 4096: lane0 7027129cabee388c lane31 2dd26ee19f07cbd1
|
||||
vector warp base 1000000: lane0 b7ff12a5456933bd lane31 81065cdec0efecca
|
||||
cache FNV-1a 64 448274a57f508cbc
|
||||
OVERALL: PASS (pack written)
|
||||
# end 2026-10-09T10:02:36Z rc 0
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
# 2026-10-09T10:02:30Z host igneum-build-9 cmd: ./bin-int8-wip export --out packs-out/int8w50r32 --epoch-hex af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3 --day-hex 69676e65756d2d6461792ffa50000000000000 --program-class v6 --era-hex edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07 --state node1-state.igsd1 --dataset-log2 30 --fam8 int8w50r32
|
||||
state stream node1-state.igsd1: chain block 159357 af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3, root 1c583d352bb9c75a06dadb8d82d42836ebe8afa82d0b413be87bf921741f1526, 93 records, 93 leaves
|
||||
igneum-pow export packs-out/int8w50r32
|
||||
seed "igneum-epoch/af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3/day/69676e65756d2d6461792ffa50000000000000", day "bytes:69676e65756d2d6461792ffa50000000000000", dataset 2^30 words (memory-hard), generator v6 attempt 0 program id e74e5ed6b9686781, loads/hash 16; epoch built in 5308.1 ms
|
||||
op mix: dp4a=20 add=13 rotl=9 sub=8 rotr=6 xor=6 load=2; class mx8-erad810f22d+sh256x27+state+reg64c+fold+int8w50r32, 64 bytes/hash, widths (1,4,16 words) [2, 0, 0]
|
||||
seed words 0x9c8caa3d 0x83537296 0xf21169a4 0x12d4d157 0x66e0ce33 0xcc049080 0x8579c352 0x4c6e4ac9
|
||||
wrote packs-out/int8w50r32/program.json (57160 bytes)
|
||||
wrote packs-out/int8w50r32/identity.json (1003 bytes)
|
||||
wrote packs-out/int8w50r32/vectors.json (6458 bytes)
|
||||
wrote packs-out/int8w50r32/kernel.cu (44831 bytes)
|
||||
wrote packs-out/int8w50r32/kernel.cl (45596 bytes)
|
||||
wrote packs-out/int8w50r32/program.h (7328 bytes)
|
||||
wrote packs-out/int8w50r32/vectors.h (5942 bytes)
|
||||
wrote packs-out/int8w50r32/program.metal (33696 bytes)
|
||||
wrote packs-out/int8w50r32/program_bound.metal (33848 bytes)
|
||||
wrote packs-out/int8w50r32/kernel_bound.cu (42606 bytes)
|
||||
wrote packs-out/int8w50r32/kernel_bound.cl (78520 bytes)
|
||||
wrote packs-out/int8w50r32/memhard.h (7369 bytes)
|
||||
wrote packs-out/int8w50r32/memhard.metal (7258 bytes)
|
||||
vector warp base 0: lane0 7613fbd09ebad410 lane31 8258dbc8c1d8bca6
|
||||
vector warp base 4096: lane0 1626c89572860ca0 lane31 bdb50545527f7a86
|
||||
vector warp base 1000000: lane0 70228df867a63fb8 lane31 63056f4571a34b41
|
||||
cache FNV-1a 64 448274a57f508cbc
|
||||
OVERALL: PASS (pack written)
|
||||
# end 2026-10-09T10:02:36Z rc 0
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
# 2026-10-09T10:02:30Z host igneum-build-9 cmd: ./bin-int8-wip export --out packs-out/int8w75r32 --epoch-hex af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3 --day-hex 69676e65756d2d6461792ffa50000000000000 --program-class v6 --era-hex edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07 --state node1-state.igsd1 --dataset-log2 30 --fam8 int8w75r32
|
||||
state stream node1-state.igsd1: chain block 159357 af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3, root 1c583d352bb9c75a06dadb8d82d42836ebe8afa82d0b413be87bf921741f1526, 93 records, 93 leaves
|
||||
igneum-pow export packs-out/int8w75r32
|
||||
seed "igneum-epoch/af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3/day/69676e65756d2d6461792ffa50000000000000", day "bytes:69676e65756d2d6461792ffa50000000000000", dataset 2^30 words (memory-hard), generator v6 attempt 0 program id 445765984996028a, loads/hash 16; epoch built in 6283.8 ms
|
||||
op mix: dp4a=44 xor=5 rotr=4 sub=4 add=3 load=2 rotl=2; class mx8-erad810f22d+sh256x27+state+reg64c+fold+int8w75r32, 64 bytes/hash, widths (1,4,16 words) [2, 0, 0]
|
||||
seed words 0x9c8caa3d 0x83537296 0xf21169a4 0x12d4d157 0x66e0ce33 0xcc049080 0x8579c352 0x4c6e4ac9
|
||||
wrote packs-out/int8w75r32/program.json (57221 bytes)
|
||||
wrote packs-out/int8w75r32/identity.json (1003 bytes)
|
||||
wrote packs-out/int8w75r32/vectors.json (6458 bytes)
|
||||
wrote packs-out/int8w75r32/kernel.cu (49376 bytes)
|
||||
wrote packs-out/int8w75r32/kernel.cl (44621 bytes)
|
||||
wrote packs-out/int8w75r32/program.h (7326 bytes)
|
||||
wrote packs-out/int8w75r32/vectors.h (5942 bytes)
|
||||
wrote packs-out/int8w75r32/program.metal (32615 bytes)
|
||||
wrote packs-out/int8w75r32/program_bound.metal (32767 bytes)
|
||||
wrote packs-out/int8w75r32/kernel_bound.cu (47151 bytes)
|
||||
wrote packs-out/int8w75r32/kernel_bound.cl (76570 bytes)
|
||||
wrote packs-out/int8w75r32/memhard.h (7369 bytes)
|
||||
wrote packs-out/int8w75r32/memhard.metal (7258 bytes)
|
||||
vector warp base 0: lane0 0682338b74a04c37 lane31 46a7253171090557
|
||||
vector warp base 4096: lane0 0b2de8e8d48c9a85 lane31 e7120f2f5c688b5d
|
||||
vector warp base 1000000: lane0 0356246b8a19b8b2 lane31 ccc92e7507e700f1
|
||||
cache FNV-1a 64 448274a57f508cbc
|
||||
OVERALL: PASS (pack written)
|
||||
# end 2026-10-09T10:02:37Z rc 0
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
# 2026-10-09T10:02:30Z host igneum-build-9 cmd: ./bin-int8-wip export --out packs-out/int8w90r32 --epoch-hex af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3 --day-hex 69676e65756d2d6461792ffa50000000000000 --program-class v6 --era-hex edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07 --state node1-state.igsd1 --dataset-log2 30 --fam8 int8w90r32
|
||||
state stream node1-state.igsd1: chain block 159357 af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3, root 1c583d352bb9c75a06dadb8d82d42836ebe8afa82d0b413be87bf921741f1526, 93 records, 93 leaves
|
||||
igneum-pow export packs-out/int8w90r32
|
||||
seed "igneum-epoch/af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3/day/69676e65756d2d6461792ffa50000000000000", day "bytes:69676e65756d2d6461792ffa50000000000000", dataset 2^30 words (memory-hard), generator v6 attempt 0 program id 0d438354ca69b247, loads/hash 16; epoch built in 6384.4 ms
|
||||
op mix: dp4a=55 load=2 rotr=2 sub=2 add=1 rotl=1 xor=1; class mx8-erad810f22d+sh256x27+state+reg64c+fold+int8w90r32, 64 bytes/hash, widths (1,4,16 words) [2, 0, 0]
|
||||
seed words 0x9c8caa3d 0x83537296 0xf21169a4 0x12d4d157 0x66e0ce33 0xcc049080 0x8579c352 0x4c6e4ac9
|
||||
wrote packs-out/int8w90r32/program.json (57255 bytes)
|
||||
wrote packs-out/int8w90r32/identity.json (1003 bytes)
|
||||
wrote packs-out/int8w90r32/vectors.json (6458 bytes)
|
||||
wrote packs-out/int8w90r32/kernel.cu (51966 bytes)
|
||||
wrote packs-out/int8w90r32/kernel.cl (44221 bytes)
|
||||
wrote packs-out/int8w90r32/program.h (7322 bytes)
|
||||
wrote packs-out/int8w90r32/vectors.h (5942 bytes)
|
||||
wrote packs-out/int8w90r32/program.metal (32161 bytes)
|
||||
wrote packs-out/int8w90r32/program_bound.metal (32313 bytes)
|
||||
wrote packs-out/int8w90r32/kernel_bound.cu (49741 bytes)
|
||||
wrote packs-out/int8w90r32/kernel_bound.cl (75770 bytes)
|
||||
wrote packs-out/int8w90r32/memhard.h (7369 bytes)
|
||||
wrote packs-out/int8w90r32/memhard.metal (7258 bytes)
|
||||
vector warp base 0: lane0 0efd4800328caf38 lane31 08b4e0d27bcc35f6
|
||||
vector warp base 4096: lane0 2fdb239b2e6cf15b lane31 1bff9b5abb29d044
|
||||
vector warp base 1000000: lane0 2bea1236f6fb35a1 lane31 ae8a005b17a4b664
|
||||
cache FNV-1a 64 448274a57f508cbc
|
||||
OVERALL: PASS (pack written)
|
||||
# end 2026-10-09T10:02:37Z rc 0
|
||||
|
|
@ -1,425 +0,0 @@
|
|||
<!-- the landed copy of the lab's interface (scratchpad/lab/INTERFACE.md on the LAB lane's Mac); the landed table is table.md beside it; both move on master at every real result -->
|
||||
|
||||
# The 1.5x laboratory interface (LAB, 9 October 2026, set 11:5x UK; every lane reads this before its first row)
|
||||
|
||||
Scope: the one evaluation every control and candidate goes through, the interfaces between the five lanes (LAB, CONTROLS,
|
||||
SEARCH, ADVERSARY, COHORT), the score, the rejection filters, the regression set, the row and batch forms, the report rule.
|
||||
The charter (scratchpad/lab/CHARTER.md) sits above this file; nothing here relaxes it. The landed copy is
|
||||
docs/analysis/class-v6/1p5x/lab/README.md on the box mirror's master; the live table is scratchpad/lab/TABLE.md.
|
||||
|
||||
## 0. The objects and their ids
|
||||
|
||||
- A candidate is an executable specification: a generator tree sha (the program class), a pack set (program.json ids, the
|
||||
class string, the fingerprints), a CPU reference, a CUDA kernel (OpenCL and Metal where the cohort needs them), a resource
|
||||
line (dataset bytes, reads per hash, read width, the working set at that width, state bytes per instance, instructions per
|
||||
hash in BOTH conventions, cold-verification cost per unit), and an id: controls `ctl-v6`, `ctl-progpow-094`, `ctl-fishhash`,
|
||||
`ctl-coop-arx`; search candidates `cand-<nn>`.
|
||||
- The frozen v6 control (`ctl-v6`): tree 1a938abe4 (branch class-v6, source fingerprint 5f4d6dc6…), signing object
|
||||
2a1d6caab4c24564 AT 2^30 WORDS = 4 GiB (dataset policy 70a6c703): the pack x1-ctrl-ds30.tgz 7606c13d (its read-only twin
|
||||
x1-ctrl-ds30-readonly.tgz 7629ed95) under build-1:/srv/artefacts/lab/controls/frozen-v6/ (CONTROLS, 12:2x UK on main's
|
||||
correction), run by the l8off kit's worker (kit c20999d1…, CUDA worker 218ca414… Windows / 9bfcf728… Linux) or the gen-6 Linux
|
||||
worker 804a6f7f…; 256 random reads per hash over 4 GiB, 62,120 ALU instructions per hash (102,612 in the counted-op
|
||||
convention), layer 8 ON. The 1 GiB research pairing hl-v6-all 0x9d40978601a7df2a (kernel fingerprint 59e6708e46f1e87c) serves
|
||||
the fingerprint cross-check row only; no 1 GiB figure is ever a cohort row of ctl-v6.
|
||||
- The work unit is energy per ACCEPTED work. For v6 and like-for-like candidates: joules per hash at equal difficulty. For a
|
||||
changed work unit (ProgPoW, FishHash, any proof-carrying unit): an accepted-work equivalence (lottery contribution per unit at
|
||||
an equal security budget) written by CONTROLS or SEARCH and approved by LAB before any number is served; raw hashes per
|
||||
second are never compared across algorithms. The comparison is always the ratio WITHIN an algorithm (its GPU energy over its
|
||||
own redesigned specialist's).
|
||||
|
||||
## 1. The measurement recipe (COHORT lane)
|
||||
|
||||
The hash lane's recipe (build-1:/srv/artefacts/1p5x/gpu-row-recipe.md, sha cbab453c) is the instrument; this section adds the
|
||||
lab's rules. Every figure's software, pack, card, clock and boundary are pinned in the row.
|
||||
|
||||
1. The instrument: the kit's own worker `--bench --pack <dir> --batches 250 --batch-log2 24 --block-warps 1 --device 0`; the
|
||||
pack self-test `check=PASS` or the row is the finding; the worker and the kit zip named by sha256; the fingerprint (FNV-1a 64
|
||||
of the 2^24 outputs at base nonce 0) equal on every platform and at every state before a rate row is read.
|
||||
2. Power: `nvidia-smi --query-gpu=timestamp,power.draw,clocks.sm,clocks.mem,temperature.gpu,utilization.gpu,power.draw.instant,power.draw.average --format=csv,noheader -l 1`
|
||||
for the whole run; watts = the mean of power.draw from 8 s in to the end of the timed dispatches; idle NOT subtracted;
|
||||
board power as the card reports it. Rate = the worker's own `mhs=`. nJ per hash = W / MH/s x 1,000.
|
||||
3. Wall where a pod allows: a pod that exposes a host-side reading (a metered PDU, the provider's node-level meter) records it
|
||||
beside the board figure as `wall_w` and the ratio is computed on both. No rented pod of ours exposes one today, so the wall
|
||||
column reads BLOCKED and the machine-term allowance of X0 section 4.4 (about +10 percent on the card's figure: a PSU at 92
|
||||
percent and the host share, approximate) is printed as a labelled third column, never applied silently to the board figure.
|
||||
4. States: stock on every rented pod (a container that refuses `-lgc` says so in the row). The knee where a lock holds (the
|
||||
5090 at `-lgc 0,1300`, the 5080 at `-lgc 0,1100`); the house rigs are BLOCKED for every lane, so knee rows exist today only
|
||||
in the record (the frozen v6 on the house 5090: 4,011 nJ at the lock, 5,842 at stock, job report 05f00bd8…) and on any rented
|
||||
pod that permits the lock (try it; keep the refusal line).
|
||||
5. The cohort, measured from the FIRST candidate: RTX 5090, 4090, 3090, 5080 (rented); the AMD tier (RX 7600 or the nearest
|
||||
RDNA card the market has) and the Intel tier (Arc B580 or the nearest) through the OpenCL kits. A tier with no reachable card
|
||||
reads NOT RUN with "no card reachable" and the score reads NOT RUN; the scope is never narrowed quietly.
|
||||
6. The pair rule: the candidate and the frozen v6 control in the same job, on the same card, at the same state, back to back;
|
||||
deltas as percent of the pair; absolutes never compared across sessions (the 13 percent enclosure-swap caveat on the first
|
||||
rig stands).
|
||||
7. The read-only twin (the memory share f): the same kernel with the arithmetic short-circuited and the dataset reads kept (the
|
||||
same read count and width, the address stream reproduced from the candidate's own address generator or a recorded trace),
|
||||
paced to the full kernel's rate (the same dispatch cadence, throttled to match), same session, same card, same state.
|
||||
f = J_twin / J_full. Both rows carry W and MH/s. The twin's fingerprint is not expected to match (it is not the function); its
|
||||
address stream is checked against the full kernel's trace (the first 4,096 addresses equal) and the check is in the row.
|
||||
8. Repeats: three timed runs per cell; the row carries the median and the spread (max minus min, percent of the median); a
|
||||
spread over 3 percent repeats the cell.
|
||||
9. The row: `RESULT lab cand=<id> ctl=<id> card=<model> pod=<fleet id> driver=<ver> kit=<sha8> worker=<sha8> pack=<program id>
|
||||
class=<class string> state=stock|lock<MHz> mhs=<x> watts=<mean> watts_instant=<mean> watts_average=<mean>
|
||||
wall_w=<x|BLOCKED> nj_per_hash=<x> nj_twin=<x|none> f=<x|none> fingerprint=<16 hex> check=PASS|FAIL twin_addr=PASS|FAIL|none
|
||||
spread_pct=<x> utc=<start>..<end>`, the worker's own RESULT line beside it verbatim; the nvidia-smi samples in the job folder;
|
||||
the folder tarred to build-1:/srv/artefacts/lab/<cand id>/<pod>/ with SHA256SUMS; rows.jsonl beside it with the same fields.
|
||||
11. Power-capped pods (COHORT's finding, 12:17 UK): on a host whose card runs at a vendor power cap the twin reads f near 1 and is
|
||||
meaningless (the card spends the saved ALU power on clock and reads the same watts), so f is read only on uncapped cards; the
|
||||
gate refuses capped hosts for the f cells; a capped row stands as a named capped row (`cap_state=capped` and the cap in the
|
||||
row) and its f is credible only where the card did not reach its cap (watts under the cap in the row). Every rented pod today
|
||||
refuses `-lgc`, so every cohort row is stock and the knee rows are the house record's until a pod that locks is found.
|
||||
10. The P03 cost line per candidate and card: rate and joules per accepted work as percent of the paired frozen v6 at the same
|
||||
state (the budget: at most 5 percent joules and 2 percent throughput for a like-for-like change, read at the knee where a
|
||||
lock exists, else at stock).
|
||||
|
||||
## 2. The adversary interface (ADVERSARY lane): what comes back per candidate
|
||||
|
||||
1. The input from LAB (the X5 section 5 trace shape, extended): `{"cand": "<id>", "tree": "<sha>", "ops_per_hash":
|
||||
{"instructions": N, "counted": Nc, "by_family": {...}}, "loads_per_hash": L, "read_bytes": W, "dataset_bytes": D,
|
||||
"state_bytes": S, "hit": {"0.01": h, "0.05": h, "0.10": h, "0.25": h, "0.50": h, "0.75": h, "1.00": 1.0}, "policies":
|
||||
["static", "trained", "adaptive"], "gpu_nj": {"5090": {"stock": x, "knee": x}, "4090": {...}, ...}}`. SEARCH or CONTROLS
|
||||
supplies the counts and the item histogram (the attack-f8 histogram over the candidate's own nonces); COHORT supplies gpu_nj.
|
||||
2. The output per candidate: `scratchpad/lab/adv/<cand id>.json` and the lane's README under
|
||||
docs/analysis/class-v6/1p5x/lab/adv/<cand id>/. One row per design, the design set never smaller than: the pure DRAM board
|
||||
(GDDR7), the hybrids at 0.25, 0.50 and 0.75 stored, the full SRAM die (one N2 reticle), recomputation, HBM3 (one stack),
|
||||
near-memory (the hash unit beside the bank), and every architecture improvement the lane finds (fused operations, register
|
||||
organisations, shared instruction storage, specialised arrays, a systolic int8 array wherever the candidate has MACs, memory
|
||||
fractions, recomputation, alternative scheduling). Each row: `{"design": ..., "node": "N5"|"N3", "nj_per_work": {"low": ..,
|
||||
"nominal": .., "high": ..}, "label": "WITNESS"|"BOUND", "convention": "instruction"|"counted", "complete_machine": true,
|
||||
"machine_terms": "board.py <sha>", "hit_used": .., "sustained_rate_mhs": .., "machine_w": .., "capex_usd": .., "usd_per_mhs":
|
||||
.., "credible": true|false, "sources": [<shas>], "witness_note": "placed"|"synthesised"|"modelled"}`. The lane's model files by
|
||||
sha (tools/chip-model/mf/results/table.csv, tools/1p5x/x5/sweep.py, tools/chip-model/mf/flow/board.py).
|
||||
3. The lowest credible specialist energy: a_same = the minimum nominal over the credible rows at N5 (low and high carried);
|
||||
a_ahead = the same at N3. Credible = capital per MH/s under ten times the card's (X5's rule) AND a complete machine. A BOUND
|
||||
row is used only as a justified lower bound L with its assumptions named; a WITNESS row is used as E_D and every ratio from
|
||||
it is labelled WITNESS (E* at or under E_D).
|
||||
4. The convention: the instruction count is the default (the units fault, REG-07); a counted-op row is carried beside it, never
|
||||
alone.
|
||||
5. Every improvement found goes into the adversarial set with a version (`adv-set v1`, `v2`, ...) and its UTC stamp; LAB re-scores
|
||||
every earlier candidate on the new set and the table names the set each score used. The strongest known specialists are kept
|
||||
throughout (the plan's stopping rule 7).
|
||||
6. Per candidate the lane also returns the family sentence: for each family in the candidate's mix, the cheapest permitted unit
|
||||
and its pJ per op against the card's (the family table of record: ARX 1.25 to 1.3x, mul 2x, mulhi and shfl 5x, int8 MAC about
|
||||
7x against an array, random reads about 13x).
|
||||
|
||||
## 3. The score (LAB)
|
||||
|
||||
1. The cells: for every cohort card c at each state s with a row, and each node n in {same, ahead}: R(c, s, n) = G(c, s) / a(n),
|
||||
G the measured board-power joules per accepted work (the wall column and the +10 percent machine-term column computed
|
||||
beside it, labelled, never mixed into R).
|
||||
2. The score = the MAXIMUM of R over every cell (the worst comparison), with its band: score = max G / a_nominal;
|
||||
score_high = max G / a_low (the adversary's optimistic end); score_low = max G / a_high. The band is printed with the score.
|
||||
3. The target: a score at or under 1.5 at the nominal AND at score_high is the only reading that counts as under; the internal
|
||||
design goal is 1.25 nominal (the guardband: 1.25 x 1.05 / 0.90 = 1.458).
|
||||
4. Missing cells: a cohort card, state or node without a row makes the score NOT RUN; a partial figure is printed as "partial
|
||||
over {cells}" and never as the score.
|
||||
5. Within an algorithm: a control's score is its GPU energy over its own redesigned specialist; the controls and candidates are
|
||||
compared on their scores, never on hashes. If every control reads poorly under the model, LAB investigates (optimistic old
|
||||
claims, a stronger adversary, a miscalibrated model) and writes the finding; never the convenient reading.
|
||||
6. The table's vocabulary: NOT RUN, BLOCKED (with the owner), REJECTED (a filter or a regression test, the reason named), FAIL
|
||||
(a measured score over 1.5 at the nominal), SCREEN (a simulator's or model's score, no measurement yet), MEASURED (the cohort
|
||||
rows and the adversary rows in). PASS is never written by any lane (X6's blind run and the panel).
|
||||
|
||||
## 4. The rejection filters (before any score; any one rejects, the reason printed on the table)
|
||||
|
||||
- F1 correctness: bit-exact fingerprints across the CPU reference, CUDA and OpenCL (Metal where present) at every state; the
|
||||
pack self-test PASS; for a proof-carrying unit, a verifier that accepts every GPU output and refuses every known-failed fixture.
|
||||
- F2 verification budget: cold verification of one accepted unit on one CPU core at or under the frozen v6's; proof bytes,
|
||||
invalid-proof amplification, pool share checking and propagation inside the fleet's node limits (R15-10; B6's table on master
|
||||
1ab735be9 is the reference). A unit with no measured cold-verification row is NOT RUN, not rejected.
|
||||
- F3 practical generation: deterministic generation from the epoch seed on every platform; the pack exportable by the kit lane's
|
||||
recipe (the cross-build on build-1 at the x86-64-v3 baseline); generation time at or under the frozen class's at the epoch
|
||||
boundary.
|
||||
- F4 the honest-GPU cost limits (P03): a like-for-like change pays at most 5 percent joules per accepted work and 2 percent
|
||||
throughput against the paired frozen v6 (the knee where a lock exists); a changed work definition carries its comparison
|
||||
contract and the budget applies to the equivalent unit. No padding that costs ordinary miners more than it costs the chip: a
|
||||
candidate whose GPU energy rises by more than its specialist's is rejected by the mixture lemma before measurement.
|
||||
- F5 nothing but joules: attestation, quotas, proving revenue, resale, chip cost and rotation count for nothing in R (they may
|
||||
sit in a separate economics column, never in the score).
|
||||
- F6 the resource line stated: dataset bytes, the working set at the candidate's read width, state bytes per instance; the full
|
||||
SRAM, hybrid and recompute designs stay in the set after every change; where the working set fits one reticle the die is
|
||||
scored first.
|
||||
|
||||
## 5. The regression set (every failure of 8 and 9 October as a test)
|
||||
|
||||
A candidate that repeats a failed mechanism under a new name fails the test and is REJECTED; re-entry only with a written
|
||||
statement of what differs materially from the failure mechanism, read by LAB before any row.
|
||||
|
||||
| Test | The failure of record | The test on a candidate |
|
||||
|---|---|---|
|
||||
| REG-01 connected state | 8 Oct, KILL: the adversary's clock-gated register file; the window width was the one robust knob (+0.14 k at the lock); the cs64 research class | a candidate whose lever is state kept live between reads, or liveness, is scored against the clock-gated register-file row; no credit for "the chip must keep the state powered" |
|
||||
| REG-02 mixed FP32 | 8 Oct, KILL: +15 to 26 percent card energy for +7 percent chip edge (FP32 FMA k 0.4 to 0.5) | any FP32 or masked-float family in the mix reads the FP32 k from the family table; a mix whose card cost rises more than the chip's fails F4 |
|
||||
| REG-03 window removal (X1) | 9 Oct: g 0.98 to 1.00 on four cards; the hottest half serves 0.56 (signing) to 0.61 (mean), not 0.72; a half store still serves 0.50 (H(q) at or above q/N); the windows buy nothing for the board or the die; the hybrid's a capped at 1.14 to 1.29 | a candidate whose lever is address flattening, permutation or "no hot set" is screened by the static-cache bound: the hybrid rows at hit = q/N are its best case and the die serves every read whatever the distribution |
|
||||
| REG-04 int8 mixes (X8) | 9 Oct: against a systolic array (0.30 pJ per MAC, 0.10 to 0.60) every int8 mix at 10, 50 and 90 percent raises the ratio (the board 2.4x to 2.7x, the die 8x to 14x); dp4a-only (1.54x at 90 percent) is not the adversary's cheapest unit | any tensor, MAC or dot-product family puts the systolic array in the adversary set; a row against dp4a alone is refused |
|
||||
| REG-05 the scattered-read ARX point (X8b) | 9 Oct: ARX-only at 32 per-lane scattered reads 3.55x same node / 4.38x a node ahead (dollars 11.2x); at 16 reads 3.85x / 4.92x: fewer scattered reads raised the ratio | a candidate that changes only the count of per-lane scattered reads is screened by R = 1 / ((1 - f) / 1.3 + f / 13); a new mechanism needs the cooperative shape (lanes on one fetched entry, every byte consumed, the arithmetic feeding the next address) |
|
||||
| REG-06 the memory-route verdict (X7) | 9 Oct: f = 0.853 on the 4090 at stock (8,091 nJ against the twin's 6,899); s = 12.8 (GDDR7 board), 22 (HBM3), 39 (beside the bank) per read; r = s / f; no read-dominated design reaches 1.5x at any f | a candidate whose claim is "more read-dominated" or "more bytes" is screened by the bridge r = 1 / (f / s + (1 - f) / c) with X7's s and the candidate's own f; the term it must change is the card's cost per read (27 nJ all-in on the 4090 at stock; 14 all-in and 8.7 marginal on the 5090 at its knee), not the count |
|
||||
| REG-07 the units fault | 9 Oct 11:4x UK: a "counted op" is the GPU's 1.83 micro-op tally; the shadow is 55,296 instructions; the placed rows multiplied 102,612 counted ops by a per-instruction chip energy and overstated the chip's shadow by 1.85x | every adversary row names its convention; the instruction count is the default; a counted-op row without the instruction row beside it is refused; every candidate's op count is reported in both conventions |
|
||||
| REG-08 layer 8 off | register row 18, closed 04:49 UK 9 Oct: off at the knee costs the card 5.7 percent of rate and 3.6 to 5.7 percent of joules; the chip side gains 4 percent at the mean hit | the layer stays ON in every control row; a candidate that removes it carries its own pair row |
|
||||
|
||||
Also of record (X0, the decider): the frozen v6 costs the card 1.78x to 1.84x class v4's joules per hash at stock (twice the reads
|
||||
for 9 to 10 percent more ALU) and the chip's memory term doubles while its shadow holds. Every ratio is read on ITS OWN pair
|
||||
(chip and card on the same program), never spliced across programs or snapshots.
|
||||
|
||||
## 6. The candidate table (scratchpad/lab/TABLE.md)
|
||||
|
||||
Columns: candidate id; kind; tree and pack ids; the resource line (SEARCH or CONTROLS); the cohort rows per card (stock nJ, knee
|
||||
nJ, f, spread; wall BLOCKED or the figure); the adversary rows (a_same, a_ahead, the lowest credible design, the adv-set
|
||||
version); the worst score with its band; status; the next move with its owner and clock. LAB writes the table; the lanes send
|
||||
rows; the table is current within ten minutes of a row.
|
||||
|
||||
## 7. The report rule
|
||||
|
||||
- A lane reports to LAB and to main on a real change only: a measured row read back, an adversary row set, a rejection, a fault
|
||||
with its cause and clock. Never a status note. The form: one line, UK time from `date`, the figure, what it means on the score,
|
||||
the evidence path.
|
||||
- LAB's line to main at every real result: a control's ratio, a candidate's score, an adversary improvement that re-ranks, a
|
||||
fault. The first full line: the frozen v6's worst-cohort score.
|
||||
- A slip once, with its cause and the new clock. Nothing on the devnet is touched; no founder input is ever asked for; builds and
|
||||
runs on the boxes and rented pods only; kills by recorded pid after reading the pid's command line.
|
||||
|
||||
## 8. The registry and the landings
|
||||
|
||||
- Suite R15; the lab's cell `lab:candidate` (cases R15-01, R15-08, R15-09); every row NOT RUN until read by the panel (X6 blind).
|
||||
The batch form (tools/ci/batches/lab-<date>-<nn>.json, as the recorder takes it): run_id, manifest_sha = the candidate's tree
|
||||
(the frozen control 1a938abe4), method GPU for card rows and model for chip rows, evidence_dir, release_identity with
|
||||
network_object "none: ...", cells `[{cell: "lab:candidate", cases, status, in_progress, method, evidence, note, claim_impact}]`;
|
||||
recorded with `node tools/ci/test-record.mjs --record <batch>`.
|
||||
- The landing: a worktree off build/master; `tools/ci/pre-push.sh` (the full gate on the branch); `MERGE_REMOTE=build
|
||||
tools/ci/merge-to-master.sh` (the box mirror, under the master-landing lock on build-1); read back with
|
||||
`git fetch build master` and the file's sha on the mirror. Documents under docs/analysis/class-v6/1p5x/lab/; a lane's rows under
|
||||
docs/analysis/class-v6/1p5x/lab/<lane>/<cand id>/; artefacts under build-1:/srv/artefacts/lab/<cand id>/.
|
||||
- Clocks: this file 12:15 UK; the frozen v6's worst-cohort score when COHORT's and ADVERSARY's rows are in (by 13:4x UK); every
|
||||
later candidate scored within one hour of its last row.
|
||||
|
||||
## 8a. X10, the full-sector fold candidate (the founder's third review, 11:5x UK; main's digest scratchpad/lab/review-3-x10.md)
|
||||
|
||||
The working scope from now, by default, until the founder says otherwise: the ENERGY CLAIM is the same-node board only (a
|
||||
programmable core plus a GDDR controller, the same process node as the reference GPU, the dataset in external DRAM, no on-package
|
||||
store larger than a cache); the SRAM or HBM die and every node-ahead chip stay in the table as COEXISTENCE rows, labelled out of
|
||||
the energy claim (ticket, fleet cost, margin). Every other rule of this file holds; the score column for X10 carries the board
|
||||
column as the claim and the die and node-ahead columns beside it.
|
||||
|
||||
The object (`cand-x10`), built exactly as reviewed, one object fixed at genesis, no shadow ladder: a chain of DEPENDENT 32-byte
|
||||
sector loads from the 4 GiB dataset, the address of load n a function of the bytes of load n - 1; all 32 bytes folded into the
|
||||
lane state with 32-bit integer MAD, XOR and shifts; between loads a fixed mix of 32-bit integer MAD, shifts and bitwise ops, a
|
||||
64-register window retained across every rotation, a 32-lane shuffle at least every four arithmetic ops; no 64-bit multiply, no
|
||||
division, no wide permute, no matmul; the read count = the largest count whose joules per hash on the locked 5090 is within 10
|
||||
percent of the frozen v6's, the search starting at 64 loads (2 KiB useful traffic), cut before any width is added; the low address
|
||||
bits folded before every era boundary; a draw refused if any fraction of the set takes more than its size share of loads (the
|
||||
0.995 acceptance floor pointed at this address-share test); hourly program and weekly era draws stay (a versatility tax, outside
|
||||
the energy model); the 180-day family epoch out until the bracket has passed; the verifier one warp under 10 ms on the reference
|
||||
core.
|
||||
|
||||
Two facts of record conflict with the reviewer's premises; X10 is the measurement that settles them, never an argument: (1) the
|
||||
board reads 2.35x same node at the knee after X0's corrections (1.90 to 2.65), not the 1.5 to 1.6x of X0's first reading, so the
|
||||
"remaining tenth" is 0.85x on the corrected figure; (2) X7's s = 12.8 on the chip's own GDDR7 board for the same traffic (the card
|
||||
about 27 nJ per random read all-in against about 2 nJ for the burst) says the card's read-path fabric, not the DRAM burst, is the
|
||||
dominant term. The reviewer's answer is the full-sector fold (8x the useful bytes per read, 8x fewer reads for the same traffic);
|
||||
whether that moves the card's cost per useful byte toward the chip's is what X10 measures.
|
||||
|
||||
The assignments (earliest clocks; one line per real result to LAB and main):
|
||||
|
||||
| Lane | X10 work | Clock UK |
|
||||
|---|---|---|
|
||||
| CONTROLS | the readwidth fact of record: does the frozen v6 read 4 bytes of a 32-byte sector or fold the whole sector (the readwidth row, by sha); the card's measured energy per 32-byte random sector read at the knee and at stock on the 5090 and the 4090 by X7's read-only-twin method, and the same with every byte of the sector folded (the ALU-fold cost), as a paired same-session row set | the readwidth fact 12:30; the sector rows 14:00 |
|
||||
| SEARCH | X10 exactly as specified above: CPU reference + CUDA kernel (bit-exact), 64 dependent 32-byte loads to start, the read count set by the 10 percent budget on the locked 5090 (a rented pod that holds the lock, else the house record's pair convention stated), the address-share test at the 0.995 floor, the verifier timed under 10 ms; the trace JSON of section 2.1 (both conventions) to ADVERSARY; the kit to COHORT through the kit lane's recipe | the kernel and reference 14:00; the kit and the trace 14:30 |
|
||||
| ADVERSARY | the same-node board on X10 under the reviewer's model rule (external GDDR, same node, the cache hit rate = cache size over 4 GiB, no stable hot set), the ENERGY SPLIT published (memory, ALU, the fixed-function tax) and not only the ratio, under BOTH conventions (ours: the cheapest programmable core; ProgPoW's: a chip that keeps a GPU-class datapath); the die and the node-ahead rows beside it as coexistence rows, labelled out of the energy claim; the same split on the frozen v6 for the pair | the v6 split 13:30; the X10 board rows within one hour of SEARCH's trace |
|
||||
| COHORT | the frozen denominator: the locked 5090 and 5080 (a rented pod that holds the lock, else stock with the lock refusal in the row), a 4090, one AMD card when rentable (NOT RUN with the reason until then), on the frozen v6 and on X10, same session pairs, the read-only twin on both; the 10 percent cross-vendor budget line (FAIL if the AMD card or the 4090 pays more than 10 percent over the budget against v6) | the v6 denominator rows 12:3x (in flight); the X10 rows within one hour of the kit |
|
||||
| LAB | the verdict line: the board at or under 1.5 on every denominator card, or the number it reads and which term (memory, ALU, tax) holds it above; FAIL CLOSED per the review (above 1.5 after the burst change: stop; no added arithmetic, no family rotation, no set growth); the PASS condition is never written by the lab (the board column at or under 1.5 on every card, the memory share of card energy at least half, the verifier under 10 ms, the GPU-cost budget held, then the coexistence rerun: the panel reads it) | within one hour of the last X10 row |
|
||||
|
||||
The twin for X10: the dependent-address chain is kept by construction (the fold of each sector into the next address stays; the
|
||||
rest of the ALU mix is dropped), so the twin's address stream equals the full kernel's and `twin_addr` reads `by construction
|
||||
<kernel sha>` without a trace instrument; the same ruling covers ctl-v6 (the X7 twin, kernel 93a6a5ce): `twin_addr=none` is accepted
|
||||
on the control rows and no instrument is built before the first candidate.
|
||||
|
||||
### 8a.1 X10 facts of record (12:2x UK)
|
||||
|
||||
- The readwidth fact (CONTROLS): the frozen v6 reads ONE 32-bit word per load site and never folds the sector. Proof lines in the
|
||||
l8off kit's packs/hl-v6-all (program.json 9eec9e325a820c16, program.h bfd9230e231ffb2c, kernel_bound.cu 77cabb0602ee7ad5):
|
||||
program.h `IGNEUM_LOADS_PER_HASH 128`, `IGNEUM_WIDE_LOADS_PER_HASH 0`, `IGNEUM_LOAD_WIDTH_COUNTS { 16, 0, 0 }`; kernel_bound.cu
|
||||
line 115 a 4-byte `ds[...]` xor into one register; the reg64c mirror repeats each site: 256 executed loads per hash, 1,024 bytes
|
||||
consumed of the 8,192 bytes of 32-byte sectors the memory system moves. The read-width rows of record (the first rig's 5090 at the
|
||||
1,300 lock, 8 Oct): W = 4 words 2.29 uJ per hash, W = 8 2.55 (+11 percent), W = 16 4.09 (+78 percent). So X10's premise (the
|
||||
card moves a sector and consumes 4 bytes of it) is true of record, and folding the sector costs the card's ALU (+11 percent at
|
||||
8 words on that row).
|
||||
- The fault of record in the Sector reference as written (SEARCH): the sector index reads lane registers 0 and 1; the fold writes
|
||||
dst = (load x 8 + k) & 63, so registers 0 and 1 are rewritten only every eighth load; 7 of every 8 loads re-read the same sector
|
||||
(every fixture's lane-0 trace: two fresh addresses, then six repeats); about 16 distinct dependent sector addresses per lane-hash;
|
||||
the other 102 loads are L1 or L2 hits on the card and free on a chip. The author's split (memory 35 / fold 55 / tax 10) and the
|
||||
10 percent budget rest on 118 real misses. Evidence scratchpad/lab/x10-sector/cuda/fixtures.txt (columns 5 to 12), hash.ts lines
|
||||
95 to 112. RULING: cand-x10 is built and measured bit-exact to the reference as written (its own row, labelled "16 real misses");
|
||||
cand-x10b is main's ratified address rule (load n reads the two registers the previous load's fold wrote last; zero repeats in
|
||||
every trace), measured beside it; both kits under build-1:/srv/artefacts/lab/cand-x10/kit/ (sector.cu 16417ce6, run-x10.sh;
|
||||
VARIANT=x10b for the corrected one) and cand-x10b/. The CUDA kernel is bit-exact with the reference on all 18 fixtures (a rented
|
||||
4090, 11:11 UTC; 112 registers, no spills).
|
||||
- The accepted-work contract for X10, APPROVED by LAB: the lottery unit is the warp digest; the column comparable to the frozen
|
||||
v6's joules per hash is joules per LANE-WORK (per digest over 32); both are printed on every row; the P03 budget and every ratio
|
||||
are read per lane-work; the ratio is unit-independent while both sides use the same unit; the security-budget line (energy per
|
||||
lottery draw at an equal target) is printed beside it per the units rule (8c.2).
|
||||
- The first X10 rows (SEARCH, a rented 4090 at stock, SCREEN until COHORT's paired rows): 118 loads 810 to 845 nJ per lane-work
|
||||
(25.9 to 27.0 uJ per digest) at 264 to 275 W; 64 loads 567 nJ per lane-work at 294 W; a tenth of v6's 8,091 nJ on the same card
|
||||
class, which is the cache hits of the fault, not a design gain.
|
||||
|
||||
## 8b. The coexistence table (the founder, 12:0x UK; the scoreboard's headline beside the energy ratio; ADVERSARY's job, due with the controls this afternoon)
|
||||
|
||||
The outcome the founder has set is COEXISTENCE: GPU owners remain competitive, no lasting overwhelming supplier advantage, no
|
||||
emergency intervention. So every control and candidate carries a coexistence table next to its score, built by ADVERSARY with the
|
||||
placed core and the X8b dollar method, every number a WITNESS with its source, the fleet size and the design-cost assumption
|
||||
stated on the row, no raw hashes. Rows per adversary: the same-node DRAM board, the stored-half hybrid, the N2 SRAM die, the
|
||||
node-ahead board. Columns:
|
||||
|
||||
1. Energy edge at the knee: the card's nJ per hash over the chip's (the complete machine), with the band.
|
||||
2. Chip capital per MH/s in USD: the die (the placed core's mm2 at the node x the claimed wafer cost and yield), the memory (4 GiB
|
||||
of GDDR7, USD 320 of devices of record, or the SRAM at USD 250 per GiB claimed), the board and assembly (USD 200 of record), the
|
||||
design cost (the mask set and the design team, the sum stated) spread over a stated fleet (machines and MH/s); against the card's
|
||||
USD per MH/s on the SAME object (15.7 of record on the X8b row; 30.6 on the frozen v6 at the knee in adv-set v1): both pairs
|
||||
stated.
|
||||
3. The GPU owner's electricity-only cost per hash at three prices, USD 0.05, 0.10 and 0.15 per kWh, in USD per TH (nJ per hash x
|
||||
price per kWh / 3,600; the card is sunk).
|
||||
4. The chip's all-in cost per hash at the same three prices, USD per TH: its electricity (its nJ per hash x the price) plus its
|
||||
capital per MH/s amortised over a stated life (months, straight line, the chip's sustained MH/s) plus the design-cost share; the
|
||||
life and the fleet stated on the row.
|
||||
5. The verdict per row and per price: does the chip's all-in cost per hash fall BELOW the GPU owner's electricity-only cost per hash?
|
||||
yes = the GPU owner is pushed out; no = coexistence holds. The row prints the margin (chip all-in over GPU electricity-only) so a
|
||||
reader sees how far from the line it sits.
|
||||
|
||||
The table's file: scratchpad/lab/adv/<cand id>-coexistence.json beside the design rows (the same keys as the columns above, one
|
||||
object per adversary row with its assumptions) and the lane's README section; LAB carries the four verdict cells (one per
|
||||
adversary row at USD 0.10) on TABLE.md beside the score, the full table in the landed README.
|
||||
|
||||
## 8c. Scoreboard rulings (the founder's fourth and fifth reviews, through main, 12:5x and 13:1x by main's clock; binding on every served or committed sentence)
|
||||
|
||||
1. Labels: every ratio names its adversary (the same-node DRAM board, the stored-half hybrid, the N2 SRAM die, the node-ahead
|
||||
board), its column (energy, capital, cost ratio), its clock state (knee or stock), the card and the set version; never a blended
|
||||
figure.
|
||||
2. The coexistence table's last column is "cost ratio" (the chip's all-in cost per TH over the GPU owner's electricity-only cost
|
||||
per TH), never "margin". A revenue column: the block reward times the token price under growing, flat and falling scenarios, as
|
||||
revenue per accepted work in each algorithm's OWN units (the units rule, CONTROLS), never raw terahashes across algorithms.
|
||||
3. "All three tested tariffs" (USD 0.05, 0.10, 0.15 per kWh), never "every electricity price"; the tariff at which the two curves
|
||||
meet is stated per row (the reviewer's linear read on ctl-v6's board row: about USD 0.33 per kWh).
|
||||
4. "No fleet size reaches break-even" appears only with its fleet, amortisation and revenue rows shown.
|
||||
5. Two phases per adversary (board, hybrid, die), with the development cost both recoverable and sunk. Phase A, before investment:
|
||||
can the maker recover its spend under growing, flat and falling demand with realistic hardware sales. Phase B, after investment
|
||||
with the spend sunk: the ongoing cost per TH on both sides (the chip's electricity, hosting and maintenance itemised: of the
|
||||
chip's USD 0.265 per TH at ten cents about 0.043 is electricity, the other 0.222 itemised as capital, design or recurring), the
|
||||
realised contribution against revenue, who exits first as difficulty rises, GPU participation and supplier concentration over
|
||||
time.
|
||||
6. The SRAM die stays a live opponent in both phases; where it is called implausible the row names the assumption that fails.
|
||||
7. The NEW-BUYER column: a new card's capital plus electricity against the chip's, per tier, with the indifference point; under
|
||||
each growth schedule with the chip's memory capital scaling with the set (4, 8, 16 GiB) against a card buyer who already holds
|
||||
16 to 32 GB, and the set size at which the chip's capital per MH/s meets the card's. The USED-CARD column: a 4090 and a 3090 at
|
||||
today's used prices (source stated), resale retained at 24 months, the chip's resale zero after a program-draw change. No
|
||||
sentence about supplier advantage appears before the new-buyer column exists; ADVERSARY's first result (12:1x): the new buyer
|
||||
picks the chip on every row and tier (the board 0.24x to 0.43x a new card's all-in at ten cents; the die 0.04x; indifference at
|
||||
USD 9.7 per MH/s on the 5090 knee, 10.5 on the 5080, 5.4 on the 5090 stock, 2.6 on the 4090; the only hold the project share
|
||||
below about 13,500 boards sold).
|
||||
8. The card's random-read headroom (COHORT with CONTROLS; the lab's most important physical row, the only lever on phase B): the
|
||||
achieved sector reads per second on v6 (rate x 256) per cohort card against that card's random-activation bound from its memory
|
||||
timing (channels, banks, tRC) and against the chip's 148 MH/s on the same memory kind; if the card sits well under its bound the
|
||||
miner-side lever is more loads in flight per core (software pipelining across lane-hashes, occupancy, batch shape) and SEARCH
|
||||
cuts a worker variant that measures it: a faster card at the same price is the direct answer to the chip's USD 14 per MH/s.
|
||||
9. The approved wording, verbatim, until the two-phase rows exist: "The current model finds that existing GPU electricity costs
|
||||
are below the assessed DRAM-board specialist's all-in costs at the tested tariffs and stated lifetime assumptions. This supports
|
||||
a conditional entry-deterrence result. It does not yet establish post-deployment competitiveness, viable GPU replacement or new
|
||||
entry, or coexistence against the modelled SRAM fleet at sufficient scale. Those outcomes remain separately assessed."
|
||||
Withdrawn everywhere: "the winning outcome is met", "only one design decision remains", "no lasting supplier advantage", "no
|
||||
other GPU chain has this written down" (the last needs a comparative review of what other chains publish: a row of the table,
|
||||
NOT RUN until done). The sentence the rows support today: a DRAM-board chip can be built and does not undercut a card its owner
|
||||
already has; the store-on-die chip is the residual, held by the growth schedule.
|
||||
10. The 4 GiB proving cap defined precisely on the record: which of the mining state, the proof-generation allocation, the
|
||||
verifier requirement or the advertised hardware profile it limits (CONTROLS states it from the proving manifest and the record;
|
||||
ADVERSARY the economics).
|
||||
11. (the sixth review, 12:3x UK) The revenue column splits revenue by hardware: a GPU earns the mining share plus the proving
|
||||
share of the block reward (the ratified split from the Token Value volume on master, D01 to D06 and TV-04, taken from the
|
||||
documents by path and sha, never invented), a hash chip the mining share only; phase B (spend sunk) is recomputed with that
|
||||
split for the board, the hybrid and the die, with the proving share at which the chip's operating advantage over the GPU
|
||||
disappears at ten cents; the row is labelled NOT LIVE until a shipped package runs a prover (today the HiveOS package mines
|
||||
only; proving turns on at 24 GB) and a shipping row is open for it (TABLE.md, the shipping row: which packages prove today
|
||||
at what VRAM and by what switch, the 24 GB threshold and whether a 16 GB card can prove a smaller instance, the share of the
|
||||
live cohort that can prove; a HiveOS prover a named 2.0.3.1 item beside snapshot sync). The phase B row states the
|
||||
assumption that all proving stays on the GPU cohort, with the sentence: a chip maker who fields GPUs to prove is a GPU miner
|
||||
with a sidecar. ADVERSARY's first result (545f3f8a0): the proving share at which the chip's operating advantage disappears at
|
||||
ten cents is 9 percent for the board, 13 for the hybrid, 20 for the die, against the ratified 20.
|
||||
14. The 8e "4 GiB fault" is corrected (12:4x UK, once): every cohort cell ran the live 4 GiB object (the self-test's dataset word
|
||||
1,073,741,823 on every cell; dataset_words on every row); CONTROLS' 13.16 MH/s was its worker build 804a6f7f at 20 x 2^22, not
|
||||
the object; the decider names the instrument difference; the 4 GiB rule (every row names its dataset words) stands.
|
||||
12. (the sixth review) The per-epoch tuple rotation (bytes folded per line, register-window depth, shuffle distance, chosen from a
|
||||
committed list and published one epoch early) is costed by ADVERSARY as versatility tax on the programmable core (what it
|
||||
adds to the chip's shadow and what it costs the card) beside the hourly and weekly draws, and is NOT scored as a defence
|
||||
against the programmable adversary; the lab's measured X10 verdict (31 against 31.6 nJ per read, 5.8x against 5.4x on the same
|
||||
4090) is the answer to the review's rule 2 and the growth rows the answer to its rule 3.
|
||||
13. A SCREEN cell under 1.5 (the first: cand-x10 at 520k ALU on set v3, 1.03x with the card assumed equal to v6) is never served
|
||||
as a result; it is a measurement order (SEARCH builds the point, COHORT measures it paired with v6, the P03 line decides
|
||||
before the ratio).
|
||||
Owners: ADVERSARY 2 to 7, 10's economics, 11 and 12; CONTROLS the units rule and 10's definition; COHORT 8 with CONTROLS; LAB the
|
||||
wording, the layout and the labels.
|
||||
|
||||
## 8d. The founder's lab bundle (14:00 UK with the measured controls)
|
||||
|
||||
build-1:/srv/artefacts/lab/founder-bundle-<stamp>.zip, built by /srv/artefacts/lab/make-bundle.sh (no identity text: no names,
|
||||
logins, rig names or the zone word; the script greps every text file and refuses on a hit): the interface, the scoreboard with the
|
||||
coexistence table and its assumptions, the adversarial set v2 design notes and the placed rows, the frozen-v6 control kit with the
|
||||
4 GiB packs and sidecars, the cohort rows, the controls' rows, the X10 record; the path and sha256 to main.
|
||||
|
||||
## 8e. The 4 GiB rule and the post-deployment lever (main, 12:3x UK)
|
||||
|
||||
CONTROLS measured the live 4 GiB signing object (x1-ctrl-ds30, 2^30 words) at 13.16 MH/s on a rented 4090 at stock against 31.28
|
||||
for the 1 GiB research pack on the same card and session (3.4 against 7.9 G dataset reads per second on the same memory). Rules:
|
||||
(1) the 4 GiB figure is the one every scoreboard row carries from now; every 1 GiB row is relabelled research and is never a cohort
|
||||
cell; (2) the decider (COHORT, one session per card: the 1 GiB pack, the 4 GiB pack, both twins, ABBA, the dataset words on every
|
||||
row), then the cause read directly (the worker's occupancy and loads in flight at 4 GiB from its RESULT line; CONTROLS' pure-read
|
||||
kernel at 1, 2 and 4 GiB in steps against the card's page size and L2 TLB coverage; the layer-8 window shape over 2^30 words),
|
||||
then the first worker-side fix the cause names (SEARCH: larger pages or a different allocation, more independent lane-hashes in
|
||||
flight per warp, a window shape that keeps the page set hot), measured as a pair against the frozen worker on the same card with a
|
||||
bit-exact fingerprint, the gain in MH/s per card and the new nJ per hash; (3) one line to main at the decider, one at the cause,
|
||||
one at the first fix pair. Recovering the loss halves every card's USD per MH/s at no gain to the chip: it is the phase B lever.
|
||||
|
||||
## 8f. The ledger order (the founder, 12:5x UK; binding; the deliverables reordered)
|
||||
|
||||
The primary objective within five hours: the statement "Someone can build a chip, but ordinary GPU owners remain competitive,
|
||||
the supplier cannot obtain a lasting overwhelming advantage, and the network does not depend on emergency intervention to survive"
|
||||
made true and verifiable. The lab's lanes feed the GPU NETWORK LEDGER (the LEDGER lane a101aa8329d1e82bf assembles; it does not
|
||||
re-measure) FIRST: (1) every live GPU chain measured the same way on the same rented 4090 and 5090 in one session each (Igneum v6
|
||||
at 4 GiB, KAWPOW via ProgPoW 0.9.4 at the live DAG, FishHash, Etchash via the pinned chfast hashimoto at ETC's current DAG,
|
||||
Autolykos2 if the in-house rule and the clock allow), the set v3 board ratio and the ProgPoW-premise ratio per chain, the two-phase
|
||||
rows with each chain's actual reward split (only Igneum pays provers), and the E9 Pro calibration line (ADVERSARY models the one
|
||||
real chip against Etchash; the gap to its public rate and power is the model's calibration, stated); (2) the proving row flips
|
||||
LIVE on the PROVING LIVE lane's on-chain evidence (af561127dc1bc7044, one full epoch of paid rows, by 16:00; today's read: 663 paid
|
||||
v1 segment records to 30 keys on Devnet 4, payments paused since the hub roll, the cause under fix); (3) the comparative-review row
|
||||
(what other chains publish about their resistance, verbatim with source) so the ledger's claim is checkable; (4) the hash candidate
|
||||
search continues fail-closed at lower priority (the 520k-ALU point and the headroom row close their records; no new families).
|
||||
Clocks: the ledger table on the record 15:30, the kit 16:30, the page 17:30. Every row MEASURED or WITNESS with its source;
|
||||
"pending" never blank; the BUILD STEWARD a3d365ef4a3a107a4 fills any idle box the lab names. The ledger's registry cell:
|
||||
ledger:cross-chain beside lab:candidate on R15-09, NOT RUN until the kit's rows reproduce; its paths: the scoreboard table.md and
|
||||
README.md at the box master's sha at 15:00, the conventions on the ctl-v6 and ctl-progpow-094 rows. Correction to 8c.11: the
|
||||
32 GB prototype-shard clause is Devnet 1's; on Devnet 4 (fees_v1_activation_daa 0) a 24 GB NVIDIA card proves the v1 shard and
|
||||
aggregates beside its miner now; the 16 GB tier holds no shard.
|
||||
|
||||
## 8g. The "no emergency intervention" row (main, 13:1x UK; measured behaviour of record)
|
||||
|
||||
The statement's third clause ("the network does not depend on emergency intervention to survive") is carried on the scoreboard as
|
||||
a measured row, never an argument: Devnet 4 runs finality rule v3 (the frozen table expiring at lock DAA + 7,200); finality has
|
||||
been paused since lock 1980; the roll restores signing weight; the first new lock is expected 15:30 to 17:00 UK; through the pause
|
||||
the chain ran on PoW, nothing certified reversed, and proving was unaffected once the finality gate on provers was lifted. The vote
|
||||
key is label-derived (no key file; a fresh datadir keeps it). The row flips from "paused, chain on PoW" to "locked" on the hub's
|
||||
first new lock line; the ledger page cites it with the DAA and the UK time.
|
||||
|
||||
The LAB stays on watch until the ledger page lands (17:30 UK): the scoreboard and table current as COHORT's five-chain session,
|
||||
ADVERSARY's ledger rows, the proving epoch record (16:00) and the comparative review (15:30) land; make-bundle.sh re-run for the
|
||||
14:00 edition and at 17:30 with the ledger rows, each edition's path and sha to main.
|
||||
|
||||
## 9. The lane briefs, one line each
|
||||
|
||||
- CONTROLS: the four controls as rows of the table: `ctl-v6` from the record and re-measured on the cohort; `ctl-progpow-094`
|
||||
built from the official 0.9.4 source with its test vectors (kawpowminer master a proxy only); `ctl-fishhash` from Iron Fish's
|
||||
implementation; `ctl-coop-arx` (the CUDA kernel and CPU reference by 14:00 UK); each with its own optimised GPU implementation
|
||||
and its accepted-work equivalence contract; the traces (op counts in both conventions, the item histogram) to ADVERSARY and
|
||||
the kits to COHORT.
|
||||
- SEARCH: candidates as executable specifications with explicit resource limits over arithmetic composition, dependency
|
||||
structure, state layout, lane cooperation, memory-transaction shape and work granularity; each through F1 to F6 and REG-01 to
|
||||
REG-08 on the simulator before a kit is cut (a SCREEN score); the top of the SCREEN ranking to COHORT and ADVERSARY as kits
|
||||
and traces.
|
||||
- ADVERSARY: section 2 per candidate; the set versioned; every earlier candidate retested on each new set.
|
||||
- COHORT: section 1 per candidate on the full cohort from the first candidate; the rows to build-1:/srv/artefacts/lab/; the P03
|
||||
lines.
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
# Proving live on Devnet 4: the on-chain record (9 October 2026)
|
||||
|
||||
The 1.5x scoreboard's phase B row reads "a GPU earns the proving share of the block reward and a hash chip cannot". This page is the evidence that the proving share is paid on the live devnet to GPUs proving beside their miners, read from the chain and nothing else. One epoch is 3,600 DAA; Devnet 4's DAA runs at 1.0 per second (63,577 at 11:59:15Z to 63,902 at 12:04:30Z), so an epoch is one hour.
|
||||
|
||||
## The rule being exercised, by file and line (node 27f54124, the 2.0.3 line)
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| 20 percent of every block subsidy to the proving pool, 80 to the producer | consensus/core/src/igneum.rs:44 (`PROVING_POOL_SHARE_PERCENT`), :87 (`proving_pool_share`) |
|
||||
| a carried record pays its shard part of the pool credit to the record's payout address when valid, verified and first | igneum/exec/src/proving.rs:551 (`carried_payouts`) |
|
||||
| a v1 segment (8 chain blocks, 8 shards + one aggregation) pays as one record, 1,000 bps of the credit to the aggregator | consensus/core/src/proving.rs:638 (`split_pool_credit`); igneum/exec/src/proving.rs `segment_status` Proven |
|
||||
| fees v1 from DAA 0 on the devnet (the shard is the calibrated v1 size, 30,000 pgas, never the prototype) | consensus/core/src/config/params.rs:2307 (`DEVNET_PARAMS.fees_v1_activation_daa: 0`); the node reports `fees.v1ActivationDaa 0`, `shardBudget 0x7530` |
|
||||
| what a prover reads and submits | `igneum_getProvingStatus`, `igneum_getAssignedShards`, `igneum_submitProofRecord`, `igneum_submitSegmentRecord`, `igneum_getSegmentRecords` (igneum/exec/src/rpc.rs:1342, :1443, :1628) |
|
||||
|
||||
## What proves today
|
||||
|
||||
The fleet's prover stack on rented RunPod and Vast boxes: `tools/fleet/box-prover.py` (one loop per box, beside the box's kit-2 miner, MINER=none so the miner is never paused) driving the served 0317 prover pair (`igneum-prove-host` sha256 71bc2438…, `igneum-prove-export` 263bf4ce…, SP1 6.8.1, shard program id 0x2b1a81cb…, aggregator id 0x474678f3…, the pair the node pins) and the SP1 GPU server. The same binaries are what the 2.0.3.1 HiveOS package wraps; the Mac and Windows apps carry the same prover, on by default at 23,552 MiB or more (app/igneum-app/src/provedefault.rs:23-24). Cards: RTX 4090 (24,564 MiB), RTX 3090 (24,576), L40S and RTX 6000 Ada (48 GB) as aggregators. Measured on a 4090 beside its miner: one 8-block segment in 117 to 185 s end to end (shards 48 to 52 s, aggregation 57 to 61 s), peak 13,523 to 14,899 MiB; a 16 GB card cannot hold the shard (the LAB's floor rows).
|
||||
|
||||
## Baseline before the restart (read 11:50:22Z, lp-4090-02's node, chain id 4465, executed tip 31,719, DAA 62,833)
|
||||
|
||||
| counter | value |
|
||||
|---|---|
|
||||
| paid v1 segment records since genesis | 663 = 653.93 IGN |
|
||||
| distinct prover keys paid | 30 (median 4 segments a key, maximum 230) |
|
||||
| payout addresses | 3 (the fleet's wallets) |
|
||||
| paid v0 shard records (the pre-segment path) | 6,474 = 7,196 IGN |
|
||||
| proving pool balance | 27,706 IGN |
|
||||
| carried records refused as duplicates ("segment already paid") | 952 of 2,199 carried |
|
||||
| carried records refused as late ("unproven", after the 600 DAA deadline) | 461 |
|
||||
|
||||
Evidence file: build-1:/srv/artefacts/proving-live/prov-ledger-4465-tip31719-115022Z.json (sha256 bd3f53c18c3ffa20354ea62ade9fd5e7a5794202a71c3c369a5be71484f7143a), every segment from genesis with its paid row and carried records.
|
||||
|
||||
Why payments had stopped at 11:15Z: Devnet 4's finality paused at 11:15:57Z under the hub roll (signing weight 4 to 25 percent of the frozen table), and every fleet prover gates on a finality pause (the 8 October build claims only below the finality-settled block; the V6-08 build holds after 600 s). The gate is the prover's own policy; payment never depended on finality (`carried_payouts`). The provers were restarted as generation 2 at 11:59Z with the gate lifted and the chain paid again from 12:02Z.
|
||||
|
||||
## The epoch of evidence (12:02Z to EPOCH_END)
|
||||
|
||||
EPOCH_TABLE
|
||||
|
||||
## The sentence the row carries
|
||||
|
||||
EPOCH_SENTENCE
|
||||
|
||||
## Access to proving work (main's question of 13:1x UK)
|
||||
|
||||
Segments are allocated by first valid record: 8 assignees per shard drawn from the keys in the record window hold a 10 DAA exclusive window (consensus/core/src/proving.rs:26, :30, :255, :305), after which any key may prove and the first valid record carried is paid; no stake, no queue; leases are off on the live nodes. A newcomer competes equally from its first record; a prover on a synced node claims within one 15-second pass (lp-4090-02 claimed 15 s after its restart). With about 227 segments an hour on this devnet and 120 to 185 s a proof, 8 to 11 provers saturate the work and every prover past that dilutes the fixed 20 percent as 1/N; about a third of carried records today are duplicate proofs. The rule change is docs/design/proving-claim-order.md (node change after 2.0.3; nothing on the chain today).
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
# The SEARCH lane of the 1.5x laboratory (9 October 2026): the constrained workload search, its candidates and its rows
|
||||
|
||||
The lab's charter (scratchpad/lab/CHARTER.md) and interface (docs/analysis/class-v6/1p5x/lab/README.md) sit above this file.
|
||||
Nothing here is a PASS; every chip figure is a WITNESS on the adversarial set named beside it; every card figure names its
|
||||
card, state, pod and pack. Branch `lab-search` (off `1p5x-int8` on the frozen tree 1a938abe4), tools under `tools/lab/`.
|
||||
|
||||
## 1. The system
|
||||
|
||||
| Part | What it is | Where |
|
||||
|---|---|---|
|
||||
| The knobs | arithmetic composition (a weight table over add, sub, xor, rotl, rotr, mul, mulhi, mad, shfl, dp4a; the ARX families, int8 MAC and FP32 FMA allowed, mul, mulhi and shfl as explicit points), dependency structure (the drawn chain, reg64c's interleave, `+ilp2`, `+ilp4`), state layout (8 or 64 registers per lane), lane cooperation (scattered per-lane entries; the warp-coalesced entry modelled only), memory-transaction shape (entry 4 to 64 bytes realisable, 128 to 1,024 modelled; reads per hash 16 to 256; the dataset 2 to 8 GiB), work granularity (the shadow block 64 to 1,024 instructions x 4 to 108 passes) | `tools/lab/search/labsearch.py` |
|
||||
| The executable specification | a class string on the lab tree plus `params.env` (the limits: the verifier 10 ms on the F6 proxy, the dataset at or under 8 GiB for the 16 GB floor, P03 overhead at or under 5 percent, registers under 96, 16,384 to 340,000 instructions per unit) and `trace.json` in the adversary's section 2.1 form | `candidates/cand-NN/` |
|
||||
| The generator flags the search needed | `+labw<20 hex>[r<reads>]` (a generic non-load table, the reads per hash on the fam8 load-slot rule), `+ilp<2 or 4>` (the eight registers as independent banks: W chains per lane; 8 is refused, one register per bank has no bijection), `+flat` parsing outside a fam8 suffix; the program id binds every flag | `igneum-pow/src/generator.rs`, `igneum-pow/tests/labsearch.rs` |
|
||||
| The cost model | the card: a throughput model per tier and state (the measured twins: the 4090 at stock 24.9 nJ per dependent read above idle and 21.2 pJ per instruction; the 5090 at the knee 12.0 nJ and 11.8 pJ on the drawn chain against 6.2 on an ILP-4 chain; the 3090 and 5080 rows PROVISIONAL from the class v4-shape rows), the per-unit fixed work, the coalesced entry at the streaming cost; the chip: the X5 machine (memory at its sustained rate or the SRAM die's power bound, the core sized to the ops, PSU, VRM, fan, host share) on the family table of the adversarial set in force (v0 the placed core; v2 ADVERSARY's fused units with a per-design scale fitted so the frozen object reads v2's own rows) | `calib.json` |
|
||||
| The score | the worst cell over the cohort (5090 knee and stock, 4090 stock, 3090 stock, 5080 knee and stock) against the lowest credible specialist (board, HBM3, near-memory, N2 die, stored-half hybrid; capex under 10x the card's USD per MH/s), with the band; since 11:5x the BOARD cell first (the energy claim's scope) and the die beside it | `labsearch.py score` |
|
||||
| The regression tests | REG-01 to REG-07 as predicates on the knob vector, by name (connected state; mixed FP32; window removal alone; int8 at or above 10 percent; scattered ARX at or under 32 reads; scattered reads above 256; the units fault) | `regression.json` |
|
||||
| The box side | the census (8 chain-shaped seeds through the class's own acceptance rule, attempts and exhaustion) and the pack export on the signing pair (epoch af89be5d, era edc4fa84, day 20730, node1's state, 2^28 words for screening and 2^30 for a handed candidate), SHA256SUMS per candidate | `run-candidates.sh`, build-9:/srv/builds/lab-search/ |
|
||||
| The card side | the paired row (ABBA against the frozen control in one session, the gen-6 worker, 250 x 2^24, the sampler rule, PAIR lines with USD per MH/s at a street price) and the headroom sweep (the worker's loads-in-flight knobs beside its memprobe) | `pair-bench.sh`, `headroom.sh` |
|
||||
|
||||
## 2. The candidates staged (build-1:/srv/artefacts/lab/)
|
||||
|
||||
| Set | Candidates | Ranking | Census | Self-test (F1) |
|
||||
|---|---|---|---|---|
|
||||
| batch 1 (adv-set v0 top ten) | cand-01 to cand-10: ARX-only at 48 to 64 reads, 170k to 340k instructions, w4/w16/w32/w64 entries | candidates/ranking-batch1-v0.md, -v2.md | 8 of 8 on every one | PASS 10 of 10 (rented 4090, gen-6 worker --check, 11:43 UTC) |
|
||||
| batch 2 (adv-set v2 top ten on the labw and ilp knobs) | cand-11 to cand-20: MAD-heavy `+labw...+ilp4+flat` draws at 256 reads, two ARX-only points | candidates/ranking-batch2-v2.md | 8 of 8 on nine; cand-17 0 of 8 (exhausted, REJECTED on practical generation) | queued |
|
||||
| the merged v2 ranking | the twenty on one table by the board cell | candidates/ranking-merged-v2.md | | |
|
||||
| the ILP-wide redraw of the frozen program | cand-ilp4, cand-ilp2 (the same ops, the same 256 reads and shadow, the registers in 4 or 2 banks; 4 GiB signing pair) | the frozen object's cells | attempt 2 and 0 | PASS |
|
||||
| X10 "Sector" (the founder's reference) | cand-x10 (as written) and cand-x10b (main's ratified address rule), cand-x10 variant alu-520k | ADVERSARY's cand-x10-*.json | the reference's own acceptance floor | bit-exact on 18 and 18 and 9 fixtures |
|
||||
| the frozen v6 controls | controls/frozen-v6/headroom/ (variant 1), controls/frozen-v6/fix-4gib/v6x2-ds30 (two lane-hashes per thread, `interleave2.py`) | | | PASS |
|
||||
|
||||
## 3. The findings of record (12:0x to 13:0x UK)
|
||||
|
||||
1. On the frozen tree's realisable space no scattered-read design screens under 3.7x on the worst cell on set v0 and none under 2.4x on the board cell at the 5090 knee on set v2 (the frozen object 2.66x there); the mixture lemma holds on every set: a candidate moves the ratio only through a family the adversary prices near the card's, and on v2 that family is mad (2.9x), so the top of the v2 ranking is MAD-heavy ILP-4 draws (cand-14 2.42x board at the 5090 knee, 5.20x on the provisional 3090 stock row).
|
||||
2. The ILP-wide redraw, measured (4090 stock, in-session ABBA, spread 0.00): rate +0.00 percent, joules -0.64 percent (ilp4) and +0.00 (ilp2). The card is on its dependent-read ceiling (30.697 MH/s x 256 = 7.86 G reads/s) with the ALU in the reads' shadow; a cheaper instruction stream cannot raise the rate on v6.
|
||||
3. X10 "Sector" as written re-reads the same sector 7 loads in 8 (the address reads registers 0 and 1, rewritten by the fold every eighth load): about 16 real misses per lane-hash; 827 nJ per lane-work on the 4090 at stock is a cache artefact of the fault, not a design gain (LAB's ruling). cand-x10b (every load a fresh miss): 3,653 nJ per lane-work at 118 loads, 31.0 nJ per dependent read against v6's 31.6, the card at its read ceiling, 5.8x on ADVERSARY's v2 board row against v6's 5.4x on the same card: the full-sector fold buys nothing on the ratio (REG-06).
|
||||
4. The 4090's read headroom (variant 1): fifteen cells of block-warps x batch size flat within 0.2 percent at 7.85 to 7.86 G reads/s; the memprobe's dependent 4-byte chase at 1 GiB saturates at 8.06 to 8.15 G loads/s from 65k to 4.2M lanes in flight and with 8 independent loads per lane: the live object at 4 GiB runs at 97 percent of the card's activation bound; larger pages are not available (cuMemGetAllocationGranularity 2 MiB minimum and recommended on driver 12.8).
|
||||
|
||||
## 4. The rows of 13:0x to 13:2x UK (the same rented 4090 at stock; the pod destroyed at 12:24 UTC)
|
||||
|
||||
| Row | Figure | Reading |
|
||||
|---|---|---|
|
||||
| v6x2 (two lane-hashes per thread, interleave2.py) against the frozen control, ABBA | 30.697 against 30.697 MH/s (+0.00 percent), 8,574 against 8,615 nJ per hash (-0.47 percent), the fingerprint equal (b1f90e91cadb15b6) | 8c.8 closed: with the fifteen loads-in-flight cells flat at 7.85 to 7.86 G reads/s and the memprobe's 8.1 G/s ceiling, the 4090 is at its bound; no worker variant raises its rate per dollar on v6 |
|
||||
| cand-x10b at 64 loads | 122.2 M lane-works/s at 243.5 and 246.8 W: 1,993 and 2,020 nJ per lane-work (63.8 and 64.7 uJ per digest), 31.1 nJ per read | the same read ceiling as at 118 loads; the sector fold rides free |
|
||||
| cand-x10 variant alu-520k (x10b's address rule, 1,408 steps per load) | fixtures PASS 9 of 9 (97 registers, 0 spills); 15.8 M lane-works/s at 351 to 366 W: 22,152 and 23,225 nJ per lane-work (709 and 743 uJ per digest) | 2.6x to 2.7x the frozen v6's joules per hash on the same card (8,594 in the session's control cell): the P03 10 percent budget failed; the card pays about 42 pJ per instruction on this text (41,536 shuffles per lane-work); ADVERSARY's v3 1.03x assumed the card equal to v6 |
|
||||
| batch 2 self-tests | cand-11 to cand-20 PASS 10 of 10 (the worker's --check) | cand-17 stays REJECTED on the census (0 of 8) |
|
||||
| the three handed to COHORT and ADVERSARY at 4 GiB | cand-14, cand-12, cand-13 (candidates/<id>/pack-ds30, SHA256SUMS-ds30) | SCREEN on v2: board 2.42 to 2.43x at the 5090 knee against the frozen 2.66x; the die above 10x |
|
||||
|
||||
## 5. Rows pending at this cut
|
||||
|
||||
The x10b 64-load rows, the alu-520k fixture check and row, the v6x2 pair, batch 2's self-tests (the rented 4090; the pod is
|
||||
destroyed on the lane's done line); the 5090 knee pairs and the AMD and Intel tiers (COHORT); the adversary rows per candidate
|
||||
on set v3 (ADVERSARY). Every row lands beside its candidate under /srv/artefacts/lab/ and in this directory's `rows/`.
|
||||
|
||||
## 6. Every artefact by path and sha (build-1, 13:3x UK)
|
||||
|
||||
| Artefact | Path | sha256 (16) |
|
||||
|---|---|---|
|
||||
| the ranking on set v0 (batch 1) | /srv/artefacts/lab/candidates/ranking-batch1-v0.md | bbab9085bbfa35b7 |
|
||||
| the ranking on set v2 (batch 1; batch 2) | candidates/ranking-batch1-v2.md; candidates/ranking-batch2-v2.md | 5a6278fe2b65ab4e; fe62b202efcf5afb |
|
||||
| the merged rankings on v2 and v3 (this directory carries copies) | candidates/ranking-merged-v2.md; candidates/ranking-merged-v3.md | ed075af407a0ef12; 7ad24b0c965a71a2 |
|
||||
| the handed three at 4 GiB, kernel.cu | candidates/cand-14/pack-ds30; cand-12/pack-ds30; cand-13/pack-ds30 | 7e86d32bbe1ba100; c93ef7220f320b4a; a4f26e4f1b921a9a |
|
||||
| the ILP-wide redraw, kernel.cu | candidates/cand-ilp4; candidates/cand-ilp2 | 67b65a21ca4c8a8b; 6df1ffb96221373c |
|
||||
| X10: sector.cu (both kits, -DX10B for x10b); sector520.cu (alu-520k) | cand-x10/kit; cand-x10b/kit; cand-x10/kit-alu520k | 8d137d1fb19010ca; 8d137d1fb19010ca; 34cbc7999d0c28d3 |
|
||||
| the frozen v6 controls: v6x2-ds30/kernel.cu (two lane-hashes per thread); headroom.sh | controls/frozen-v6/fix-4gib; controls/frozen-v6/headroom | 202b16239ad1b795; 5cfef1ad212beeb4 |
|
||||
| the row folders (102 files, one SHA256SUMS) | rows/lab-search-4090-pod/SHA256SUMS | cf91736badb50eb0 |
|
||||
| the ILP pair rows; the headroom cells; the v6x2 pair | rows/lab-search-4090-pod/lab/pair-ilp.log; lab/headroom.log; lab/pair-v6x2.log | 823709eae0c9db28; 538f7f5d91dbbae6; ab61fe5f016b52aa |
|
||||
| the self-tests, batches 1 and 2 | lab/selftest.log; lab/selftest2.log | 5823f94036ad60fc; 9bb8792edb0d79f3 |
|
||||
| X10 rows: x10 as written; x10b (118 then 64 loads); alu-520k | x10/run-x10-long.log; x10/run-x10b.log; x10/run-x10b-2.log; x10/run-alu520k.log | 22320101d56f3251; ce99e6d524a5995f; d7ef8f48d0e46b85; 35a7e649db01fa4f |
|
||||
| the tools and the generator flags | branch lab-search on the box mirror, tip 719d4f223 (tools/lab/search, tools/lab/x10, igneum-pow/src/generator.rs, igneum-pow/tests/labsearch.rs) | by branch |
|
||||
|
||||
The rented pod (one-shot, 4090, driver 570.211.01, CUDA 12.8) was destroyed at 12:24 UTC on the lane's done line; the queue file for build-9 was removed when the lane's last batch ended.
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
# Batches 1 and 2 re-scored on adversarial set v2 (SEARCH, 12:5x UK 9 Oct 2026): ranked by the BOARD cell (the energy claim's scope), the die and the other designs beside it (coexistence rows); every chip cell v2 (ADVERSARY's 12:0x rows, the per-design scale fitted on the frozen object: board 1.03, hbm3 1.06, near 0.76, die 1.26, hybrid 1.25); the frozen object on the same model: board 2.66x at the 5090 knee, 5.35x at the 4090 stock, die 12.2x. Self-test (F1): cand-01..10 PASS on a rented 4090 (gen-6 worker --check, 11:43 UTC); cand-11..20 queued. Census (F3): 8 of 8 chain-shaped seeds on every candidate except cand-17 (0 of 8, exhausted: REJECTED on practical generation). The 3090 and 5080 card rows remain PROVISIONAL until COHORT measures the frozen object on them.
|
||||
|
||||
| cand | class | board 5090 knee (v2) | BOARD worst cell (v2) | worst credible cell, all designs (v2) | node ahead | instrs/hash | reads | status |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| cand-11 | w16m8g+sh1024x13+state+fold+labw0f0f140c0d0000190000+ilp4+flat | 2.46 | 5.19 (3090 stock) | 17.51 (3090 stock vs sram) | 21.47 | 112348 | 256 | SCREENED |
|
||||
| cand-12 | mx8+sh128x36+state+fold+labw0f0f140c0d0000190000+ilp4+flat | 2.43 | 5.20 (3090 stock) | 27.95 (3090 stock vs sram) | 32.83 | 40412 | 256 | SCREENED |
|
||||
| cand-13 | w32m8g+sh64x54+state+fold+labw0f0f140c0d0000190000+ilp4+flat | 2.43 | 5.20 (3090 stock) | 29.05 (3090 stock vs sram) | 33.98 | 36572 | 256 | SCREENED |
|
||||
| cand-14 | w32m8g+sh128x18+state+fold+labw0f0f140c0d0000190000+ilp4+flat | 2.42 | 5.20 (3090 stock) | 30.85 (3090 stock vs sram) | 35.62 | 27356 | 256 | SCREENED |
|
||||
| cand-15 | mx8+sh256x13+state+fold+labw12121213130000080000+ilp4+flat | 2.43 | 5.23 (3090 stock) | 33.11 (3090 stock vs sram) | 38.01 | 30172 | 256 | SCREENED |
|
||||
| cand-16 | mx8+sh512x4+state+reg64c+fold+arxr64 | 2.47 | 5.23 (3090 stock) | 17.61 (3090 stock vs sram) | 21.70 | 20354 | 64 | SCREENED |
|
||||
| cand-17 | w16m8g+sh256x13+state+fold+labw12121213130000080000+ilp4+flat | 2.44 | 5.23 (3090 stock) | 31.06 (3090 stock vs sram) | 35.73 | 32476 | 256 | SCREENED |
|
||||
| cand-18 | mx8+sh128x36+state+fold+labw12121213130000080000+ilp4+flat | 2.44 | 5.24 (3090 stock) | 30.06 (3090 stock vs sram) | 34.90 | 40412 | 256 | SCREENED |
|
||||
| cand-19 | w32m8g+sh1024x4+state+fold+labw12121213130000080000+ilp4+flat | 2.44 | 5.24 (3090 stock) | 29.73 (3090 stock vs sram) | 34.55 | 41692 | 256 | SCREENED |
|
||||
| cand-20 | mx8+sh256x9+state+reg64c+fold+arxr48 | 2.50 | 5.24 (3090 stock) | 14.90 (3090 stock vs sram) | 18.65 | 22040 | 48 | SCREENED |
|
||||
| cand-05 | mx8+sh256x81+state+reg64c+fold+arxr48+flat | 3.91 | 7.84 (3090 stock) | 13.08 (3090 stock vs near) | 15.48 | 169496 | 48 | SCREENED |
|
||||
| cand-06 | mx8+sh256x81+state+reg64c+fold+arxr48 | 3.91 | 7.84 (3090 stock) | 13.08 (3090 stock vs near) | 15.48 | 169496 | 48 | SCREENED |
|
||||
| cand-04 | w32m8g+sh1024x36+state+reg64c+fold+arxr96 | 3.92 | 7.87 (3090 stock) | 13.43 (3090 stock vs near) | 15.72 | 301621 | 96 | SCREENED |
|
||||
| cand-08 | mx8+sh1024x27+state+reg64c+fold+arxr64 | 3.97 | 7.94 (3090 stock) | 13.31 (3090 stock vs near) | 15.71 | 225154 | 64 | SCREENED |
|
||||
| cand-09 | mx8+sh256x108+state+reg64c+fold+arxr64 | 3.97 | 7.94 (3090 stock) | 13.31 (3090 stock vs near) | 15.71 | 225154 | 64 | SCREENED |
|
||||
| cand-07 | w16m8g+sh1024x27+state+reg64c+fold+arxr64 | 3.97 | 7.95 (3090 stock) | 13.31 (3090 stock vs near) | 15.71 | 225730 | 64 | SCREENED |
|
||||
| cand-01 | mx8+sh256x108+state+reg64c+fold+arxr48+flat | 4.17 | 8.31 (3090 stock) | 13.43 (3090 stock vs near) | 16.08 | 224792 | 48 | SCREENED |
|
||||
| cand-02 | mx8+sh256x108+state+reg64c+fold+arxr48 | 4.17 | 8.31 (3090 stock) | 13.43 (3090 stock vs near) | 16.08 | 224792 | 48 | SCREENED |
|
||||
| cand-03 | w32m8g+sh256x108+state+reg64c+fold+arxr48 | 4.17 | 8.32 (3090 stock) | 13.43 (3090 stock vs near) | 16.09 | 225800 | 48 | SCREENED |
|
||||
| cand-10 | w64m8g+sh512x81+state+reg64c+fold+arxr64 | 4.37 | 8.76 (3090 stock) | 15.17 (3090 stock vs near) | 18.18 | 338626 | 64 | SCREENED |
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# The search candidates re-ranked on ADVERSARY's set v3 (SEARCH, 13:2x UK 9 Oct 2026): the card side from the lane's calibrated screen (5090 knee 4,109 nJ for the frozen object; the 3090 and 5080 rows PROVISIONAL), the chip side from ADVERSARY's per-candidate v3 rows (scratchpad/lab/adv/cand-NN.json: the cheapest DRAM design = the board column, a_same = the lowest credible design, usually the full SRAM die with fused operations). The board cell is the energy claim; the die is a coexistence row. Every candidate self-test PASS; cand-17 REJECTED on the census (0 of 8).
|
||||
|
||||
| cand | class | board cell at the 5090 knee, set v3 (band) | board, worst card cell (card) | die at the 5090 knee (v3) | board a node ahead | die a node ahead | board 5090 knee on my v2 model | v3 lowest credible design |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| cand-11 | w16m8g+sh1024x13+state+fold+labw0f0f140c0d0000190000+ilp4+flat | 2.42 (1.92 to 2.83) | 5.10 (3090 stock) | 8.3 | 2.57 | 10.5 | 2.46 | full SRAM die(s) + sized + fused operations |
|
||||
| cand-13 | w32m8g+sh64x54+state+fold+labw0f0f140c0d0000190000+ilp4+flat | 2.51 (1.99 to 2.90) | 5.38 (3090 stock) | 16.0 | 2.57 | 18.9 | 2.43 | full SRAM die(s) + sized + fused operations |
|
||||
| cand-19 | w32m8g+sh1024x4+state+fold+labw12121213130000080000+ilp4+flat | 2.51 (1.99 to 2.90) | 5.38 (3090 stock) | 15.5 | 2.57 | 18.4 | 2.44 | full SRAM die(s) + sized + fused operations |
|
||||
| cand-12 | mx8+sh128x36+state+fold+labw0f0f140c0d0000190000+ilp4+flat | 2.51 (1.99 to 2.90) | 5.38 (3090 stock) | 15.5 | 2.57 | 18.3 | 2.43 | full SRAM die(s) + sized + fused operations |
|
||||
| cand-18 | mx8+sh128x36+state+fold+labw12121213130000080000+ilp4+flat | 2.52 (2.00 to 2.91) | 5.41 (3090 stock) | 16.3 | 2.58 | 19.1 | 2.44 | full SRAM die(s) + sized + fused operations |
|
||||
| cand-14 | w32m8g+sh128x18+state+fold+labw0f0f140c0d0000190000+ilp4+flat | 2.52 (2.00 to 2.91) | 5.42 (3090 stock) | 17.4 | 2.57 | 20.2 | 2.42 | full SRAM die(s) + sized + fused operations |
|
||||
| cand-17 | w16m8g+sh256x13+state+fold+labw12121213130000080000+ilp4+flat | 2.53 (2.01 to 2.91) | 5.43 (3090 stock) | 16.9 | 2.57 | 19.6 | 2.44 | full SRAM die(s) + sized + fused operations |
|
||||
| cand-15 | mx8+sh256x13+state+fold+labw12121213130000080000+ilp4+flat | 2.54 (2.01 to 2.92) | 5.45 (3090 stock) | 19.0 | 2.58 | 21.9 | 2.43 | full SRAM die(s) + sized + fused operations |
|
||||
| cand-16 | mx8+sh512x4+state+reg64c+fold+arxr64 | 2.64 (2.10 to 3.06) | 5.59 (3090 stock) | 11.0 | 2.74 | 13.5 | 2.47 | full SRAM die(s) + sized + fused operations |
|
||||
| cand-20 | mx8+sh256x9+state+reg64c+fold+arxr48 | 2.67 (2.12 to 3.11) | 5.60 (3090 stock) | 9.6 | 2.81 | 11.9 | 2.50 | full SRAM die(s) + sized + fused operations |
|
||||
| cand-04 | w32m8g+sh1024x36+state+reg64c+fold+arxr96 | 3.09 (2.47 to 3.72) | 6.19 (3090 stock) | 4.1 | 3.66 | 5.2 | 3.92 | hybrid, hottest three quarters in SRAM + sized + fused operations |
|
||||
| cand-07 | w16m8g+sh1024x27+state+reg64c+fold+arxr64 | 3.12 (2.50 to 3.77) | 6.25 (3090 stock) | 4.2 | 3.73 | 5.4 | 3.97 | full SRAM die(s) + sized + fused operations |
|
||||
| cand-08 | mx8+sh1024x27+state+reg64c+fold+arxr64 | 3.12 (2.50 to 3.77) | 6.26 (3090 stock) | 4.2 | 3.73 | 5.5 | 3.97 | full SRAM die(s) + sized + fused operations |
|
||||
| cand-09 | mx8+sh256x108+state+reg64c+fold+arxr64 | 3.12 (2.50 to 3.77) | 6.26 (3090 stock) | 4.2 | 3.73 | 5.5 | 3.97 | full SRAM die(s) + sized + fused operations |
|
||||
| cand-05 | mx8+sh256x81+state+reg64c+fold+arxr48+flat | 3.13 (2.51 to 3.78) | 6.27 (3090 stock) | 4.2 | 3.74 | 5.5 | 3.91 | full SRAM die(s) + sized + fused operations |
|
||||
| cand-06 | mx8+sh256x81+state+reg64c+fold+arxr48 | 3.13 (2.51 to 3.78) | 6.27 (3090 stock) | 4.2 | 3.74 | 5.5 | 3.91 | full SRAM die(s) + sized + fused operations |
|
||||
| cand-03 | w32m8g+sh256x108+state+reg64c+fold+arxr48 | 3.19 (2.56 to 3.87) | 6.36 (3090 stock) | 4.1 | 3.87 | 5.3 | 4.17 | full SRAM die(s) + sized + fused operations |
|
||||
| cand-01 | mx8+sh256x108+state+reg64c+fold+arxr48+flat | 3.19 (2.56 to 3.88) | 6.37 (3090 stock) | 3.8 | 3.88 | 4.9 | 4.17 | near-memory (base dies) + sized + fused operations |
|
||||
| cand-02 | mx8+sh256x108+state+reg64c+fold+arxr48 | 3.19 (2.56 to 3.88) | 6.37 (3090 stock) | 4.0 | 3.88 | 5.2 | 4.17 | hybrid, hottest three quarters in SRAM + sized + fused operations |
|
||||
| cand-10 | w64m8g+sh512x81+state+reg64c+fold+arxr64 | 3.32 (2.65 to 4.06) | 6.65 (3090 stock) | 4.2 | 4.00 | 5.3 | 4.37 | hybrid, hottest three quarters in SRAM + sized + fused operations |
|
||||
|
||||
the frozen object on v3: board 1541 nJ, die 335 nJ; my 5090 knee card 4,109 nJ: board 2.67x, die 12.3x
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
<!-- the landed copy of the lab's candidate table at this landing; the live table is scratchpad/lab/TABLE.md on the LAB lane's Mac -->
|
||||
|
||||
# The 1.5x laboratory candidate table (LAB; current as of 12:2x UK 9 October 2026 by `date`; the interface: scratchpad/lab/INTERFACE.md)
|
||||
|
||||
Score = the WORST comparison over the cohort (same node and one node ahead), the candidate's GPU energy per accepted work over the
|
||||
lowest credible specialist energy for the same work (interface section 3). The working scope since 11:5x UK (8a): the same-node
|
||||
board is the energy claim; the die and node-ahead chips are coexistence rows. The coexistence table (8b, under the rulings of 8c) is
|
||||
the headline beside the score. Every ratio names its adversary, column, clock state, card and set (8c.1). PASS is never written
|
||||
here. Every chip figure is a WITNESS (E_D) unless marked BOUND, CLAIMED or SCREEN. Energies in nJ per hash (per lane-work for X10).
|
||||
|
||||
The approved claim wording (8c.9), verbatim: "The current model finds that existing GPU electricity costs are below the assessed
|
||||
DRAM-board specialist's all-in costs at the tested tariffs and stated lifetime assumptions. This supports a conditional
|
||||
entry-deterrence result. It does not yet establish post-deployment competitiveness, viable GPU replacement or new entry, or
|
||||
coexistence against the modelled SRAM fleet at sufficient scale. Those outcomes remain separately assessed."
|
||||
|
||||
## The controls and candidates
|
||||
|
||||
| id | kind | tree, packs, kits | resource line | cohort rows (board power, stock on every rented pod, lock refused everywhere; wall BLOCKED; median of three) | adversary rows (set v2, instruction convention, complete machine) | worst score (band), labelled | coexistence (8b under 8c) | status | next move (owner, clock UK) |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| ctl-v6 | control (frozen) | 1a938abe4; the signing object 0x2a1d6caab4c24564 at 2^30 words = 4 GiB (pack x1-ctrl-ds30.tgz 7606c13d, twin 7629ed95; CONTROLS 12:2x on main's correction, build-1:/srv/artefacts/lab/controls/frozen-v6/ with sidecars); kits: l8off c20999d1 (CUDA 218ca414 / 9bfcf728 Linux), gen-6 bb66a546 (Linux CUDA 804a6f7f, OpenCL 371dbb27); COHORT's rows on kit bf9b85d6, worker f5f3846c; the 1 GiB pairing only a cross-check row | 4 GiB; 256 loads per hash of ONE 32-bit word each (1,024 bytes consumed of 8,192 moved; the readwidth fact of record, 8a.1); 62,120 instructions (102,612 counted); state class; layer 8 ON; cold verification 8.3 to 8.6 ms per warp of 32 on one core (the record; the fresh F2 row 13:30) | STOCK (COHORT 12:17, fp b1f90e91cadb15b6 PASS on every cell): RTX 3090 pod 55006505 (capped 260 of 350 W) 8,429 at 30.80 MH/s; RTX 3090 pod 55006552 (capped 220 of 420) 7,109 at 30.90; RTX 5090 pod 55006481 (384 of a 500 W cap, uncapped in effect) 5,597 at 68.66, f 0.811; RTX 5090 pod 55006478 (capped 400 of 600) 5,828 at 68.64; RTX 5080 pod 55006521 (189 of 330, clean) 5,410 at 35.03, f 0.806; the 3090 twins read f 1.02 (meaningless on a capped card). KNEE: the house record only (5090 at 1,300: 4,011). In flight: 4090a (repeat triple at 3.98 percent spread), 4090b, 5080b, uncapped 5090c and 3090c. AMD, Intel: NOT RUN "no rented card; the house rigs unreachable by software" | v2 (ADVERSARY 12:0x to 12:1x; commits f9cb259cc routed lanes, 923f76e74 coexistence, 7c6b538f1 price sheet on class-v6-adversary): the cheapest same-node DRAM board a = 1,543 (1,328 to 1,947) = 2.60x (2.06 to 3.02) at the 5090 knee, 2.70x a node ahead; the split chip memory 527 / ALU 167 / tax 849 against the card's 3,642 / 369 / 0; the tax ladder: the record's static 2.60x, half 3.28x, zero (the bound) 4.43x; ProgPoW's premise 1.01x to 1.02x; the N2 die a = 337 = 11.9x (coexistence) | PARTIAL over {3090, 5090, 5080 at stock; the 5090 knee from the record}; the 4090 cells pending; AMD and Intel NOT RUN. The worst cell: the RTX 3090 at stock on the same-node DRAM board, set v2: 5.46x (4.33 to 6.35) same node, 5.68x a node ahead; the die at that cell 25.0x. The other cells on the board: 3090 (capped) 4.61x, 5090 stock 3.63x, 5090 (capped) 3.78x, 5080 stock 3.51x, 5090 knee 2.60x; the record's rented 4090 at stock (8,091) would read 5.24x | ctl-v6 on set v2 (WITNESS; a 100,000-machine fleet, a 24-month straight-line life, design USD 35 M board chip and 300 M die, the card sunk at USD 30.6 per MH/s on v6 at the knee, 15.7 of record beside it): the same-node board, edge 2.60x at the knee, chip capital USD 14.04 per MH/s (die 0.34, memory 5.42, board 3.15, design 5.13); GPU electricity-only 0.056 / 0.111 / 0.167 USD per TH at the three tested tariffs against the chip's all-in 0.244 / 0.265 / 0.287: cost ratio 4.4x / 2.4x / 1.7x (the curves meet about USD 0.33 per kWh, the reviewer's linear read; ADVERSARY's exact figure owed); the stored-half hybrid, edge 3.80x, USD 9.17: 2.9x / 1.6x / 1.1x; the N2 die, edge 11.9x, USD 1.59: 0.5x / 0.3x / 0.2x, the GPU owner's electricity above the chip's all-in once the fleet passes 14,300 dies at ten cents (break-even 14,300 to 25,000 under the growth schedules; no schedule kills the die at 100,000 dies); the node-ahead board, edge 2.70x: 4.4x / 2.4x / 1.7x. NEW BUYER (8c.7): picks the chip on every row and tier (the board 0.24x to 0.43x a new card's all-in at ten cents, the die 0.04x; indifference USD 9.7 per MH/s on the 5090 knee, 10.5 on the 5080, 5.4 on the 5090 stock, 2.6 on the 4090; the hold is the project share below about 13,500 boards). The two-phase rows, the revenue column, the used-card column and the exact meeting tariff: NOT RUN (ADVERSARY, this afternoon) | MEASURED-partial (the 4090 cells pending) | COHORT: the 4090 and uncapped cells; the read-headroom row (8c.8) with CONTROLS; ADVERSARY: the two phases, revenue, used-card, the meeting tariff, the growth-scaled new buyer; LAB: the full score when the 4090 rows land |
|
||||
| ctl-progpow-094 | control | CONTROLS building the official 0.9.4 reference from source with its test vectors on build-5 (pid build-5-lab-controls-progpow.pid), by 14:00; ADVERSARY's v2 rows use the KAWPOW census as the proxy | the equivalence contract before any number; 16 KB per hash of DRAM traffic (ADVERSARY) | NOT RUN (the record's 4090 stock row 6,920 is ADVERSARY's numerator) | v2 (proxy): the board 4.24x at the 4090 stock numerator (the 32-register lane costs more than the 8-register one), a_same 545 = 12.7x; the board bandwidth-bound at 16 KB per hash (93 MH/s per board), USD 6.89 per MH/s against 29.7; ProgPoW's premise 1.1x to 1.2x (no twin) | proxy, 4090 stock, board, v2: 4.24x; NOT the lab's score (the pinned build, the cohort rows and a twin pending) | v2 at ten cents, proxy: the board holds 1.1x (the GPU's electricity above the chip's all-in at fifteen cents); the die pushed out above 14,200 dies | NOT RUN (proxy) | CONTROLS 14:00; COHORT rows 15:00 with a twin; ADVERSARY re-prices on the pinned object |
|
||||
| ctl-fishhash | control | CONTROLS building Iron Fish's implementation on build-9 (pid build-9-lab-controls-fishhash.pid), by 14:00; ADVERSARY read cpp/FishHash.cpp | 32 x 3 fetches of 128 B = 96 random reads over a fixed 4.83 GB dataset, about 9,400 lane-instructions of 64-bit multiply-add and FNV | NOT RUN | v2: a_same 162 (three N2 reticles, fused lane), USD 0.35; the board 1,044 (872 to 1,251), 124 MH/s per board bandwidth-bound, USD 4.78; 95 percent of the board's energy is the memory path | waits on COHORT's card row | NOT RUN | NOT RUN | CONTROLS 14:00; COHORT 15:00 |
|
||||
| ctl-coop-arx | control and candidate | CONTROLS: the cooperative ARX kernel and CPU reference bit-exact at 2^16 for chain lengths 24 / 264 / 1,032 / 4,104 ARX ops per fetch per lane (build-2, pid build-2-lab-controls-arxcoop.pid); the document and rows 13:00 | 64 coalesced 256 B fetches; target f at or under 14.8 percent non-ARX energy; spills, sync, generation and finalisation counted | NOT RUN (the card MODELLED by ADVERSARY: 35 nJ per coalesced read, 8.5 pJ per ARX instruction at the knee) | v2 (card MODELLED, SCREEN) at 1.6 M ARX per hash: the board 2.40x (1.96 to 2.86) same node against the routed 32-register fused lane, 2.98x ahead; a_same 5,024 (near-memory, fused) = 3.15x; USD 13 to 15 per MH/s against 15.7 | SCREEN, 5090 knee modelled, board, v2: 2.40x | v2 at ten cents, card modelled: the board holds 1.1x; the die holds 1.5x | SCREEN | CONTROLS 13:00; COHORT's 4090 and 5090 rows 15:00; ADVERSARY re-prices within the hour |
|
||||
| cand-x10 (variants alu-130k / 260k / 520k and sector-118 inside it) | candidate (the founder's third review; the scoped same-node board claim; 8a, 8a.1) | SEARCH: the kit at build-1:/srv/artefacts/lab/cand-x10/kit/ (sector.cu 16417ce6, run-x10.sh, 18 fixtures PASS on a rented 4090 at 11:11 UTC, 112 registers, no spills); the accepted-work contract APPROVED (per lane-work = per digest over 32, both printed) | dependent 32 B sector loads, every byte folded; 118 loads as written; the FAULT OF RECORD: 7 of every 8 loads re-read the same sector, about 16 real misses per lane-hash (8a.1); the verifier under 10 ms owed | SEARCH's first rows (a rented 4090 at stock, SCREEN until COHORT's pairs): 118 loads 810 to 845 nJ per lane-work (25.9 to 27.0 uJ per digest) at 264 to 275 W; 64 loads 567 at 294 W; a tenth of v6's 8,091 on the card class, the cache hits of the fault, not a design gain | v2 with the card MODELLED (SCREEN): sector-118 the board 634 (553 to 798) = 3.00x (2.38 to 3.44) at a card modelled at 1,900 nJ, 3.03x ahead; the split chip 243 / 22 / 369 against the card's 1,650 / 70 / 180; the fold gap 1.2x to 3.9x by core; the die 73 (26x); the ALU ladder 130k / 260k / 520k: the board 4.67x / 2.89x / 1.63x (1.27 to 2.06), 5.52x / 3.56x / 2.08x ahead (the card = v6 by the reviewer's construction). Every modelled-card row re-priced within the hour of COHORT's measured row; the 16-real-misses fault means the chip's memory term in these rows is also overstated for the object as written | SCREEN; the verdict line waits on COHORT's measured pairs (v6 and X10, the same card, the same session) | v2 at ten cents, card modelled: the board holds 2.2x; the die pushed out above 5,800 dies | SCREEN (the object as written carries the fault) | COHORT: run cand-x10 and cand-x10b paired with v6 on the 4090 and 5090 pods now (the kits are staged); ADVERSARY: re-price on the measured rows and on the 16-miss trace; LAB: the verdict line within the hour of the pairs |
|
||||
| cand-x10b | candidate (main's ratified address rule on X10: load n reads the two registers the previous load's fold wrote last; zero repeats in every trace) | SEARCH: hash-x10b.ts, fixtures-x10b.txt (18), sector.cu under -DX10B; the kit lands under build-1:/srv/artefacts/lab/cand-x10b/ with its fixture line; queued on the same 4090 pod behind the x10 rows | as X10 with 118 real dependent sector misses per lane-work; fixtures 18 of 18 PASS under -DX10B; the kit at build-1:/srv/artefacts/lab/cand-x10b/kit with trace.json | SEARCH's first row (a rented 4090 at stock, sampler rule, SCREEN until COHORT's pair): 118 loads 3,653 nJ per lane-work at 66.1 M lane-works per second and 241.5 W, 31 nJ per dependent sector read, the card at its 7.8 G dependent reads per second ceiling; the 64-load and lock rows queued | set v2's same-node board on the 118-miss object: 634 (553 to 798), WITNESS, the split chip memory 243 / fold 22 / tax 369 | the 4090 at stock, the same-node DRAM board, set v2: 5.8x (4.6 to 6.6); v6 on the same card class 5.4x: the fold buys nothing on the ratio (REG-06 confirmed); the term that holds it above 1.5 is the card's memory path (31 nJ per read against the chip's about 2); PARTIAL: the locked 5090 and 5080 and the AMD card NOT RUN | NOT RUN (ADVERSARY re-prices on the measured row) | MEASURED-partial; FAIL CLOSED per the review (no added arithmetic, rotation or set growth) | COHORT: the v6 and x10b pairs on the uncapped 4090 and 5090 after the dataset-size decider; SEARCH: the 64-load and lock rows; ADVERSARY: the coexistence row on the measured card |
|
||||
| cand-14, cand-12, cand-13 (SEARCH's top three on set v2, the hand-off 13:2x UK) | candidates | packs at 4 GiB on the signing pair with SHA256SUMS and trace.json at build-1:/srv/artefacts/lab/candidates/<id>/pack-ds30 (the ds28 screening pack beside); self-test PASS (the gen-6 worker --check on the rented 4090), census 8 of 8 | cand-14: w32m8g+sh128x18+state+fold+labw0f0f140c0d0000190000+ilp4+flat (a MAD-heavy table: add 15 sub 15 xor 20 rotl 12 rotr 13 mad 25, four register banks, 32 B entries, 256 reads, 27k instructions, no window layer); cand-12: mx8+sh128x36 on the same table and banks, 4 B entries, 40k instructions; cand-13: w32m8g+sh64x54, 37k instructions | NOT RUN (COHORT: paired with v6 at stock and at the knee on the 4090 VM, behind the ledger session and the X10 pairs) | v2 (SEARCH's model; ADVERSARY's v3 pages for cand-11 to cand-20 beside): board 2.42x / 2.43x / 2.43x at the 5090 knee, 5.20x on the provisional 3090 stock row; the die 14.4x / 11.6x / 13.6x; the frozen object on the same model 2.66x and 12.2x | SCREEN: the realisable space sits between 2.4x and 2.7x on the board cell and above 10x on the die; the top three gain 9 percent on the board through mad (the one family priced near the card, 2.9x) and the ILP-4 banks; no candidate screens near 1.5 on any cell; the measurement at the knee decides whether the 9 percent exists (at stock the ALU sits in the reads' shadow) | NOT RUN | SCREEN | COHORT after the ledger; ADVERSARY re-prices on the measured rows |
|
||||
| cand-01 to cand-09, cand-ilp2, cand-ilp4 (SEARCH batch 1) | candidates (the workload-search system) | staged at build-1:/srv/artefacts/lab/candidates/ (12:17 UK by the box clock) with trace.json; ADVERSARY's trace watcher prices each within the hour; the GPU self-test on a one-shot 4090 pod by 13:30; the top three to COHORT and ADVERSARY by 14:00 at ds30 | the six knobs with explicit limits; the ARX-only region 48 to 64 reads, 170k to 225k instructions per unit; the ilp variants test the card's dependency structure (11.8 pJ per instruction at the lock on the drawn block against 6.2 on an ILP-4 chain) | NOT RUN | NOT RUN (priced on v2 within the hour) | SCREEN on set v3 (ADVERSARY 12:3x, the card figures SEARCH's screened values; the worst cohort cell the 3090 at stock in every one): cand-01 to cand-10 (arxr48/64 shadow classes, 48 to 96 scattered reads, 169k to 339k instructions): the board 3.1x to 3.3x at the 5090 knee and 6.2x to 6.7x at the worst cell, a (the die, the three-quarter hybrid or near-memory, 574 to 1,168 nJ) 3.9x to 4.2x and 7.7x to 8.4x (9.7x to 10.9x a node ahead); cand-11 to cand-19 (labw classes, 256 scattered reads of 4 to 32 bytes, 27k to 112k instructions): the board 2.4x to 2.5x at the knee, 5.1x to 5.5x at the worst cell, a 8.3x to 19.0x and 17.6x to 40.9x (the full SRAM die serves 256 reads at 187 to 495 nJ); cand-16 and cand-20 (sh512x4, sh256x9 at 64 and 48 reads, 20k to 22k instructions, the 5090 at 0.8 to 1.0 uJ): a 11.0x and 9.6x, boards 2.6x and 2.7x. NONE UNDER 1.5x ON ANY CELL: the die binds every candidate with 256 reads; the board's own tax holds the board cells at 2.4x and above | v3 coexistence rows per candidate on the twenty pages (the new-buyer column included) | SCREEN | SEARCH: the self-tests 13:30, the top three on v3 at 14:00; COHORT measures the top three |
|
||||
|
||||
## COHORT's denominator run on ctl-v6 (from 11:49 UK)
|
||||
|
||||
Rented by id through the handover gate (two hosts per card: 5090 x2, 4090 x2, 3090 x2, 5080 x2; drivers 570 to 595), the gen-6
|
||||
worker f5f3846c on kit bf9b85d6, the x1-ctrl-ds30 pack (0x2a1d6caab4c24564) with its read-only twin (X7's twin, kernel 93a6a5ce,
|
||||
the address path kept by construction, twin_addr none accepted), three timed runs per pack per state interleaved, rows at
|
||||
build-1:/srv/artefacts/lab/ctl-v6/vast-<pod>/ (rows.jsonl, SHA256SUMS, the .tgz) linked under /srv/artefacts/lab/cohort/ctl-v6/<card>/
|
||||
and the cohort's own table at /srv/artefacts/lab/cohort/TABLE.md. Every pod refuses the lock: stock only. The capped-host finding
|
||||
(12:17): f near 1 on a capped card is meaningless; the gate now refuses capped hosts for the f cells; the 4090b pod that never
|
||||
answered was destroyed and re-rented. AMD and Intel: no RX 7600 to 9070 or Arc card on either provider (AMD there is MI300X and
|
||||
MI350X only), the house rigs unreachable by software; NOT RUN "no rented card; the house rigs unreachable by software" on every
|
||||
candidate (build-1:/srv/artefacts/lab/NOT-RUN-amd-intel.md); the hourly provider check retries. Spend: USD 750 at 12:03.
|
||||
|
||||
## Adversarial sets
|
||||
|
||||
| set | date | contents | candidates scored on it |
|
||||
|---|---|---|---|
|
||||
| v0 (the record) | 9 Oct, X0 amendment 5b and X5 | the placed 8-lane 18-family core (6.55 pJ per lane-op at N5), the GDDR7 board, the hybrids at 0.25, 0.50, 0.75, the N2 full-SRAM die, recomputation; X7's HBM3 and near-memory paths; X8's systolic int8 array | ctl-v6 (the record: board 2.35x, die 7.8x); SEARCH's cost model (recalibration owed) |
|
||||
| v1 | 12:0x UK (ADVERSARY, 0344ec150 on class-v6-adversary) | v0 plus a bandwidth ceiling on every DRAM design (1.79 TB/s at 85 percent), a register file sized to the work (the routed 8-register flop lanes), fused dependent operations (the tail CLAIMED), HBM3 at one and eight stacks and near-memory as complete machines, recomputation priced from the Ethash-family generators, the die on as many reticles as the dataset needs; calibration reproduces X0 5b (1,706 vs 1,707 nJ; the die 511 vs 513) | the four controls (ctl-v6 board 2.57x) |
|
||||
| v2 | 12:0x to 12:1x UK (ADVERSARY, f9cb259cc the routed lanes, 923f76e74 coexistence, 7c6b538f1 the price sheet; tools/chip-model/floor-k-lab with the routed power logs) | v1 with the routed fused-ARX lanes (ASAP7 routed with SPEF, 1,200 ps, timing met: arxf 8-register window 1.87 pJ per fused add-rotate-xor triple, 464 um2, against the single-op lane's 2.16 pJ per op; arxf32 32-register window 5.42 pJ per triple, 1,316 um2): the ARX-fused rows WITNESS; the non-ARX fused groups (mul-xor, 64-bit multiply-add, math and merge) CLAIMED-partial until the two lanes in the flow on build-4 land (the 64-register fused-ARX window that X10 carries as "x1.6 claimed", a fused multiply-xor lane), about 12:3x; every modelled-card row labelled "card": "MODELLED" and SCREEN; the coexistence tables, the new-buyer column, the growth schedules (growth-schedules.json) and the price sheet with the break-even sensitivity (the board breaks even at ten cents only with devices under about USD 8 to 10 each, a fleet above a million machines and a 36-month life together) | every control and candidate (ctl-v6 board 2.60x, die 11.9x; ctl-progpow-094 4.24x proxy; ctl-coop-arx 2.40x screen; cand-x10 3.00x screen) |
|
||||
| v3 | 12:2x UK (ADVERSARY b2b9ee0b9; v3/ beside v2/ under lab/adv/; the JSONs in scratchpad/lab/adv/ now v3) | v2 plus two more ROUTED lanes (the 64-register fused-ARX window 9.90 pJ per triple, 2,469 um2, replacing the "x1.6 claimed"; the fused multiply-xor pair 4.13 pJ over 32 registers, replacing the claimed tail on the FNV and merge steps) and two corrections (a shadow class's window is the 8 registers its shadow runs on, as v6; a sized lane pays for a window above 8, so the 64-register sized lane is dearer than the placed core's SRAM window) | every control and candidate: ctl-progpow-094 a_same 669 (10.3x), board 3.94x (proxy); cand-x10 Sector 2.92x; cand-x10 at 520k ALU the board 1.03x (0.83 to 1.14) same node, 1.35x ahead, SCREEN with the card assumed = v6 (the measurement ordered: SEARCH builds the point, COHORT pairs it; the card's own 520k instructions at 5.9 to 11.8 pJ are 3 to 6 uJ, so the P03 line decides first); cand-01 to cand-10 a_same 574 to 1,168, the board 769 to 1,472 (about 3.1x to 3.3x against their screened 5090 knee figures, a 3.9x to 5.4x) |
|
||||
|
||||
## Rejections and regression hits
|
||||
|
||||
| when | source | what |
|
||||
|---|---|---|
|
||||
| 12:1x UK | SEARCH, the first screen (4,000 draws, seed 1) | 1,762 int8 mixes by REG-04; 122 scattered-ARX designs at or under 32 reads by REG-05; 343 on the verification or granularity limits (F2); SCREEN rejections, none measured |
|
||||
| 12:1x UK | SEARCH on cand-x10 | the Sector reference as written re-reads the same sector for 7 of every 8 loads (16 real misses per lane-hash): the object is measured as written (its own row) and the corrected address rule measured beside it as cand-x10b; the author's energy split and the 10 percent budget do not describe the object as written |
|
||||
|
||||
## Faults
|
||||
|
||||
| when | source | what | state |
|
||||
|---|---|---|---|
|
||||
| 11:49 UK | COHORT | no AMD (RX 7600 to 9070) or Intel Arc card rentable on either provider; the house rigs unreachable by software | the two tiers NOT RUN with the reason on every candidate; hourly retry |
|
||||
| 12:1x UK | SEARCH | a parse fault on +arxr48+flat in batch 1 | fixed in the cut; a 20-minute slip on the 13:30 clock, absorbed |
|
||||
| 12:1x UK | COHORT | the 4090b pod never answered on the provider's ssh proxy | destroyed and re-rented on another host |
|
||||
| 12:17 UK | COHORT | the read-only twin reads f near 1 on a power-capped card (the saved ALU power goes to clock) | the lab rule 1.11: f on uncapped cards only; the gate refuses capped hosts for the f cells; capped rows stand named |
|
||||
| 12:2x UK | LAB | the line to main of 12:1x carried "13:1x UK" (main's clock, not `date`) | the lab's lines carry `date` from here; reported once |
|
||||
|
||||
## The shipping row (the claim's phase B is NOT LIVE until a shipped package runs a prover; main 12:4x UK: the most important 2.0 shipping item)
|
||||
|
||||
| item | what the claim needs on the record | state | owner |
|
||||
|---|---|---|---|
|
||||
| the two-phase result it serves | ADVERSARY 545f3f8a0: with the ratified 80/20 split (D04, token-value/README.md: the 80/20 split emission allocation only) a GPU earns the mining share plus the proving share, a hash chip the mining share only; the proving share at which the chip's operating advantage disappears at ten cents is 9 percent for the board, 13 for the hybrid, 20 for the die, against the ratified 20: with proving live the GPU out-earns the board chip in phase B. The stated assumption on the phase B row: all proving stays on the GPU cohort; a chip maker who fields GPUs to prove is a GPU miner with a sidecar | NOT LIVE | ADVERSARY (the row), the release lane (the package) |
|
||||
| which shipped packages prove today, at what VRAM, by what switch | the Windows and Mac apps: the VRAM threshold and the switch by file and line; the HiveOS package: none (mines only); the 24 GB threshold (proving turns on at 24 GB) stated against the proving manifest and the app's settings code | BLOCKED: the file-and-line facts from the release lane and the app lane | the release lane aa4391fd50c62e28f |
|
||||
| whether a 16 GB card can prove a smaller instance | the proving instance sizes against the 16 GB tier (the B3 feasibility rows explored 32, 128 and 512 MiB per instance for Track B; the live prover's own instance size by its manifest) | BLOCKED: the prover's instance table | the release lane with the proving lane |
|
||||
| the share of the live cohort that can prove | the workers page's card census (the share of cards at 24 GB and above; the share at 16 GB) | NOT RUN: the fleet lane's workers.json read | the fleet lane |
|
||||
| a HiveOS prover beside the miner | a named 2.0.3.1 item beside snapshot sync, with the memory and VRAM rules from this row; no new research | NOT RUN: scheduled by the release lane | the release lane aa4391fd50c62e28f |
|
||||
| the 4 GiB proving cap defined precisely (8c.10) | which of the mining state, the proof-generation allocation, the verifier requirement or the advertised hardware profile it limits | NOT RUN | CONTROLS (the definition), ADVERSARY (the economics) |
|
||||
|
||||
## The read-headroom row (8c.8), answered 12:4x UK (CONTROLS and SEARCH, WITNESS)
|
||||
|
||||
| card | achieved sector reads per second on v6 at 4 GiB (rate x 256) | the activation bound (JEDEC-family timing approximations, named so) | nJ per 32 B sector read (stock) | the fold | the ILP lever (SEARCH, in-session ABBA) | reading |
|
||||
|---|---|---|---|---|---|---|
|
||||
| RTX 4090 | 7.86 G | at its tFAW form, 7.9 G | 26.4 to 28.4 | the whole-sector load free (a32x = a4); the full fold +1.0 nJ (+3.7 percent) | cand-ilp4 rate +0.00 percent, joules -0.64 (8,560 against 8,615); cand-ilp2 +0.00 / +0.00 | AT the limit: no card-side headroom; the ALU in the reads' shadow |
|
||||
| RTX 5090 | 17.6 G | within a third of its tRC form | 18.9 to 19.8 | the full fold +1.2 nJ (+6.2 percent) | not yet paired | AT the limit; flat from 1 to 8 GiB (no TLB cliff for a flat stream) |
|
||||
| RTX 5080 | 8.97 G | CONTROLS owed | | | | |
|
||||
| RTX 3090 | 7.91 G (capped pods) | CONTROLS owed | | | | |
|
||||
|
||||
The miner-side lever "more loads in flight per core" is CLOSED on v6 by these rows; the phase B lever on the card is its memory
|
||||
system itself. The "2.4x at 4 GiB" reading is WITHDRAWN (a contention fault of the CONTROLS lane: the 4 GiB bench shared the card
|
||||
with the sector sweep; clean ABAB on a 5090: the 4 GiB object costs 3 percent of rate against the 1 GiB research pack).
|
||||
|
||||
## The shipping row's facts (the release lane, 12:5x UK, release-2.0.3 at 034cdad1, app/igneum-app/src)
|
||||
|
||||
The Mac and Windows apps carry the prover (SP1 6.8.1 through igneum-prove-host), ON by default on an NVIDIA card of 23,552 MiB or
|
||||
more (provedefault.rs:23-24; a 24 GB card reports 24,564), mining or not, on Linux or Windows with WSL2; OFF with the reason on 16 to
|
||||
24 GB, under 16 GB, Windows under 32 GB RAM, Apple silicon, AMD-only; the prover refuses to start under 12 GB (provedefault.rs:31) and
|
||||
a card under 12 GB holds the prover or the miner, never both (engine.rs:41, :2717). HiveOS: none (h-run.sh has no prover section);
|
||||
the HiveOS prover is SCHEDULED as a 2.0.3.1 item beside snapshot sync (cut by 18:00 UK, the rule-33 canary on a 24 GB 4090 by
|
||||
19:00). A 16 GB card proves no shard today: the floor 13,874 MiB for an empty shard and 15,670 beside the miner (provedefault.rs:8);
|
||||
the v1 shard (30,000 pgas, 4.7 M cycles) 20,434 alone, 22,210 beside the miner; the devnet's prototype shards until the fee switch
|
||||
at DAA 210,000 need 32 GB (28,307 alone, 30,039 beside; VRAM_MB_PROTOTYPE_SHARD 31,000, provedefault.rs:27, :135); aggregation
|
||||
16,751 MiB with the miner (24 GB; prover.rs:1122); no smaller instance exists (provedefault.rs:9 open). The Hive package applies the
|
||||
app's device.rs:63 mode table (Simultaneous at or above 23,552 MiB, ProveOnly, MiningOnly). Phase B therefore reads LIVE today only
|
||||
on Windows and Mac where a 32 GB NVIDIA card is present, NOT LIVE on the 24 GB tier until DAA 210,000 and on HiveOS until the cut.
|
||||
|
||||
## Results of record, 13:0x UK
|
||||
|
||||
- THE DECIDER (COHORT, uncapped 5090 pod 55012544, 575 W default, driver 580.173.02, worker f5f3846c, 250 x 2^24, three runs
|
||||
each, one session; build-1:/srv/artefacts/lab/ctl-v6-decider/vast-55012544/): the 4 GiB signing object 68.63 MH/s at 410.4 W =
|
||||
5,979.9 nJ per hash, twin 4,806.2, f 0.804, spread 0.46 percent, fp b1f90e91cadb15b6 PASS; the same program at 2^28 (1 GiB)
|
||||
70.63 MH/s at 414.7 W = 5,871.2 nJ (fp 01f51b9d4805e5e6); the research pack hl-v6-all as staged under tas/same-work-20261008-01
|
||||
(program id 0x4de7b836cc40a4ea, not the l8off kit's 0x9d40978601a7df2a) 70.63 at 402.2 W = 5,694.8 nJ. VERDICT: the 4 GiB
|
||||
object costs this worker 2.9 percent of rate and 1.8 percent of joules per hash against its own 1 GiB export; occupancy
|
||||
identical (96 registers, 20 blocks per SM, 3,400 resident lanes per SM); reads 17.57 G per second at 4 GiB against 18.08 at 1
|
||||
GiB. The 3.4 against 7.9 G reading was CONTROLS' instrument; withdrawn. The uncapped 5090 ctl-v6 cell: 5,979.9 nJ at stock =
|
||||
3.88x (3.07 to 4.50) on set v2's same-node board; the 5090 knee stays the record's 2.60x.
|
||||
- The TLB rows (CONTROLS, both cards, mode a4 at 118 loads, build-1:/srv/artefacts/lab/controls/frozen-v6/tlb/): the 4090 8.02 /
|
||||
7.92 / 7.87 / 7.84 G reads per second at 1 / 2 / 4 / 8 GiB (26.07 to 26.81 nJ per read), the 5090 18.11 / 17.76 / 17.60 / 17.52
|
||||
(19.11 to 19.77 nJ): a flat random stream loses 2 to 3 percent of rate from 1 to 8 GiB on either card, no cliff; the layer-8
|
||||
windows at 2^30 touch the whole 4 GiB as a flat stream does; the live object's cost is the 2 to 3 percent page-walk margin.
|
||||
- The 4 GiB cap defined (CONTROLS, 8c.10): the MINING DATASET floor and nothing else: docs/spec/01-lottery-hash.md lines 849 to
|
||||
850 (4,096 MiB from genesis, one step, no later step; the dataset policy 70a6c703, register row 10; the cache 2^26 words, line
|
||||
434); the proof-generation allocation carries no 4 GiB figure (proving/igneum-prove/host/src/memory_profile.rs TIERS lines 115 to
|
||||
121: full 26 GiB free, 24gb 20 GiB, 16gb 14 GiB, small 0; the ELF manifest pins no memory line); the verifier is independent of the
|
||||
dataset size (spec 01 line 702: at most 4,096 item derivations per unit from the 256 MiB cache; the F2 row 10.63 ms per warp);
|
||||
the advertised hardware profile (docs/design/app-audit-2026-10-08.md row F8) is constrained only through the miner's resident set
|
||||
pushing a co-resident prover into the small tier (memory_profile.rs line 117).
|
||||
- ctl-progpow-094 at the live DAG (CONTROLS): the official CUDA kernel's mix digests at Ethereum epoch 871 (block 26,154,532, DAG
|
||||
8,380,217,984 bytes) equal the CPU reference's on both nonces: F1 holds at the live size; the live rate on the rented 4090 at
|
||||
stock 58.75 / 58.76 MH/s (0.96 TB/s of coalesced 256-byte entries) against 62.58 at the 1 GiB DAG: the live DAG costs 6 percent;
|
||||
the by-construction read-only twin gpu/pp_twin built, rows queued; the KAWPOW live size (epoch 609, 6,182,403,712 bytes) queued.
|
||||
- ctl-coop-arx on the 5090 (CONTROLS, stock, sm_120): 105.3 M units per second at 72 ops (1.72 TB/s, the DRAM ceiling; the twin
|
||||
103.8), 14.32 at 1,032 and 3.66 at 4,104 (ALU-bound, equal to the 4090's 14.18 / 3.69); five chain lengths F1-complete (CHAIN 86
|
||||
fingerprint 84f43aecf5d8a8cd on CPU and GPU).
|
||||
- THE PROVING CORRECTION (PROVING LIVE and the release lane): the prototype-shard 32 GB clause (provedefault.rs) is Devnet 1's
|
||||
(params.rs:3082 LIVE_13); Devnet 4 runs fees_v1_activation_daa 0 (params.rs:2307 on 27f54124; the node reports shardBudget
|
||||
30,000 pgas), so a 24 GB NVIDIA card proves the v1 shard and aggregates beside its miner NOW (13,523 to 14,899 MiB peak beside
|
||||
the miner on the fleet's 4090s, 117 to 120 s per 8-block segment, 0.99 to 1.88 IGN paid); on chain 4465 at tip 31,719 (11:50Z)
|
||||
663 paid v1 segment records = 653.93 IGN to 30 prover keys; 0 segments proven in the last 600 blocks (payments paused since the
|
||||
hub roll at about 11:00Z, the cause under fix). The proving row reads NOT LIVE "paid on Devnet 4 to 30 keys, epoch evidence
|
||||
pending" until docs/analysis/class-v6/1p5x/lab/proving-live.md lands with one full epoch of paid rows (by 16:00). Phase B
|
||||
therefore reads live today for the 24 GB tier on Mac and Windows once that record lands, and on HiveOS from the prover package's
|
||||
entry (cut by 18:00); the 16 GB tier holds no shard.
|
||||
- X10 pairs: the first 4090 pair carried valid v6 rows (8,692 nJ at 30.69 MH/s, f 0.859) and valid X10 rates (x10b 66.1 M
|
||||
lane-works per second at 118 loads = the v6 pair's 30.69 x 256/118 within 1 percent: memory-bound exactly as v6; the x10b ladder
|
||||
64 / 96 / 118 / 128 loads 122.1 / 81.3 / 66.1 / 60.9 M scales as 1/loads) but ZERO watts on every X10 row (the 40-batch default
|
||||
ran a quarter second, under the sampler's 8 s warm-in); kit r3 (sha 323e3970) sizes the batches per variant (45 to 85 s), adds
|
||||
cand-x10's fixtures and an x10b by-construction twin (-DX10B -DTWIN: the address rule kept, the fold one xor per loaded word,
|
||||
the mix and shuffles removed; its address stream a dependent chain of the same count and width, named so in twin_addr), and the
|
||||
driver refuses any row with watts 0.0 at staging; both pairs re-rented on uncapped 4090 and 5090. ADVERSARY's interim: the
|
||||
Sector card from its rate at the pod's v6 watts, 4,039 nJ per lane-work MODELLED-from-rate (bounded below at about 3,470 by the
|
||||
twin's ALU share), the cheapest DRAM board (650 nJ) 6.2x on the 4090 at stock.
|
||||
- THE LEDGER ORDER (the founder, 12:5x UK; the LEDGER lane a101aa8329d1e82bf): the lab's lanes feed the GPU NETWORK LEDGER first:
|
||||
every live GPU chain measured the same way on the same rented 4090 and 5090 in one session each (Igneum v6 at 4 GiB, KAWPOW via
|
||||
ProgPoW 0.9.4 at the live DAG, FishHash, Etchash via the pinned chfast hashimoto at ETC's current DAG, Autolykos2 if the in-house
|
||||
rule and the clock allow), the set v3 board ratio and the ProgPoW-premise ratio per chain, the two-phase rows with each chain's
|
||||
actual split (only Igneum pays provers), the E9 Pro calibration line (ADVERSARY models the one real chip against Etchash; the gap
|
||||
to its public rate and power stated as the model's calibration). COHORT's ledger session is renting (v6, FishHash full and twin,
|
||||
ProgPoW 1 GiB class now; the live-DAG binary pp_harness_26154532 from CONTROLS pulled in if it lands in time, else a second short
|
||||
session); CONTROLS' Etchash harness is built and F1-checked against chfast at blocks 0 and 12,750,000 (ETC epoch 425). The ledger
|
||||
table on the record at docs/analysis/class-v6/1p5x/ledger/README.md 15:30, the kit 16:30, the page 17:30; the registry cell
|
||||
ledger:cross-chain beside lab:candidate on R15-09, NOT RUN until the kit's rows reproduce. The candidate search continues
|
||||
fail-closed at lower priority (the 520k-ALU point and the headroom row close their records; no new families).
|
||||
|
||||
- The 4090 decider CLOSED (CONTROLS, one quiet session, stock, the 4 GiB signing pack): the kit zip 4 worker f5f3846c 30.661 MH/s
|
||||
at 20 x 2^22 and 30.693 at 50 x 2^24; the build-1 worker 804a6f7f 30.663 and 30.692 on the same pack and shape; the 1 GiB
|
||||
research pack 31.313: the two builds are the same instrument to four figures, the batch shape moves nothing, the live 4 GiB
|
||||
object costs the 4090 2.0 percent of rate (3 percent on the 5090); the 13.16 was the shared-card read, withdrawn
|
||||
(build-1:/srv/artefacts/lab/controls/frozen-v6/decider-4090.log). ctl-progpow-094's by-construction twin on the 4090: 77.42 MH/s
|
||||
at the 1 GiB DAG and 60.20 at the live 8.38 GB DAG against the full kernel's 62.58 and 58.75: at the live size the card is at
|
||||
its memory limit (the ALU hidden under the reads). ctl-fishhash on the 5090: F1 PASS (fingerprint f841605125ce375e equal to
|
||||
the 4090's), 45.37 MH/s full against 39.71 for the twin (19.73 / 17.75 on the 4090). The F2 row re-run on the same pinned core:
|
||||
10.440 ms per warp for the signing object at 2^30, 5.134 for the class v4 calibration (frozen-v6/f2-verifier-build5.txt).
|
||||
|
||||
- CONTROLS' done line (13:0x to 13:1x UK): the four controls and the ledger rows staged under build-1:/srv/artefacts/lab/controls/
|
||||
with SHA256SUMS (frozen-v6 with tlb/, coop-arx, progpow-094, fishhash, sector-read, etchash, autolykos; 25 MB). Autolykos2 landed
|
||||
in-house and bit-exact (hashlib reference against the CUDA harness at N = 65,536 and at Ergo's live N = 227,251,815, 7.27 GB;
|
||||
216.2 / 201.8 MH/s full / twin on the rented 4090; the live-block end-to-end row owed). coop-arx's OpenCL text F1-complete on two
|
||||
OpenCL platforms at all five chain lengths (the AMD tier runs the moment a card is reachable; Metal owed). Open on that lane: the
|
||||
knee rows (every rented pod refuses the lock; COHORT's VM-host lock hunt reports by 14:30), the Metal text, the Ergo live-block row.
|
||||
- ADVERSARY (12:5x to 13:0x): phase B tier rows on the on-chain read (commit 82330b04c; 47f16b86f with Etchash on the mirror): the
|
||||
24 GB-and-up NVIDIA tier on Windows and Mac LIVE on Devnet 4 and out-earning the board chip in phase B (the GPU's contribution per
|
||||
TH after electricity 0.341 against the chip's 0.288 after its recurring cost at ten cents on the flat path); HiveOS NOT LIVE until
|
||||
the 2.0.3.1 package; the 16 GB tier never (mining share only, 0.046 per TH behind the chip). ctl-etchash as a control (64 x 128 B
|
||||
reads over about 3.3 GB) with the calibration line: the modelled DRAM board 765 nJ per hash against the shipped Antminer E9 Pro's
|
||||
598 (3,680 MH/s at 2,200 W, the public figure as the ledger lane quotes it): the model 1.28x pessimistic for the chip, a witness.
|
||||
Phase A and B for Ravencoin, Ethereum Classic and Iron Fish in their own units (commit d7db3e596, README section 14): no fleet
|
||||
recovers a board chip's spend on any of the three on any path (each chain's 24-month mining emission under one design cost: RVN
|
||||
about USD 2.9 M, IRON 1.4 M, ETC 83 M at a 10,000-board fleet); phase B with no prover income: on Ravencoin the chip's operating
|
||||
advantage stands in full at USD 0.123 per TH; on ETC the mining revenue per TH (0.0087) is below the board's recurring cost (0.031)
|
||||
and the E9 Pro's electricity alone (0.017), a loss on both sides at ten cents. The reading: on every chain in the set the board
|
||||
chip's phase B advantage is real and its phase A recovery is not at today's emissions; the proving share is the one term that lets
|
||||
Igneum's live GPU tier out-earn the chip in phase B.
|
||||
- The boxes (the build steward, 13:1x): the lab's ended box runs' pid files removed as stale after each pid's command line was read
|
||||
dead; the steward's defaults on build-4, build-5 and build-6 stand until a lane claims with a live pid file.
|
||||
|
||||
- THE 4090 DECIDER (COHORT 13:10, uncapped pod 55012546, 450 W default, driver 570.172.08, worker f5f3846c, 250 x 2^24, three
|
||||
runs each, one session; build-1:/srv/artefacts/lab/ctl-v6-decider/vast-55012546/): the 4 GiB signing object 31.52 MH/s at
|
||||
275.1 W = 8,728.3 nJ per hash, twin 7,398.0, f 0.848, spread 1.06 percent, fp PASS; the same id at 2^28 32.14 MH/s = 8,600.1
|
||||
nJ; the research pack 32.15 = 8,448.2; occupancy identical (88 registers, 20 blocks per SM). CLOSED on both denominator cards:
|
||||
the 4 GiB set costs 2.0 (4090) and 2.9 (5090) percent of rate and 1.5 to 1.8 percent of joules; reads 8.07 G per second at 4 GiB
|
||||
against 8.23 at 1 GiB; the batch-shape question closed by CONTROLS' own pair (20 x 2^22 and 50 x 2^24 equal to four figures), so
|
||||
no fix pair exists to measure. The uncapped 4090 ctl-v6 cells: 8,369 / 8,692 / 8,728 / 8,751 nJ over four pods (host spread 4.5
|
||||
percent, drivers 570 to 595): the scoreboard's 4090 figure is the median 8,710 with the spread, the worst cell 8,751 = 5.67x.
|
||||
- Autolykos2 priced on set v3 (ADVERSARY 13:1x, README section 15): the same-node board 198 nJ (172 to 249) = 5.8x on the 4090 at
|
||||
stock from CONTROLS' 216.2 MH/s at 250 W claimed (MODELLED-from-rate until COHORT's session row); the die on four reticles 46
|
||||
nJ (25x); premise 1.01x; phase A no recovery on any path (Ergo's 24-month mining emission about USD 0.6 M); phase B mining only,
|
||||
the chip's advantage USD 0.024 per TH standing in full.
|
||||
- SEARCH handed build-9 back (batches 1 and 2 done and exported); its measurements continue on the rented 4090 (x10b 64-load,
|
||||
alu-520k, the v6x2 pair, batch 2's self-tests); row folders at build-1:/srv/artefacts/lab/rows/lab-search-4090-pod/ with SUMS;
|
||||
the lane record docs/analysis/class-v6/1p5x/lab/search/README.md on branch lab-search.
|
||||
- The "no emergency intervention" row (8g): Devnet 4 on finality rule v3, paused since lock 1980, the chain on PoW through the
|
||||
pause, nothing certified reversed, proving unaffected once the finality gate on provers was lifted; the first new lock expected
|
||||
15:30 to 17:00 UK; the vote key label-derived (no key file).
|
||||
|
||||
- THE 520k CELL CLOSED (SEARCH 13:2x, the rented 4090 at stock, fixtures 9 of 9 PASS, 97 registers, no spills, two passes):
|
||||
cand-x10 variant alu-520k 15.8 M lane-works per second at 351 to 366 W = 22,152 and 23,225 nJ per lane-work (709 to 743 uJ per
|
||||
digest) against the frozen v6's 8,594 nJ per hash in the same session: 2.6x to 2.7x v6's joules per unit, the P03 10 percent
|
||||
budget failed by 160 percent (about 42 pJ per instruction on this text; the 41,536 shuffles per lane-work at the card's dearest
|
||||
family); set v3's 1.03x (the card assumed equal to v6) scales to about 2.7x on this cell. REJECTED (F4). NO MEASURED OR SCREENED
|
||||
CELL ON ANY CARD SITS UNDER 1.5x ON THE SAME-NODE BOARD after the X10 family, the twenty search candidates and the four controls;
|
||||
the best cell is the frozen v6 on the 5090 at its knee, 2.60x (2.06 to 3.02), WITNESS, set v2 and v3.
|
||||
- 8c.8 CLOSED: the v6x2 confirming row (two lane-hashes per thread, fingerprint equal) 30.697 against 30.697 MH/s, 8,574 against
|
||||
8,594 nJ (-0.2 percent); fifteen headroom cells flat at 7.85 to 7.86 G reads per second against the memprobe ceiling 8.1: the 4090
|
||||
is at its bound. cand-x10b at 64 loads 122.2 M lane-works per second = 1,993 and 2,020 nJ per lane-work (31.1 nJ per read).
|
||||
- A LOCKABLE RENTED HOST (COHORT 13:23): Vast VM offers (KVM, GPU passthrough, the driver in the guest) hold -lgc; the 4090 VM
|
||||
55018643 (driver 575.51.03) locked 0 to 1,500 and restored; the five-chain ledger session runs there at stock and at the knee the
|
||||
recipe picks from a clock ladder (1,200 to 1,900); the 5090 VM at the record's 1,300 once it boots; the stock sessions mid-run on
|
||||
the uncapped containers 55017318 (4090) and 55018470 (5090). The scoreboard's claim column gets measured knee rows on rented
|
||||
cards for every chain today.
|
||||
|
||||
## Open rows (NOT RUN)
|
||||
|
||||
| row | owner | clock |
|
||||
|---|---|---|
|
||||
| the card's random-read headroom per cohort card against its memory-timing bound and the chip's 148 MH/s (8c.8) | COHORT with CONTROLS | this afternoon with the scoreboard |
|
||||
| the two-phase coexistence rows, the revenue column, the used-card column, the growth-scaled new buyer, the exact meeting tariff per row (8c.2 to 8c.7) | ADVERSARY | this afternoon |
|
||||
| the 4 GiB proving cap defined precisely (8c.10) | CONTROLS (the definition), ADVERSARY (the economics) | this afternoon |
|
||||
| the comparative review of what other GPU chains publish on chip resistance (8c.9) | unassigned; NOT RUN until done | none |
|
||||
| the units rule in every control's contract (revenue per accepted work in the algorithm's own units) | CONTROLS | with each pin |
|
||||
| the founder's bundle (8d) | LAB | 14:00 |
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
# The GPU network ledger (the 1.5x laboratory, LEDGER lane, 9 October 2026)
|
||||
|
||||
Every live GPU-mined chain the lab could pin, measured the same way on the same rented cards in the same sessions, with the same
|
||||
adversary method applied to each, ranked on (a) the same-node chip-to-GPU energy edge under the lab's convention, (b) the same
|
||||
under ProgPoW's own premise, and (c) the two-phase economics with each chain's ACTUAL reward split (only Igneum pays a share of
|
||||
every block to provers). Every number is MEASURED or WITNESS with its source on the row; a number that does not exist yet reads
|
||||
"pending" with the lane that owes it, never blank; a chain the lab could not run in-house reads NOT RUN with the reason.
|
||||
|
||||
The data lives in `ledger.json` beside this file; every table here, on the public page (`docs/ledger/gpu-ledger.md`, served at
|
||||
/gpu-ledger) and in the reproduction kit is rendered from it by `tools/ledger/render.mjs`, so no two copies of a row can drift.
|
||||
The public page carries the same tables with less of the lane bookkeeping.
|
||||
|
||||
<!-- ledger:stamp:start -->
|
||||
Data: ledger.json, stamp 2026-10-09, adversarial set v3 (build-1:/srv/artefacts/lab/adv/ (README.md, design-notes/adversarial-sets-v1-v2-v3.md, rows/, SHA256SUMS); set v3 commit b2b9ee0b9 on class-v6-adversary).
|
||||
<!-- ledger:stamp:end -->
|
||||
|
||||
## 1. The method (the same for every chain)
|
||||
|
||||
1. **The object.** Each chain's proof of work as its own pinned reference code, built in-house from source (the in-house rule: no
|
||||
downloaded miner binary; the CONTROLS lane's pins with commit ids and sha256s on build-1 under /srv/artefacts/lab/controls/).
|
||||
Igneum: the frozen class v6 at the live 4 GiB dataset. Ravencoin: the official ProgPoW 0.9.4 reference kernel (KAWPOW is
|
||||
ProgPoW 0.9.4 with Ravencoin's constants, so the official kernel at Raven's DAG size is a labelled PROXY). Firo: the same row
|
||||
(FiroPoW differs from KAWPOW in its constants only). Iron Fish: the FishHash reference library and a bit-exact CUDA port. Ethereum
|
||||
Classic: the ethash hashimoto of the pinned chfast/ethash library at ETC's current epoch. Ergo: NOT RUN (the reason on the row).
|
||||
2. **The card rows** (the COHORT lane, the lab's measurement recipe, interface section 1): one rented RTX 4090 and one rented RTX
|
||||
5090, both on uncapped hosts, every chain back to back in ONE session per card at stock; board power from `nvidia-smi` at 1 Hz
|
||||
(the mean from 8 s in to the end, idle not subtracted); the rate from the harness's own counter; nJ per hash = W / MH/s x 1,000;
|
||||
three timed runs per cell, the median served with the spread; `clocks_sm_median` and the dataset size on every row; the
|
||||
read-only twin (the same reads, the arithmetic cut) where the harness has one, giving f = J_twin / J_full, the card's memory
|
||||
share. Rented containers refuse clock locks, so every row is stock; the Igneum knee rows exist only in the record.
|
||||
3. **The chip rows** (the ADVERSARY lane, adversarial set v3): for each chain the cheapest credible specialist on the same node, a
|
||||
complete machine (die, memory, board, PSU and host terms), modelled and labelled WITNESS; the same-node DRAM board is the energy
|
||||
row, the SRAM die the residual (coexistence) row. Two conventions beside every ratio: **ours** (the cheapest programmable core,
|
||||
fused where the work allows, register file sized to the work, instruction convention) and **ProgPoW's premise** (the chip must
|
||||
keep a GPU-class datapath, so its edge is the compute term's 1.1x to 1.2x weighted by the card's measured memory share f).
|
||||
4. **The real chip.** Ethereum Classic is the one chain on the ledger with a chip on the market, the Antminer E9 Pro; its public
|
||||
rate and power go on the row as a WITNESS and the modelled DRAM board for Etchash goes beside it as the model's calibration
|
||||
line: the row says how close the model's ratio sits to the ratio from the public figures.
|
||||
5. **The two phases** (the lab's rulings 8c.5 and 8c.11): phase A, can a chip maker recover its spend before investment under
|
||||
growing, flat and falling price paths; phase B, with the spend sunk, who exits first on operating cost, with the chain's actual
|
||||
reward split: a GPU on Igneum earns the mining share plus the proving share (80 / 20 ratified), a hash chip the mining share
|
||||
only; on every other chain the GPU's share is mining only (100 / 0), so the second-income term is zero by construction.
|
||||
6. **The ranking rule.** Rank (a) ascending on the same-node DRAM board ratio under our convention at the WORSE of the two cards
|
||||
(the lower ratio is the smaller chip edge); (b) ascending on the ProgPoW-premise ratio at the same cell; (c) phase B: whether
|
||||
the chain's GPUs earn an income a hash chip cannot. A chain with any pending or proxy cell in a column is ranked provisionally
|
||||
in that column and says so; a NOT RUN chain is unranked. PASS is never written; the ranking is the rows' order, nothing more.
|
||||
|
||||
## 2. The card rows (MEASURED; board power; stock)
|
||||
|
||||
<!-- ledger:cards:start -->
|
||||
| chain | algorithm | dataset on the row | reward split (mining / proving) | RTX 4090 stock: nJ per hash (MH/s, W, f) | RTX 5090 stock: nJ per hash (MH/s, W, f) | twin | sources |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| Igneum | class v6, the live signing object 0x2a1d6caab4c24564 (2.0 devnet, chain id 4465) | 4.29 GB (4,294,967,296 bytes); 2^30 words, the live policy (70a6c703); 256 random 4-byte reads per hash, 62,120 instructions per hash (instruction convention) | 80 / 20 (the Token Value volume on master (D04, token-value/README.md: the 80/20 emission allocation); PROVING_POOL_SHARE_PERCENT) | 8,369 MEASURED (30.67 MH/s, 256.7 W, f 0.848; spread 0.67%; vast 55009211, driver 580; stock (the pod refused -lgc); clocks_sm_median pending the ledger session) | 5,597 MEASURED (68.66 MH/s, 384.3 W, f 0.811; spread 2.11%; vast 55006481, driver 580; stock (the pod refused -lgc); the host cap 500 W, the card drew 384) | yes (the same address stream, the arithmetic cut) | RTX 4090: build-1:/srv/artefacts/lab/ctl-v6/vast-55009211/ (rows.jsonl, SHA256SUMS); the cohort table build-1:/srv/artefacts/lab/cohort/TABLE.md; RTX 5090: build-1:/srv/artefacts/lab/ctl-v6/vast-55006481/; the cohort table build-1:/srv/artefacts/lab/cohort/TABLE.md |
|
||||
| Ravencoin | KAWPOW (ProgPoW 0.9.4 with Ravencoin's kiss99 constants, 7,500-block epochs and a 512-parent DAG; live since block 1,219,736, 6 May 2020) | 6.18 GB (6,182,403,712 bytes); KAWPOW's current DAG size at Ravencoin height 4,573,707 (epoch 609, read 13:0x UK): the official ProgPoW 0.9.4 kernel built at Ethash block 18,270,000 (epoch 609, the same byte count), harness progpow-094/gpu/pp_harness_18270000; KAWPOW's own constants NOT applied, so the row is 'ProgPoW 0.9.4 official at KAWPOW's DAG size (proxy)'; 64 coalesced 256-byte reads per hash; F1 GPU = CPU on both nonces (cpu/vectors-094-kawpow-size-18270000.txt) | 100 / 0 (Ravencoin's block reward goes to the miner in full (no second income to GPUs)) | 6,920 MEASURED (prior row: kawpowminer, not the pinned kernel; a PROXY until the ledger session's pinned-kernel row lands) (58.95 MH/s, 409.0 W, f none; spread none; RunPod, the comparative lane a2ed5c31; stock) | pending: pending (COHORT, the ledger session) | yes, by construction (progpow-094/gpu/pp_twin 18270000 26 250: the 64 dependent entry reads per 16-lane hash) | RTX 4090: build-1:/srv/artefacts/lab/adv/rows/rows.md (the ctl-progpow-094 section, card rows) |
|
||||
| Firo | FiroPoW (ProgPoW 0.9.4 with Firo's constants; live since block 419,264, 26 October 2021) | = Ravencoin's row | 100 / 0 | = Ravencoin's row | = Ravencoin's row | as Ravencoin | as KAWPOW: the pinned ProgPoW 0.9.4 reference; FiroPoW differs from KAWPOW only in its constants and DAG schedule, so the lab serves ONE row for both and says so |
|
||||
| Iron Fish | FishHash (live since hardfork 1, April 2024) | 4.83 GB (4,831,835,776 bytes); 37,748,717 items of 128 bytes, FIXED (no epochs); 96 random 128-byte reads per hash, 9,856 lane-instructions | 100 / 0 (Iron Fish's block reward is mining only) | pending: pending (COHORT, the ledger session); CONTROLS' rate-only read 19.73 MH/s at stock, no sampler (rate only 19.73 MH/s) | pending: pending (COHORT, the ledger session); CONTROLS' rate-only sm_120 read 45.37 MH/s full, 39.71 twin, F1 PASS (rate only 45.37 MH/s) | yes (a trace replay of the kernel's own item indexes; its energy read as a bound: the twin runs 10 percent slower than the full kernel) | RTX 4090: build-1:/srv/artefacts/lab/controls/fishhash/gpu/bench-4090.log; RTX 5090: build-1:/srv/artefacts/lab/controls/fishhash/ (fishhash.md) |
|
||||
| Ethereum Classic | Etchash (Ethash with ECIP-1099's 60,000-block epochs; live since block 11,700,000, November 2020) | 4.64 GB (4,638,898,816 bytes); ETC height 25,502,946 (read 12:5x UK) = Etchash epoch 425 (ECIP-1099: height / 60,000), the harness's block argument 12,750,000 (the Ethash epoch with the same size table index); 64 coalesced 128-byte reads per hash (two 64-byte half-mixes); CONTROLS' rate-only read on a 4090 at stock 29.90 MH/s full and 29.90 twin (memory-bound) | 100 / 0 (ETC's block reward is mining only) | pending: pending (COHORT, the ledger session; the harness staged 13:0x UK); CONTROLS' rate-only read 29.90 MH/s (rate only 29.90 MH/s) | pending: pending (COHORT, the ledger session; the sm_120 build from etchash/src) | yes, by construction | RTX 4090: build-1:/srv/artefacts/lab/controls/etchash/ (etchash.md) |
|
||||
| Ergo | Autolykos2 (k = 32, n = 26, Blake2b-256; the table grows 5 percent every 51,200 blocks) | 7.27 GB (7,272,058,080 bytes); the live table at Ergo height 1,890,820 (api.ergoplatform.com, 13:0x UK): N = 227,251,815 elements of 32 bytes (+5 percent at height 1,894,400; the row names its height); 33 random 32-byte reads per hash; the table builds on the device in 1.5 s; CONTROLS' rate-only read on a 4090 at stock 216.2 MH/s full, 201.8 twin | 100 / 0 (Ergo's block reward is mining only) | pending: pending (COHORT, the ledger session; the harness staged 13:1x UK); CONTROLS' rate-only read 216.2 MH/s full, 201.8 twin (rate only 216.20 MH/s) | pending: pending (COHORT, the ledger session; the sm_120 build from src) | yes (a trace replay) | RTX 4090: build-1:/srv/artefacts/lab/controls/autolykos/ (autolykos.md) |
|
||||
<!-- ledger:cards:end -->
|
||||
|
||||
## 3. The chip-to-GPU edge, both conventions (WITNESS chip rows over MEASURED card rows)
|
||||
|
||||
<!-- ledger:edge:start -->
|
||||
| chain | the cheapest credible same-node DRAM board (set v3, nJ per hash, band) | ratio at the RTX 4090 stock (ours) | ratio at the RTX 5090 stock (ours) | ProgPoW-premise ratio at the RTX 4090 | ProgPoW-premise ratio at the RTX 5090 | the die as the residual (nJ; ratio at the worse card) | real chip on the market | source |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| Igneum | 1,542 (1,329 to 1,941); GDDR7 board + register file sized to the work + fused operations, N5; split memory 527 / ALU 166 / tax 849; WITNESS (modelled, complete machine, instruction convention) | 5.43x (4.31 to 6.30) | 3.63x (2.88 to 4.21) | 1.01x to 1.03x (f 0.853 measured) | 1.02x to 1.03x (f 0.811 measured on the stock pod) | 335 (N2 full-SRAM die, the coexistence row); 25.0x | none on the market | docs/analysis/class-v6/1p5x/lab/adv/v3/ledger-rows.md and ledger-rows.json (class-v6-adversary 47f16b86f); build-1:/srv/artefacts/lab/adv/rows/ctl-v6.json and rows.md (set v3, 2026-10-09T11:33Z); the die 12.0x at the 5090 knee, USD 0.77 per MH/s |
|
||||
| Ravencoin | 1,757 (1,453 to 2,147); GDDR7 board + register file sized to the work + fused operations, N5; bandwidth-bound at 16 KB per hash (93 MH/s per board); split memory 647 / ALU and tax per rows.md; WITNESS (modelled on the kawpowminer census as the work proxy until CONTROLS' trace replaces it) | 3.94x (3.22 to 4.76) | pending | 1.10x to 1.20x (the premise's own figure; no measured twin on this kernel yet) | 1.10x to 1.20x (the premise's own figure; the twin row makes it measured) | 697 (N2 full-SRAM die, the coexistence row (USD 1.89 per MH/s); the cheapest specialist of any kind is near-memory base dies at 669 nJ); 9.9x | none on the market | docs/analysis/class-v6/1p5x/lab/adv/v3/ledger-rows.md and ledger-rows.json (class-v6-adversary 47f16b86f); build-1:/srv/artefacts/lab/adv/rows/ctl-progpow-094.json (set v3) |
|
||||
| Firo (= Ravencoin's row) | 1,757 (1,453 to 2,147); GDDR7 board + register file sized to the work + fused operations, N5; bandwidth-bound at 16 KB per hash (93 MH/s per board); split memory 647 / ALU and tax per rows.md; WITNESS (modelled on the kawpowminer census as the work proxy until CONTROLS' trace replaces it) | 3.94x (3.22 to 4.76) | pending | 1.10x to 1.20x (the premise's own figure; no measured twin on this kernel yet) | 1.10x to 1.20x (the premise's own figure; the twin row makes it measured) | 697 (N2 full-SRAM die, the coexistence row (USD 1.89 per MH/s); the cheapest specialist of any kind is near-memory base dies at 669 nJ); 9.9x | none on the market | = Ravencoin's row |
|
||||
| Iron Fish | 1,051 (881 to 1,257); GDDR7 board + register file sized to the work + fused operations, N5; bandwidth-bound at 124 MH/s per board; 95 percent of the board's energy is the memory path; split memory 529 / ALU 46 / tax 503; WITNESS (modelled; the card's cell pending, so the ratio is pending) | pending | pending | pending (needs the measured f from the twin) | pending | 170 (full SRAM dies (three N2 reticles) + sized + fused operations); pending | none on the market | build-1:/srv/artefacts/lab/adv/rows/ctl-fishhash.json and rows.md (set v3) |
|
||||
| Ethereum Classic | 765 (638 to 919); GDDR7 board + register file sized to the work + fused operations, N5; USD 3.25 per MH/s; split per ledger-rows.md; WITNESS (modelled; the card cells pending, so the ratios are pending) | pending | pending | 1.10x to 1.20x (the premise's own figure; the twin row makes it measured) | 1.10x to 1.20x (the premise's own figure) | 177 (N2 full-SRAM die, the coexistence row); pending | Bitmain Antminer E9 Pro: 3,680 MH/s at 2,200 W = 598 nJ per hash, WITNESS (a manufacturer's published figure, not measured by the lab); Bitmain's stated figures as listed on minerstat's hardware page (https://minerstat.com/hardware/antminer-e9-pro): 3.68 GH/s on Ethash at 2,200 W, release 2023-02; 2,200 W / 3,680 MH/s = 0.598 J per MH = 598 nJ per hash | docs/analysis/class-v6/1p5x/lab/adv/v3/ledger-rows.md and ledger-rows.json (class-v6-adversary 47f16b86f, wording 82330b04c) |
|
||||
| Ergo | pending: pending (ADVERSARY: the chip rows from CONTROLS' trace; default 15:00 UK, else pending (adversary)) | pending | pending | pending | pending | pending | none on the market | pending |
|
||||
<!-- ledger:edge:end -->
|
||||
|
||||
## 4. The two phases with each chain's actual split
|
||||
|
||||
<!-- ledger:phases:start -->
|
||||
| chain | split | phase A (before investment) | phase B (spend sunk) | an income a hash chip cannot earn | source |
|
||||
|---|---|---|---|---|---|
|
||||
| Igneum | 80 / 20 | before investment (24 months, the mining share 80 percent of the year-1 subsidy): the same-node board maker recovers its spend on NO fleet from 100 to 100 M boards on the growing or flat price path (100,000 boards: net USD -55.4 M on 58.8 M revenue growing; -37.6 M on 76.6 M flat); only the falling path pays it; the N2 die recovers on no path | spend sunk, flat path at USD 0.10 per kWh, the chip at 10 percent of the hash: mining revenue USD 0.354 per TH; the GPU's proving income at the ratified 20 percent share USD 0.098 per TH against the board chip's operating advantage USD 0.046 per TH (GPU electricity 0.111 less the chip's electricity, hosting and maintenance 0.066): the GPU out-earns the built board chip; the cancelling proving share 9 percent (board), 13 (hybrid), 20 (die) | yes by rule (the proving share of every block, 20 percent ratified); LIVE ON DEVNET with on-chain paid records (663 paid v1 segment records to 30 prover keys on chain 4465, the PROVING LIVE lane's file bd3f53c1 on build-1), not yet in a shipped package | build-1:/srv/artefacts/lab/adv/rows/rows.md (the two-phase section), ADVERSARY commit 545f3f8a0; ctl-v6-coexistence.json; WITNESS; phase B NOT LIVE until a shipped package runs a prover |
|
||||
| Ravencoin | 100 / 0 | before investment: no fleet from 100 to 100 M machines recovers a board chip's spend on any path, because Ravencoin's whole mining emission over 24 months (about USD 2.9 M at the public price) sits below one USD 35 M design cost; 10,000 boards would take 56 percent of the 721 GH/s network and earn USD 1.6 M to 2.4 M against a USD 44 M spend | spend sunk, mining only: mining revenue USD 0.064 per TH of the chain's own hashes; the board chip's recurring cost USD 0.069 per TH at ten cents; the chip's operating advantage over the RTX 4090 at stock (6,920 nJ) USD 0.123 per TH (the GPU's electricity 0.192 less the chip's 0.069) stands in full, no prover income | no (the block reward is mining only) | docs/analysis/class-v6/1p5x/lab/adv/v3/ctl-progpow-094-coexistence.json (rows[].phase_a, rows[].phase_b_split; method lab/adv/README.md section 14), ADVERSARY commit d7db3e596 on class-v6-adversary; WITNESS, the chain's own units, the 100/0 split, the same 24-month life, design cost and fleet rule as v6, the three price paths (flat at the public price, x2 growing, x0.5 falling, the hash held); the public lines on the row |
|
||||
| Firo | 100 / 0 | as KAWPOW | as KAWPOW | no | as KAWPOW; = KAWPOW's row at a 100/0 split |
|
||||
| Iron Fish | 100 / 0 | before investment: no fleet from 100 to 100 M machines recovers a board chip's spend on any path; Iron Fish's whole mining emission over 24 months is about USD 1.4 M at the public price (10,000 boards would take 84 percent of the hash for USD 1.1 M to 1.6 M) | spend sunk, mining only: mining revenue USD 0.086 per TH of the chain's own hashes; the board chip's recurring cost USD 0.043 per TH at ten cents; the GPU side NOT RUN until the card cell lands; no prover income by construction | no | docs/analysis/class-v6/1p5x/lab/adv/v3/ctl-fishhash-coexistence.json, ADVERSARY commit d7db3e596 on class-v6-adversary; WITNESS, the chain's own units, the 100/0 split, the same life, design cost, fleet rule and price paths as v6; the GPU side of phase B NOT RUN until COHORT's card rows |
|
||||
| Ethereum Classic | 100 / 0 | before investment: no fleet from 100 to 100 M machines recovers a board chip's spend on any path; ETC's whole mining emission over 24 months is about USD 83 M at the public price, but 10,000 boards are 2 percent of 120.9 TH/s and earn USD 1.0 M to 1.5 M, and 100,000 boards (13 percent) USD 9 M to 13 M against a USD 150 M spend | spend sunk, mining only: mining revenue USD 0.0087 per TH of the chain's own hashes, below even the modelled board's recurring cost (USD 0.031 per TH) and below the E9 Pro's own electricity at ten cents (598 nJ = USD 0.017 per TH): at the public figures nobody mines ETC at a profit at ten cents and phase B is a loss on both sides; the GPU side NOT RUN until the card cell lands | no | docs/analysis/class-v6/1p5x/lab/adv/v3/ctl-etchash-coexistence.json, ADVERSARY commit d7db3e596 on class-v6-adversary; WITNESS, the chain's own units, the 100/0 split, the same life, design cost, fleet rule and price paths as v6; the GPU side of phase B NOT RUN until COHORT's card rows |
|
||||
| Ergo | 100 / 0 | pending (ADVERSARY) | pending (ADVERSARY); no prover income by construction | no | pending; pending (ADVERSARY at the 100/0 split, the public line on the row) |
|
||||
<!-- ledger:phases:end -->
|
||||
|
||||
## 5. The ranking
|
||||
|
||||
<!-- ledger:ranking:start -->
|
||||
**(a) Ours, the same-node DRAM board at the worse card (the smaller chip edge first):**
|
||||
No order in this column yet: a cross-chain comparison is made only between cells from the same card, state and session, and the measured cells today come from 2 sessions (lab-cohort-ctl-v6 (COHORT, 11:49 to 12:30 UK, one chain); comparative-lane a2ed5c31 (RunPod, one chain, an earlier session)); the ledger session (both cards, every chain back to back) fills it. Each chain's own number, same card and state, as it stands:
|
||||
- Igneum: RTX 4090 stock 5.43x (4.31 to 6.30); RTX 5090 stock 3.63x (2.88 to 4.21)
|
||||
- Ravencoin: RTX 4090 stock 3.94x (3.22 to 4.76); RTX 5090 stock pending (a pending or proxy cell in this row)
|
||||
- Iron Fish: unranked in this column (no measured card cell yet)
|
||||
- Ethereum Classic: unranked in this column (no measured card cell yet)
|
||||
- Ergo: unranked in this column (no measured card cell yet)
|
||||
|
||||
**(b) ProgPoW's premise (the chip keeps a GPU-class datapath):**
|
||||
- Igneum: RTX 4090 1.01x to 1.03x (f 0.853 measured); RTX 5090 1.02x to 1.03x (f 0.811 measured on the stock pod)
|
||||
- Ravencoin: RTX 4090 1.10x to 1.20x (the premise's own figure; no measured twin on this kernel yet); RTX 5090 1.10x to 1.20x (the premise's own figure; the twin row makes it measured)
|
||||
- Iron Fish: RTX 4090 pending (needs the measured f from the twin); RTX 5090 pending
|
||||
- Ethereum Classic: RTX 4090 1.10x to 1.20x (the premise's own figure; the twin row makes it measured); RTX 5090 1.10x to 1.20x (the premise's own figure)
|
||||
- Ergo: RTX 4090 pending; RTX 5090 pending
|
||||
|
||||
**(c) Phase B, an income a hash chip cannot earn:**
|
||||
- Igneum: yes by rule (the proving share of every block, 20 percent ratified); LIVE ON DEVNET with on-chain paid records (663 paid v1 segment records to 30 prover keys on chain 4465, the PROVING LIVE lane's file bd3f53c1 on build-1), not yet in a shipped package
|
||||
- Ravencoin: no (the block reward is mining only)
|
||||
- Firo: no
|
||||
- Iron Fish: no
|
||||
- Ethereum Classic: no
|
||||
- Ergo: no
|
||||
<!-- ledger:ranking:end -->
|
||||
|
||||
## 6. The claim under test, clause by clause
|
||||
|
||||
The sentence the ledger was ordered to carry, in exactly these words and nothing stronger, is served as the claim UNDER TEST with
|
||||
each clause labelled by its state on the current rows; it reads as established only when every clause is SUPPORTED. The fifth
|
||||
review's ruling stands (interface 8c.9): a comparative sentence needs the comparative row, and this ledger is that row.
|
||||
|
||||
<!-- ledger:claim:start -->
|
||||
**The headline, built from the supported clauses only:** under ProgPoW's own convention, the lowest measured chip-to-GPU edge of the chains on this ledger; and the only chain on this ledger whose GPUs are paid, by rule, an income a hash chip cannot earn (LIVE ON DEVNET, on-chain records (the PROVING LIVE lane, read 11:50:22Z 9 October 2026): on chain id 4465 (the Igneum 2.0 devnet) 663 paid v1 segment records (8 shards plus aggregation each) = 653.93 IGN paid from the proving pool to 30 distinct prover keys across chain blocks 3,000 to 30,687 (tip 31,719 at the read), plus 6,474 paid v0 shard records = 7,196 IGN; read by igneum_getProvingStatus, igneum_getSegmentRecords (3,965 segments) and igneum_getSegment; evidence build-1:/srv/artefacts/proving-live/prov-ledger-4465-tip31719-115022Z.json sha256 bd3f53c18c3ffa20354ea62ade9fd5e7a5794202a71c3c369a5be71484f7143a. NOT YET LIVE IN A SHIPPED PACKAGE: the payees are the fleet's prover stack (the same binaries the 2.0.3.1 HiveOS package wraps, cut 18:00 UK); the apps' prover is on by default on 24 GB cards but no app-proved record is on the record yet; a full-epoch line follows by 16:00 UK).
|
||||
|
||||
The sentence as ordered, served as the claim under test: "the lowest measured chip-to-GPU edge of any live GPU network under a published method, on both conventions, and the only one whose GPUs earn income a chip cannot"
|
||||
|
||||
Rule: the headline reads only what the like-for-like rows support, clause by clause (main's ruling, 13:2x UK 9 October 2026); under the ledger's own convention the measured edge per chain on the same card and state is stated as numbers, with no ranking word until the rows say so after the ledger session.
|
||||
|
||||
| clause | convention | state on the current rows | why |
|
||||
|---|---|---|---|
|
||||
| under ProgPoW's own convention, the lowest measured chip-to-GPU edge of the chains on this ledger | ProgPoW's premise (a chip that keeps a GPU-class datapath) | SUPPORTED on the current rows | Igneum v6 1.01x to 1.03x (its measured f 0.81 to 0.85) against ProgPoW's own 1.10x to 1.20x for KAWPOW and FiroPoW; FishHash and Etchash pending their twins |
|
||||
| under the ledger's convention (the cheapest credible programmable specialist, adversarial set v3), each chain's measured edge on the same card and state, as numbers | ours | NUMBERS ONLY; no ranking until the ledger session (COHORT, both cards, every chain back to back, the pinned 0.9.4 kernel, twins; 14:45 UK) | the current cells come from different sessions (the lab's cohort pods for v6, the comparative lane's pod for KAWPOW) and a cross-chain comparison is made only between cells from the same card, state and session; at the RTX 4090 at stock the cells read v6 5.4x (5.67x on the second pod) and KAWPOW 3.9x (a proxy row from an earlier session); FishHash and Etchash pending |
|
||||
| and the only chain on this ledger whose GPUs are paid, by rule, an income a hash chip cannot earn | phase B, each chain's actual reward split | LABELLED BY THE PROVING LIVE LANE | the proving share (20 percent ratified) is paid to GPUs for work a hash chip cannot do; every other chain on the ledger pays mining only; the live state of that income is the PROVING LIVE lane's evidence line |
|
||||
|
||||
Withdrawn: the earlier line 'KAWPOW and FiroPoW read 5 to 6x on the measured 4090 rows and v6 reads 2.35x' is withdrawn: it compared v6 at the 5090's 1,300 MHz knee with KAWPOW at the 4090 at stock, two cards and two states; the like-for-like cells are the ones on this ledger.
|
||||
|
||||
The last clause's label from the PROVING LIVE lane: LIVE ON DEVNET, on-chain records (the PROVING LIVE lane, read 11:50:22Z 9 October 2026): on chain id 4465 (the Igneum 2.0 devnet) 663 paid v1 segment records (8 shards plus aggregation each) = 653.93 IGN paid from the proving pool to 30 distinct prover keys across chain blocks 3,000 to 30,687 (tip 31,719 at the read), plus 6,474 paid v0 shard records = 7,196 IGN; read by igneum_getProvingStatus, igneum_getSegmentRecords (3,965 segments) and igneum_getSegment; evidence build-1:/srv/artefacts/proving-live/prov-ledger-4465-tip31719-115022Z.json sha256 bd3f53c18c3ffa20354ea62ade9fd5e7a5794202a71c3c369a5be71484f7143a. NOT YET LIVE IN A SHIPPED PACKAGE: the payees are the fleet's prover stack (the same binaries the 2.0.3.1 HiveOS package wraps, cut 18:00 UK); the apps' prover is on by default on 24 GB cards but no app-proved record is on the record yet; a full-epoch line follows by 16:00 UK
|
||||
<!-- ledger:claim:end -->
|
||||
|
||||
## 7. The reproduction kit
|
||||
|
||||
build-1:/srv/artefacts/ledger/<stamp>/ (the stamp and the sha256 of the kit on the row below when it lands): the pinned controls
|
||||
(the frozen v6 pack and twin, the ProgPoW 0.9.4 reference harness, the FishHash harness, the Etchash harness when staged), the
|
||||
gen-6 Igneum worker, the lab's sampler and session script, the chain references by commit and sha, SHA256SUMS, and a README with
|
||||
one command per card and the expected rows within their bands (ROWS.md, rendered from ledger.json). Anyone with a 4090 can run it.
|
||||
|
||||
| item | state |
|
||||
|---|---|
|
||||
| the kit on build-1 | pending (16:30 UK clock) |
|
||||
| the kit's sha256 | pending |
|
||||
| the public page | pending (17:30 UK clock; /gpu-ledger) |
|
||||
|
||||
## 8. Faults and corrections (reported once)
|
||||
|
||||
| when (UK) | what | state |
|
||||
|---|---|---|
|
||||
| 13:1x | the brief's "Ravencoin's own 1.5x claim" has no source in the pinned ProgPoW text (the README states 1.1x to 1.2x); the row carries the README's figure and the brief's figure as NOT FOUND | on the row |
|
||||
| 13:1x | the earlier like-for-like line ("KAWPOW 5 to 6x against v6 2.35x") compared v6 at the 5090 knee with KAWPOW at the 4090 stock; at the same cell (4090 stock, set v3) the rows read v6 5.4x against KAWPOW 3.9x (proxy), so clause (a) of the claim is NOT SUPPORTED on today's rows | reported to main 13:1x; the ledger session's pinned-kernel rows decide |
|
||||
| 13:0x | the lab's cohort cells stand in for the ledger session's cells until COHORT's back-to-back rows land (clock 14:45) | pending |
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# Comparative review: what other live GPU chains publish about their own chip resistance (9 October 2026)
|
||||
|
||||
The row the laboratory interface names in section 8c item 9 ("a comparative review of what other chains publish: a row of the table, NOT RUN until done"). Method: the chain's own pages were read on 9 October 2026 (the date-read column); every quotation is verbatim and under 15 words, with its URL; nothing was downloaded or run; a paraphrase is never inside quotation marks. The last column answers one question per chain: does the chain's own record carry a measured or priced attacker row (a chip designed, modelled or measured against the chain's work), and where we looked. This file states what others publish; it serves no ranking sentence. The site's own claim stays the approved wording of the interface, verbatim, and nothing stronger.
|
||||
|
||||
## 1. The rows
|
||||
|
||||
| Chain, algorithm | Their statement, verbatim | Where it sits (URL), date read | Their convention | A measured or priced attacker row in their record |
|
||||
|---|---|---|---|---|
|
||||
| Ravencoin, KAWPOW | "ASIC resistant – Uses the KAWPOW hashing algorithm to discourage the development of ASIC hardware" | https://ravencoin.org/about/ (the project page, no date shown), read 9 October 2026 | ProgPoW's premise, inherited: "The KAWPOW algorithm was derived from ProgPOW and ethhash, with modifications" (same page) | None found. Looked at ravencoin.org/about, the Ravencoin repository README (github.com/RavenProject/Ravencoin, no ASIC text) and EIP-1057. A "1.5x" figure for KAWPOW was not found on any Ravencoin page; the number quoted to the project in review is a reviewer's, not Ravencoin's. |
|
||||
| Ravencoin, KAWPOW (the second sentence) | "limited potential for ASIC efficiency gains compared to consumer grade hardware" | https://ravencoin.org/about/, read 9 October 2026 | as above | as above |
|
||||
| ProgPoW (the algorithm KAWPOW and FiroPoW derive from; EIP-1057) | "These would result in minimal, roughly 1.1-1.2x, efficiency gains." | https://eips.ethereum.org/EIPS/eip-1057 (section "ProgPoW Algorithm Walkthrough"), read 9 October 2026 | Its premise, in its own words: "can have a customized ASIC created for it with slightly less functionality" (section "Rationale for PoW on Commodity Hardware"); the goal: "have the algorithm's requirements match what is available on commodity GPUs" | None found on the EIP page or the reference README (github.com/ifdefelse/ProgPOW): the 1.1x to 1.2x is a design-walkthrough estimate of what a chip saves by leaving GPU parts out, not a priced design. Version 0.9.4's stated purpose: "patch the condition modifying the input state of the last keccak pass" (the README). |
|
||||
| Iron Fish, FishHash (FIP-3, 20 December 2023) | "levels the playing field between different mining hardware (ASICS, FPGAs and GPUs)" | https://fips.ironfish.network/fips/fip-3-memory-hard-mining-algorithm (Abstract), read 9 October 2026 | Memory-bound by design: "difference between specialized and widely available hardware is as small as possible" (Specification); the dataset "equals 4608 MBytes" | None found. FIP-3's analysis is static (memory and compute demand); its one ratio is about Ethash's shipped chips: "much more efficient (2-10x) in terms of power consumption" (Rationale). The Trail of Bits audit (https://ironfish.network/learn/blog/2024-05-14-fish-hash-audit, read 9 October 2026) is cryptanalytic: "no practical exploitations were discovered around the memory-hardness requirement"; it prices no chip. |
|
||||
| Ethereum Classic, ETChash | "there is no such thing as ASIC resistance" | https://ethereumclassic.org/faqs/miners/ (the question "Is Ethereum Classic's mining algorithm ASIC resistant?"), read 9 October 2026 | A shipped-chip observation, not a model: "mine ETCHash 2x to 6x more energy efficiently than GPUs" (same answer) | The only chain of the six with real chips on its work; the 2x to 6x is stated without a source on the page, and the hardware guide names no model: "A range of ASIC miners Ethereum Classic are available" (https://ethereumclassic.org/mining/hardware/, read 9 October 2026). No designed-attacker row; none claimed. |
|
||||
| Ergo, Autolykos v2 | "fairly launched efficient ASIC-resistant Proof of Work algorithm" | https://docs.ergoplatform.com/mining/autolykos/, read 9 October 2026 | A stored table: "a single table of ~2 GB (initially)"; "designed to be efficient for miners who store the table" | None found on the Autolykos documentation page: no ratio, no priced or measured design. (The project's own comparative lane found no open GPU miner that passes its verifier on today's table, so Ergo is a non-comparable peer under the open-miner rule; recorded, not scored.) |
|
||||
| Firo, FiroPoW (release post, 1 October 2021) | "designed to be both FPGA and ASIC resistant to even the playing field" | https://firo.org/2021/10/01/firopow-and-instantsend-release.html, read 9 October 2026 | ProgPoW's premise, inherited: "FiroPoW follows ProgPoW's 0.9.4 spec with a small change" (same post) | None found in the release post: no ratio, no priced or measured design; the post states the DAG start ("slightly over 4GB") and the per-block change. |
|
||||
|
||||
## 2. What the rows say together
|
||||
|
||||
- Four of the six (Ravencoin, FishHash, Ergo, Firo) publish a qualitative sentence (resistant, levels the playing field) and no number; two publish a number: ProgPoW's 1.1x to 1.2x (a design estimate under its own premise, inherited by Ravencoin and Firo) and Ethereum Classic's 2x to 6x (shipped chips, no source on the page).
|
||||
- None of the six carries, on the pages read, a priced or measured attacker design against its own work (a chip designed, placed and costed, or a chip measured). "None found" on every row means: not on the pages named in the row; a page we did not read may exist.
|
||||
- Two conventions are in play across the field: ProgPoW's premise (a chip keeps a GPU-class datapath; its edge is what it leaves out) and the whole-machine premise (the cheapest programmable core the specialist would build, as a complete machine). Ethereum Classic's figure is neither: it is what shipped chips do.
|
||||
- This project's own rows, for the record beside them (docs/analysis/class-v6/1p5x/lab/table.md on master; the adversary lane's rows on branch class-v6-adversary): under ProgPoW's premise the frozen class v6 object reads 1.01x to 1.02x; under the whole-machine premise the same-node DRAM board reads 2.60x at the 5090 knee (2.06x to 3.02x, set v2, WITNESS) and 5.67x at the worst cohort cell; KAWPOW and FiroPoW under the whole-machine premise read 4.24x on set v2 against a rented 4090 at stock (the KAWPOW census as the op-count proxy, PROXY until the pinned 0.9.4 control is measured). No chip is measured on any side.
|
||||
|
||||
## 3. What this review does not license
|
||||
|
||||
The fifth review withdrew the sentence that no other GPU chain has such a record written down; this file is the row that was NOT RUN. It establishes only what the named pages say on the date read. It does not establish that no other chain holds a priced attacker row somewhere else, and it does not rank any chain; a ranking sentence needs the laboratory's ruling on this row, not this file.
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
{
|
||||
"title": "The GPU network ledger",
|
||||
"stamp": "2026-10-09",
|
||||
"adversarial_set": "v3",
|
||||
"adversarial_set_source": "build-1:/srv/artefacts/lab/adv/ (README.md, design-notes/adversarial-sets-v1-v2-v3.md, rows/, SHA256SUMS); set v3 commit b2b9ee0b9 on class-v6-adversary",
|
||||
"cards": [
|
||||
{"id": "4090", "name": "RTX 4090", "state": "stock", "note": "uncapped host (450 of 450 W)"},
|
||||
{"id": "5090", "name": "RTX 5090", "state": "stock", "note": "uncapped host (575 or 600 W at default)"}
|
||||
],
|
||||
"ranking_rule": "Rank (a) ascending on the same-node DRAM board ratio under the ledger's convention at the WORSE of the two cards (the lower ratio is the smaller chip edge); (b) ascending on the ProgPoW-premise ratio at the same cell; (c) phase B: whether the chain's GPUs earn an income a hash chip cannot. A cross-chain comparison is made only between cells from the SAME card, the SAME state and the SAME session (main's ruling, 13:2x UK 9 October 2026); until the ledger session's cells exist the (a) column shows each chain's own number and no order. A chain with any pending or proxy cell in a column is ranked provisionally in that column and says so; a NOT RUN chain is unranked.",
|
||||
"mechanism": {
|
||||
"title": "Why the two honest-convention edges differ: the shape of the memory traffic",
|
||||
"rows": [
|
||||
{"chain": "Igneum v6", "traffic": "256 dependent random reads of one 32-byte sector each per hash over 4 GiB (8 KB moved, 1 KB consumed); the card is activation-bound: 7.86 G sector reads per second on the 4090 at its tFAW form, 17.6 G on the 5090", "card_cost": "27 to 31 nJ per random sector read all-in on the 4090 at stock (X7 and the sector-read rows); the ALU runs in the reads' shadow (f 0.85)", "chip_cost": "about 2.1 nJ per read on the chip's own GDDR7 board (1.2 on HBM3, 0.7 beside the bank): s = 12.8", "consequence": "the chip's edge on random reads is the largest of any term in the model; a read-dominated object carries it in full"},
|
||||
{"chain": "Ravencoin KAWPOW (and FiroPoW)", "traffic": "64 coalesced 256-byte DAG entries per 16-lane hash = 16 KB streamed per hash; the card is bandwidth-bound (1.03 TB/s at 62.6 MH/s on the 4090; 93 MH/s per modelled board)", "card_cost": "a coalesced 256-byte read costs the card far less per byte than a scattered sector (modelled 35 nJ per 256-byte entry against 27 nJ per 32-byte sector)", "chip_cost": "the chip streams the same bytes at the same DRAM bandwidth bound; its gain is on the per-byte energy and the 18 random-math ops per loop (a GPU-class datapath on the premise, fused lanes under ours)", "consequence": "a smaller memory-term gap, so a smaller honest-convention edge at the same card; the price is a DAG that grows (8.38 GB at Ethereum's epoch 871 for the control) and a 16 KB stream per hash"},
|
||||
{"chain": "Iron Fish FishHash", "traffic": "96 random 128-byte lines per hash over a fixed 4.83 GB (12 KB per hash, every byte consumed): between the two shapes (four sectors per activation)", "card_cost": "pending the measured cell (CONTROLS' rate-only read 242 GB/s of lines at 19.73 MH/s on the 4090)", "chip_cost": "95 percent of the modelled board's energy is its memory path", "consequence": "the ratio is pending its card cell"},
|
||||
{"chain": "Ethereum Classic Etchash", "traffic": "64 loops of two 64-byte half-mixes = 128-byte coalesced reads per hash over the epoch DAG; bandwidth-bound", "card_cost": "pending", "chip_cost": "the one chip that exists (the E9 Pro) on this shape", "consequence": "the calibration line"}
|
||||
],
|
||||
"note": "Stated as the mechanism, not as a mitigation: the lab's X7 verdict is that no read-dominated design reaches a small edge against a chip with its own memory on the same node, and the ledger shows the shape of each chain's traffic beside its ratio so a reader sees where each number comes from."
|
||||
},
|
||||
"chains": [
|
||||
{
|
||||
"id": "igneum-v6",
|
||||
"chain": "Igneum",
|
||||
"algorithm": "class v6, the live signing object 0x2a1d6caab4c24564 (2.0 devnet, chain id 4465)",
|
||||
"control": "ctl-v6",
|
||||
"pin": "tree 1a938abe4; pack x1-ctrl-ds30.tgz sha256 7606c13d92546e44feef485fde25716a206643e6466ef5d5251d41354eee9532; read-only twin x1-ctrl-ds30-readonly.tgz 7629ed9598c0297533c03d4a9cbd83338d47a7693a26dc8b86566f3f0982174b; worker igneum-worker-cuda-gen6 804a6f7fea10a62e07cf6cd01e99331de5149e683112b5870fc25d1e11e199bd (COHORT's rows on worker f5f3846c, kit bf9b85d6); build-1:/srv/artefacts/lab/controls/frozen-v6/",
|
||||
"dataset_bytes": 4294967296,
|
||||
"dataset_note": "2^30 words, the live policy (70a6c703); 256 random 4-byte reads per hash, 62,120 instructions per hash (instruction convention)",
|
||||
"twin": "yes (the same address stream, the arithmetic cut)",
|
||||
"split": {"mining": 80, "proving": 20, "source": "the Token Value volume on master (D04, token-value/README.md: the 80/20 emission allocation); PROVING_POOL_SHARE_PERCENT"},
|
||||
"own_claim": "none published as a number; the lab's X0 record: the same-node DRAM board 2.35x at the 5090 knee (set v0), 2.60x (set v3)",
|
||||
"cells": {
|
||||
"4090": {"status": "MEASURED", "session": "lab-cohort-ctl-v6 (COHORT, 11:49 to 12:30 UK, one chain)", "nj": 8368.9, "mhs": 30.673, "w": 256.7, "f": 0.848, "twin_nj": 7098.9, "spread_pct": 0.67, "clock": "stock (the pod refused -lgc); clocks_sm_median pending the ledger session", "pod": "vast 55009211, driver 580", "source": "build-1:/srv/artefacts/lab/ctl-v6/vast-55009211/ (rows.jsonl, SHA256SUMS); the cohort table build-1:/srv/artefacts/lab/cohort/TABLE.md", "note": "the lab's cohort cell (median of three); the second uncapped 4090 (vast 55006526) read 8,751; the ledger session's own row replaces this cell when it lands"},
|
||||
"5090": {"status": "MEASURED", "session": "lab-cohort-ctl-v6 (COHORT, 11:49 to 12:30 UK, one chain)", "nj": 5597.1, "mhs": 68.661, "w": 384.3, "f": 0.811, "twin_nj": 4537.6, "spread_pct": 2.11, "clock": "stock (the pod refused -lgc); the host cap 500 W, the card drew 384", "pod": "vast 55006481, driver 580", "source": "build-1:/srv/artefacts/lab/ctl-v6/vast-55006481/; the cohort table build-1:/srv/artefacts/lab/cohort/TABLE.md", "note": "the lab's cohort cell; the ledger session's uncapped-host row replaces it when it lands"}
|
||||
},
|
||||
"adversary": {
|
||||
"status": "WITNESS (modelled, complete machine, instruction convention)",
|
||||
"board": {"low": 1329, "nominal": 1542, "high": 1941, "design": "GDDR7 board + register file sized to the work + fused operations, N5", "split_nj": "memory 527 / ALU 166 / tax 849"},
|
||||
"die": {"nominal": 335, "design": "N2 full-SRAM die, the coexistence row"},
|
||||
"premise": {"4090": "1.01x to 1.03x (f 0.853 measured)", "5090": "1.02x to 1.03x (f 0.811 measured on the stock pod)"},
|
||||
"source": "docs/analysis/class-v6/1p5x/lab/adv/v3/ledger-rows.md and ledger-rows.json (class-v6-adversary 47f16b86f); build-1:/srv/artefacts/lab/adv/rows/ctl-v6.json and rows.md (set v3, 2026-10-09T11:33Z); the die 12.0x at the 5090 knee, USD 0.77 per MH/s"
|
||||
},
|
||||
"real_chip": null,
|
||||
"phases": {
|
||||
"status": "WITNESS; phase B NOT LIVE until a shipped package runs a prover",
|
||||
"A": "before investment (24 months, the mining share 80 percent of the year-1 subsidy): the same-node board maker recovers its spend on NO fleet from 100 to 100 M boards on the growing or flat price path (100,000 boards: net USD -55.4 M on 58.8 M revenue growing; -37.6 M on 76.6 M flat); only the falling path pays it; the N2 die recovers on no path",
|
||||
"B": "spend sunk, flat path at USD 0.10 per kWh, the chip at 10 percent of the hash: mining revenue USD 0.354 per TH; the GPU's proving income at the ratified 20 percent share USD 0.098 per TH against the board chip's operating advantage USD 0.046 per TH (GPU electricity 0.111 less the chip's electricity, hosting and maintenance 0.066): the GPU out-earns the built board chip; the cancelling proving share 9 percent (board), 13 (hybrid), 20 (die)",
|
||||
"income_a_chip_cannot_earn": "yes by rule (the proving share of every block, 20 percent ratified); LIVE ON DEVNET with on-chain paid records (663 paid v1 segment records to 30 prover keys on chain 4465, the PROVING LIVE lane's file bd3f53c1 on build-1), not yet in a shipped package",
|
||||
"source": "build-1:/srv/artefacts/lab/adv/rows/rows.md (the two-phase section), ADVERSARY commit 545f3f8a0; ctl-v6-coexistence.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rvn-kawpow",
|
||||
"chain": "Ravencoin",
|
||||
"algorithm": "KAWPOW (ProgPoW 0.9.4 with Ravencoin's kiss99 constants, 7,500-block epochs and a 512-parent DAG; live since block 1,219,736, 6 May 2020)",
|
||||
"control": "ctl-progpow-094",
|
||||
"pin": "ifdefelse/ProgPOW d035ae56536d2e6fc71aad609ac5085c9785d1b3, the reference's own CUDA kernel text, DAG generator and light cache (256 parents as the code implements); harness progpow-094/gpu/pp_harness_<block>, CPU reference cpu/pp094_ref; build-1:/srv/artefacts/lab/controls/progpow-094/ (SHA256SUMS)",
|
||||
"dataset_bytes": 6182403712,
|
||||
"dataset_note": "KAWPOW's current DAG size at Ravencoin height 4,573,707 (epoch 609, read 13:0x UK): the official ProgPoW 0.9.4 kernel built at Ethash block 18,270,000 (epoch 609, the same byte count), harness progpow-094/gpu/pp_harness_18270000; KAWPOW's own constants NOT applied, so the row is 'ProgPoW 0.9.4 official at KAWPOW's DAG size (proxy)'; 64 coalesced 256-byte reads per hash; F1 GPU = CPU on both nonces (cpu/vectors-094-kawpow-size-18270000.txt)",
|
||||
"twin": "yes, by construction (progpow-094/gpu/pp_twin 18270000 26 250: the 64 dependent entry reads per 16-lane hash)",
|
||||
"split": {"mining": 100, "proving": 0, "source": "Ravencoin's block reward goes to the miner in full (no second income to GPUs)"},
|
||||
"public": {"block_reward": "1,250 RVN", "block_time": "60 s", "network_hash": "721.3 GH/s", "price_usd": "0.0022", "source": "minerstat.com/coin/RVN, read 9 October 2026 13:3x UK (public figures, not measured by the lab)"},
|
||||
"own_claim": "the ProgPoW README (the pinned commit) states a specialised chip gains 1.1x to 1.2x on ProgPoW's premise (the chip keeps a GPU-class datapath). The figure 1.5x quoted in the ledger's brief as Ravencoin's own claim has no source in the pinned text or on the record: NOT FOUND; the row carries the README's 1.1x to 1.2x",
|
||||
"cells": {
|
||||
"4090": {"status": "MEASURED (prior row: kawpowminer, not the pinned kernel; a PROXY until the ledger session's pinned-kernel row lands)", "session": "comparative-lane a2ed5c31 (RunPod, one chain, an earlier session)", "nj": 6920, "mhs": 58.95, "w": 409, "f": null, "twin_nj": null, "spread_pct": null, "clock": "stock", "pod": "RunPod, the comparative lane a2ed5c31", "source": "build-1:/srv/artefacts/lab/adv/rows/rows.md (the ctl-progpow-094 section, card rows)", "note": "407 to 411 W at 58.95 MH/s; the ledger session re-measures with the pinned official kernel at Raven's DAG size (CONTROLS' rate-only read on a 4090 at stock: 58.95 MH/s full, the twin 60.99), board power sampled, median of three, the twin beside it"},
|
||||
"5090": {"status": "pending (COHORT, the ledger session)", "session": "pending", "nj": null, "mhs": null, "w": null, "f": null, "twin_nj": null, "spread_pct": null, "clock": "stock", "pod": "pending", "source": "pending", "note": ""}
|
||||
},
|
||||
"adversary": {
|
||||
"status": "WITNESS (modelled on the kawpowminer census as the work proxy until CONTROLS' trace replaces it)",
|
||||
"board": {"low": 1453, "nominal": 1757, "high": 2147, "design": "GDDR7 board + register file sized to the work + fused operations, N5; bandwidth-bound at 16 KB per hash (93 MH/s per board)", "split_nj": "memory 647 / ALU and tax per rows.md"},
|
||||
"die": {"nominal": 697, "design": "N2 full-SRAM die, the coexistence row (USD 1.89 per MH/s); the cheapest specialist of any kind is near-memory base dies at 669 nJ"},
|
||||
"premise": {"4090": "1.10x to 1.20x (the premise's own figure; no measured twin on this kernel yet)", "5090": "1.10x to 1.20x (the premise's own figure; the twin row makes it measured)"},
|
||||
"source": "docs/analysis/class-v6/1p5x/lab/adv/v3/ledger-rows.md and ledger-rows.json (class-v6-adversary 47f16b86f); build-1:/srv/artefacts/lab/adv/rows/ctl-progpow-094.json (set v3)"
|
||||
},
|
||||
"real_chip": null,
|
||||
"phases": {
|
||||
"status": "WITNESS, the chain's own units, the 100/0 split, the same 24-month life, design cost and fleet rule as v6, the three price paths (flat at the public price, x2 growing, x0.5 falling, the hash held); the public lines on the row",
|
||||
"A": "before investment: no fleet from 100 to 100 M machines recovers a board chip's spend on any path, because Ravencoin's whole mining emission over 24 months (about USD 2.9 M at the public price) sits below one USD 35 M design cost; 10,000 boards would take 56 percent of the 721 GH/s network and earn USD 1.6 M to 2.4 M against a USD 44 M spend",
|
||||
"B": "spend sunk, mining only: mining revenue USD 0.064 per TH of the chain's own hashes; the board chip's recurring cost USD 0.069 per TH at ten cents; the chip's operating advantage over the RTX 4090 at stock (6,920 nJ) USD 0.123 per TH (the GPU's electricity 0.192 less the chip's 0.069) stands in full, no prover income",
|
||||
"income_a_chip_cannot_earn": "no (the block reward is mining only)",
|
||||
"source": "docs/analysis/class-v6/1p5x/lab/adv/v3/ctl-progpow-094-coexistence.json (rows[].phase_a, rows[].phase_b_split; method lab/adv/README.md section 14), ADVERSARY commit d7db3e596 on class-v6-adversary"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "firo-firopow",
|
||||
"chain": "Firo",
|
||||
"algorithm": "FiroPoW (ProgPoW 0.9.4 with Firo's constants; live since block 419,264, 26 October 2021)",
|
||||
"control": "ctl-progpow-094 (the same row as KAWPOW)",
|
||||
"pin": "as KAWPOW: the pinned ProgPoW 0.9.4 reference; FiroPoW differs from KAWPOW only in its constants and DAG schedule, so the lab serves ONE row for both and says so",
|
||||
"dataset_bytes": null,
|
||||
"dataset_note": "= KAWPOW's row (its own DAG schedule is not measured separately)",
|
||||
"twin": "as KAWPOW",
|
||||
"split": {"mining": 100, "proving": 0, "source": "Firo's block reward splits between miners, masternodes and the development fund; the GPU's share is mining only (no proving income)"},
|
||||
"own_claim": "as ProgPoW's: 1.1x to 1.2x on its premise",
|
||||
"same_row_as": "rvn-kawpow",
|
||||
"cells": {},
|
||||
"adversary": {"status": "= KAWPOW's row", "board": null, "die": null, "premise": {}, "source": "as KAWPOW"},
|
||||
"real_chip": null,
|
||||
"phases": {"status": "= KAWPOW's row at a 100/0 split", "A": "as KAWPOW", "B": "as KAWPOW", "income_a_chip_cannot_earn": "no", "source": "as KAWPOW"}
|
||||
},
|
||||
{
|
||||
"id": "ironfish-fishhash",
|
||||
"chain": "Iron Fish",
|
||||
"algorithm": "FishHash (live since hardfork 1, April 2024)",
|
||||
"control": "ctl-fishhash",
|
||||
"pin": "iron-fish/fish-hash 823531c002e78259759f8b1f36ac9121c32059b1 (cpp/FishHash.cpp; the spec FishHash.pdf sha256 869870b9...); the lab's CUDA implementation bit-exact with the library on 64 items and 256 hashes, fingerprint f841605125ce375e; gpu/fh_cuda, cpu/fh_cli; build-1:/srv/artefacts/lab/controls/fishhash/ (SHA256SUMS)",
|
||||
"dataset_bytes": 4831835776,
|
||||
"dataset_note": "37,748,717 items of 128 bytes, FIXED (no epochs); 96 random 128-byte reads per hash, 9,856 lane-instructions",
|
||||
"twin": "yes (a trace replay of the kernel's own item indexes; its energy read as a bound: the twin runs 10 percent slower than the full kernel)",
|
||||
"split": {"mining": 100, "proving": 0, "source": "Iron Fish's block reward is mining only"},
|
||||
"public": {"block_reward": "17.25 IRON", "block_time": "61 s", "network_hash": "229.5 GH/s", "price_usd": "0.07", "source": "minerstat.com/coin/IRON, read 9 October 2026 13:3x UK (public figures, not measured by the lab)"},
|
||||
"own_claim": "none as a chip-edge number; the Trail of Bits review (Iron Fish blog, 14 May 2024) is the published outside check",
|
||||
"cells": {
|
||||
"4090": {"status": "pending (COHORT, the ledger session); CONTROLS' rate-only read 19.73 MH/s at stock, no sampler", "nj": null, "mhs": 19.73, "w": null, "f": null, "twin_nj": null, "spread_pct": null, "clock": "stock", "pod": "CONTROLS' rented 4090 (rate only)", "source": "build-1:/srv/artefacts/lab/controls/fishhash/gpu/bench-4090.log", "note": "242 GB/s of 128-byte lines at that rate"},
|
||||
"5090": {"status": "pending (COHORT, the ledger session); CONTROLS' rate-only sm_120 read 45.37 MH/s full, 39.71 twin, F1 PASS", "nj": null, "mhs": 45.37, "w": null, "f": null, "twin_nj": null, "spread_pct": null, "clock": "stock", "pod": "CONTROLS' rented 5090 (rate only)", "source": "build-1:/srv/artefacts/lab/controls/fishhash/ (fishhash.md)", "note": ""}
|
||||
},
|
||||
"adversary": {
|
||||
"status": "WITNESS (modelled; the card's cell pending, so the ratio is pending)",
|
||||
"board": {"low": 881, "nominal": 1051, "high": 1257, "design": "GDDR7 board + register file sized to the work + fused operations, N5; bandwidth-bound at 124 MH/s per board; 95 percent of the board's energy is the memory path", "split_nj": "memory 529 / ALU 46 / tax 503"},
|
||||
"die": {"nominal": 170, "design": "full SRAM dies (three N2 reticles) + sized + fused operations"},
|
||||
"premise": {"4090": "pending (needs the measured f from the twin)", "5090": "pending"},
|
||||
"source": "build-1:/srv/artefacts/lab/adv/rows/ctl-fishhash.json and rows.md (set v3)"
|
||||
},
|
||||
"real_chip": null,
|
||||
"phases": {"status": "WITNESS, the chain's own units, the 100/0 split, the same life, design cost, fleet rule and price paths as v6; the GPU side of phase B NOT RUN until COHORT's card rows", "A": "before investment: no fleet from 100 to 100 M machines recovers a board chip's spend on any path; Iron Fish's whole mining emission over 24 months is about USD 1.4 M at the public price (10,000 boards would take 84 percent of the hash for USD 1.1 M to 1.6 M)", "B": "spend sunk, mining only: mining revenue USD 0.086 per TH of the chain's own hashes; the board chip's recurring cost USD 0.043 per TH at ten cents; the GPU side NOT RUN until the card cell lands; no prover income by construction", "income_a_chip_cannot_earn": "no", "source": "docs/analysis/class-v6/1p5x/lab/adv/v3/ctl-fishhash-coexistence.json, ADVERSARY commit d7db3e596 on class-v6-adversary"}
|
||||
},
|
||||
{
|
||||
"id": "etc-etchash",
|
||||
"chain": "Ethereum Classic",
|
||||
"algorithm": "Etchash (Ethash with ECIP-1099's 60,000-block epochs; live since block 11,700,000, November 2020)",
|
||||
"control": "ctl-etchash (new for the ledger)",
|
||||
"pin": "a CUDA hashimoto over the pinned chfast/ethash v0.8.0 library's light cache and the ProgPOW repo's reference DAG generator (libethash objects), checked against ethash::hash on the CPU at block 0 and at the live epoch (F1: GPU = CPU on both nonces); gpu/etchash_cuda (`check <block> <nonces>`, `bench <block> 22 250`, `bench <block> 22 250 twin`), cpu/etc_ref; build-1:/srv/artefacts/lab/controls/etchash/ (SHA256SUMS; etchash.md)",
|
||||
"dataset_bytes": 4638898816,
|
||||
"dataset_note": "ETC height 25,502,946 (read 12:5x UK) = Etchash epoch 425 (ECIP-1099: height / 60,000), the harness's block argument 12,750,000 (the Ethash epoch with the same size table index); 64 coalesced 128-byte reads per hash (two 64-byte half-mixes); CONTROLS' rate-only read on a 4090 at stock 29.90 MH/s full and 29.90 twin (memory-bound)",
|
||||
"twin": "yes, by construction",
|
||||
"split": {"mining": 100, "proving": 0, "source": "ETC's block reward is mining only"},
|
||||
"public": {"block_reward": "2.048 ETC", "block_time": "16 s", "network_hash": "120.9 TH/s", "price_usd": "8.24", "source": "minerstat.com/coin/ETC, read 9 October 2026 13:3x UK (public figures, not measured by the lab)"},
|
||||
"own_claim": "none; the chain accepts chips (the Antminer E9 Pro mines it)",
|
||||
"cells": {
|
||||
"4090": {"status": "pending (COHORT, the ledger session; the harness staged 13:0x UK); CONTROLS' rate-only read 29.90 MH/s", "nj": null, "mhs": 29.90, "w": null, "f": null, "twin_nj": null, "spread_pct": null, "clock": "stock", "pod": "CONTROLS' rented 4090 (rate only)", "source": "build-1:/srv/artefacts/lab/controls/etchash/ (etchash.md)", "note": ""},
|
||||
"5090": {"status": "pending (COHORT, the ledger session; the sm_120 build from etchash/src)", "nj": null, "mhs": null, "w": null, "f": null, "twin_nj": null, "spread_pct": null, "clock": "stock", "pod": "pending", "source": "pending", "note": ""}
|
||||
},
|
||||
"adversary": {
|
||||
"status": "WITNESS (modelled; the card cells pending, so the ratios are pending)",
|
||||
"board": {"low": 638, "nominal": 765, "high": 919, "design": "GDDR7 board + register file sized to the work + fused operations, N5; USD 3.25 per MH/s", "split_nj": "per ledger-rows.md"},
|
||||
"die": {"nominal": 177, "design": "N2 full-SRAM die, the coexistence row"},
|
||||
"premise": {"4090": "1.10x to 1.20x (the premise's own figure; the twin row makes it measured)", "5090": "1.10x to 1.20x (the premise's own figure)"},
|
||||
"source": "docs/analysis/class-v6/1p5x/lab/adv/v3/ledger-rows.md and ledger-rows.json (class-v6-adversary 47f16b86f, wording 82330b04c)",
|
||||
"calibration": "the modelled same-node DRAM board 765 nJ per hash against the Antminer E9 Pro's 598 from its public figures: the model reads 1.28x PESSIMISTIC for the chip (it gives the chip more energy than the one real chip spends); a witness, never a ceiling"
|
||||
},
|
||||
"real_chip": {
|
||||
"name": "Bitmain Antminer E9 Pro",
|
||||
"mhs": 3680,
|
||||
"w": 2200,
|
||||
"nj": 597.8,
|
||||
"status": "WITNESS (a manufacturer's published figure, not measured by the lab)",
|
||||
"source": "Bitmain's stated figures as listed on minerstat's hardware page (https://minerstat.com/hardware/antminer-e9-pro): 3.68 GH/s on Ethash at 2,200 W, release 2023-02; 2,200 W / 3,680 MH/s = 0.598 J per MH = 598 nJ per hash",
|
||||
"note": "the ONE live GPU-mined chain with a chip on the market; the modelled board (765 nJ) sits beside it: 1.28x pessimistic for the chip"
|
||||
},
|
||||
"phases": {"status": "WITNESS, the chain's own units, the 100/0 split, the same life, design cost, fleet rule and price paths as v6; the GPU side of phase B NOT RUN until COHORT's card rows", "A": "before investment: no fleet from 100 to 100 M machines recovers a board chip's spend on any path; ETC's whole mining emission over 24 months is about USD 83 M at the public price, but 10,000 boards are 2 percent of 120.9 TH/s and earn USD 1.0 M to 1.5 M, and 100,000 boards (13 percent) USD 9 M to 13 M against a USD 150 M spend", "B": "spend sunk, mining only: mining revenue USD 0.0087 per TH of the chain's own hashes, below even the modelled board's recurring cost (USD 0.031 per TH) and below the E9 Pro's own electricity at ten cents (598 nJ = USD 0.017 per TH): at the public figures nobody mines ETC at a profit at ten cents and phase B is a loss on both sides; the GPU side NOT RUN until the card cell lands", "income_a_chip_cannot_earn": "no", "source": "docs/analysis/class-v6/1p5x/lab/adv/v3/ctl-etchash-coexistence.json, ADVERSARY commit d7db3e596 on class-v6-adversary"}
|
||||
},
|
||||
{
|
||||
"id": "ergo-autolykos2",
|
||||
"chain": "Ergo",
|
||||
"algorithm": "Autolykos2 (k = 32, n = 26, Blake2b-256; the table grows 5 percent every 51,200 blocks)",
|
||||
"control": "ctl-autolykos (new for the ledger, landed 13:1x UK)",
|
||||
"pin": "the published text ergoplatform/ergo master 3a6b00d3, AutolykosPowScheme.scala (sha256 330ae870...; the copy in src/), implemented in-house: CPU reference src/autolykos_ref.py (hashlib's Blake2b, lazy elements), GPU gpu/autolykos_cuda (`check <height> <N> <count> < fixtures`, `bench <height> <N> 20 250`, `bench ... twin`; sm_89, the sm_120 build from src); F1 at N = 65,536 and at the live table: elements 0 and last, 64 hits and the 256-hit fingerprints equal the reference's (f3cf1fd34d1002fa small, b6d07a7b15956179 live); build-1:/srv/artefacts/lab/controls/autolykos/ (SHA256SUMS; autolykos.md). CAVEAT on the row: the fixtures are the lab's own from an independent implementation of the published text (the reference repository has no known-answer v2 hit vector); the end-to-end check against a live Ergo block is owed, not claimed",
|
||||
"dataset_bytes": 7272058080,
|
||||
"dataset_note": "the live table at Ergo height 1,890,820 (api.ergoplatform.com, 13:0x UK): N = 227,251,815 elements of 32 bytes (+5 percent at height 1,894,400; the row names its height); 33 random 32-byte reads per hash; the table builds on the device in 1.5 s; CONTROLS' rate-only read on a 4090 at stock 216.2 MH/s full, 201.8 twin",
|
||||
"twin": "yes (a trace replay)",
|
||||
"split": {"mining": 100, "proving": 0, "source": "Ergo's block reward is mining only"},
|
||||
"public": {"block_reward": "3.009 ERG", "block_time": "120 s", "network_hash": "569.1 GH/s", "price_usd": "0.29", "source": "minerstat.com/coin/ERG, read 9 October 2026 13:4x UK (public figures, not measured by the lab)"},
|
||||
"own_claim": "none on the record as a chip-edge number",
|
||||
"cells": {
|
||||
"4090": {"status": "pending (COHORT, the ledger session; the harness staged 13:1x UK); CONTROLS' rate-only read 216.2 MH/s full, 201.8 twin", "session": "pending", "nj": null, "mhs": 216.2, "w": null, "f": null, "twin_nj": null, "spread_pct": null, "clock": "stock", "pod": "CONTROLS' rented 4090 (rate only)", "source": "build-1:/srv/artefacts/lab/controls/autolykos/ (autolykos.md)", "note": ""},
|
||||
"5090": {"status": "pending (COHORT, the ledger session; the sm_120 build from src)", "session": "pending", "nj": null, "mhs": null, "w": null, "f": null, "twin_nj": null, "spread_pct": null, "clock": "stock", "pod": "pending", "source": "pending", "note": ""}
|
||||
},
|
||||
"adversary": {"status": "pending (ADVERSARY: the chip rows from CONTROLS' trace; default 15:00 UK, else pending (adversary))", "board": null, "die": null, "premise": {"4090": "pending", "5090": "pending"}, "source": "pending"},
|
||||
"real_chip": null,
|
||||
"phases": {"status": "pending (ADVERSARY at the 100/0 split, the public line on the row)", "A": "pending (ADVERSARY)", "B": "pending (ADVERSARY); no prover income by construction", "income_a_chip_cannot_earn": "no", "source": "pending"}
|
||||
}
|
||||
],
|
||||
"claim": {
|
||||
"ordered_sentence": "the lowest measured chip-to-GPU edge of any live GPU network under a published method, on both conventions, and the only one whose GPUs earn income a chip cannot",
|
||||
"headline_rule": "the headline reads only what the like-for-like rows support, clause by clause (main's ruling, 13:2x UK 9 October 2026); under the ledger's own convention the measured edge per chain on the same card and state is stated as numbers, with no ranking word until the rows say so after the ledger session",
|
||||
"clauses": [
|
||||
{"text": "under ProgPoW's own convention, the lowest measured chip-to-GPU edge of the chains on this ledger", "convention": "ProgPoW's premise (a chip that keeps a GPU-class datapath)", "state": "SUPPORTED on the current rows", "why": "Igneum v6 1.01x to 1.03x (its measured f 0.81 to 0.85) against ProgPoW's own 1.10x to 1.20x for KAWPOW and FiroPoW; FishHash and Etchash pending their twins"},
|
||||
{"text": "under the ledger's convention (the cheapest credible programmable specialist, adversarial set v3), each chain's measured edge on the same card and state, as numbers", "convention": "ours", "state": "NUMBERS ONLY; no ranking until the ledger session (COHORT, both cards, every chain back to back, the pinned 0.9.4 kernel, twins; 14:45 UK)", "why": "the current cells come from different sessions (the lab's cohort pods for v6, the comparative lane's pod for KAWPOW) and a cross-chain comparison is made only between cells from the same card, state and session; at the RTX 4090 at stock the cells read v6 5.4x (5.67x on the second pod) and KAWPOW 3.9x (a proxy row from an earlier session); FishHash and Etchash pending"},
|
||||
{"text": "and the only chain on this ledger whose GPUs are paid, by rule, an income a hash chip cannot earn", "convention": "phase B, each chain's actual reward split", "state": "LABELLED BY THE PROVING LIVE LANE", "why": "the proving share (20 percent ratified) is paid to GPUs for work a hash chip cannot do; every other chain on the ledger pays mining only; the live state of that income is the PROVING LIVE lane's evidence line"}
|
||||
],
|
||||
"withdrawn": "the earlier line 'KAWPOW and FiroPoW read 5 to 6x on the measured 4090 rows and v6 reads 2.35x' is withdrawn: it compared v6 at the 5090's 1,300 MHz knee with KAWPOW at the 4090 at stock, two cards and two states; the like-for-like cells are the ones on this ledger",
|
||||
"proving_live_label": "LIVE ON DEVNET, on-chain records (the PROVING LIVE lane, read 11:50:22Z 9 October 2026): on chain id 4465 (the Igneum 2.0 devnet) 663 paid v1 segment records (8 shards plus aggregation each) = 653.93 IGN paid from the proving pool to 30 distinct prover keys across chain blocks 3,000 to 30,687 (tip 31,719 at the read), plus 6,474 paid v0 shard records = 7,196 IGN; read by igneum_getProvingStatus, igneum_getSegmentRecords (3,965 segments) and igneum_getSegment; evidence build-1:/srv/artefacts/proving-live/prov-ledger-4465-tip31719-115022Z.json sha256 bd3f53c18c3ffa20354ea62ade9fd5e7a5794202a71c3c369a5be71484f7143a. NOT YET LIVE IN A SHIPPED PACKAGE: the payees are the fleet's prover stack (the same binaries the 2.0.3.1 HiveOS package wraps, cut 18:00 UK); the apps' prover is on by default on 24 GB cards but no app-proved record is on the record yet; a full-epoch line follows by 16:00 UK"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
# X0: the latest model reconciled with the exact frozen software (the 1.5x programme, 9 October 2026, 10:0x UK)
|
||||
|
||||
The adversary lane with the hash lane's pins (its line of 09:58 UK, every closing report staged with its sha256 at
|
||||
build-1:/srv/artefacts/tas/x0-pins/<job id>.report.txt, SHA256SUMS.txt beside them; the recipe every X lane reads,
|
||||
build-1:/srv/artefacts/1p5x/gpu-row-recipe.md sha cbab453c). The brief: the founder's
|
||||
igneum-1p5x-experiment-brief.md (sha256 74ee073286d4a6e2…, landing under docs/plans/igneum-2.0-master/1p5x/). This
|
||||
file reconciles every served and placed chip figure with the software and the measurements it rests on, names the
|
||||
assumptions that differ between the served 1.9x to 2.1x hybrid line and the placed 2.4x line, and states the r0 the
|
||||
programme uses with its range. Nothing here is a new measurement; no PASS is claimed.
|
||||
|
||||
## 1. The frozen control, as pinned
|
||||
|
||||
| Pin | Value | Read back | Label |
|
||||
|---|---|---|---|
|
||||
| The generator tree | class-v6 at 1a938abe4 (on branch class-v6, tip 755c2dbcf), generator 6, ProgramClass::V6, class string mx8-erad810f22d+sh256x27+state+reg64c+fold+rw, layer 8 ON, class_v6_family_flags 0xf, CLASS_V6_FLAG_NOWIN (1<<4) defined and off | the fingerprint recipe run on build-1 at 09:34 UK: 5f4d6dc6199294db89042171004e6420c1e5791e716d4b39b73d375818c10b6f, equal to the brief's | read back |
|
||||
| The dataset policy | 70a6c703 (4 GiB at genesis, no later step) | F0's dataset row | pinned by F0 |
|
||||
| The signing id | 2a1d6caab4c24564 at (epoch seed af89be5d, era edc4fa84, day 20730, state abb58003) | F0's freeze-candidate row | pinned by F0 |
|
||||
| The research pairing (never the freeze) | hl-v6-all, id 0x4de7b836cc40a4ea (the genesis seed over node1's state) and the l8off kit's export 0x9d40978601a7df2a (era edc4fa84, day 20730, the node1 state) | the hash lane's line | pinned |
|
||||
|
||||
## 2. The GPU side: every figure, its software, its boundary, its state
|
||||
|
||||
Every figure is the three-card Windows rig's RTX 5090 (or 5080) alone through the runner's cards-off; power is
|
||||
nvidia-smi board power.draw sampled at 1 Hz, the row's watts the sampler's mean from 8 s in to the end of the timed
|
||||
dispatches, idle NOT subtracted (the microbench rows are the one exception, residency-subtracted); no wall meter
|
||||
anywhere; fingerprints equal at every state. No GPU figure carries layer-8-off or a result compaction.
|
||||
|
||||
| Figure (as served) | Job and report sha256 | Software by sha | Pack | State | Where it is used |
|
||||
|---|---|---|---|---|---|
|
||||
| The 5090 at its 1,300 MHz lock: 134.764 MH/s at 312.5 W, 2.319 microjoules per hash (served as 2.33) | run-ca3-pc1-v4-eff-5090-20261007-b (7 Oct 18:40 to 19:16Z), report ead72106… | the installed app's igneum-worker-cuda.exe d7a413c7… (0.3.20); kit igneum-ca3-v4-sub3-pc1-kit.zip 1d441f5a… (program.json a746c924…, program.h 18bca6be…, kernel.cu 80e92839…) | v4-devnet-epoch0, the Devnet 3 epoch 0 class v4 program, fingerprint e370fb2080b7dbb1; 491 batches of 2^24 | the knee | the numerator of every served chip ratio (the "served convention") |
|
||||
| The class v5 control at the lock: v5-genesis 125.932 MH/s at 299.8 W against v4-genesis 125.924 at 294.0 (+2.0 percent watts) | run-ca3-pc1-v5lock-5090-20261008-b, report 0f444306… | the class v5 kit's worker | v5-genesis, v4-genesis | the knee | denominator.md's +2 percent (the 2.37 row) |
|
||||
| The class v6 control on the frozen tree at the lock: hl-v6-all 70.306 MH/s at 282.0 W (4.011 microjoules); stock 70.526 at 412.0 (5.842) | run-ca3-pc1-l8off-5090-20261008 (9 Oct 02:52 to 03:00Z), report 05f00bd8… | the l8off kit igneum-ca3-l8off-kit-20261008.zip c20999d1…, CUDA worker 218ca414…; fingerprint 59e6708e46f1e87c; 250 batches | hl-v6-all 0x9d40978601a7df2a (the frozen class at the research pairing) | both | NOT yet in any served chip ratio (section 4) |
|
||||
| The 5080 at its 1,100 lock: 71.201 MH/s at 146.6 W, 2.059 microjoules | run-ca3-pc1-v4-eff-5080-20261007-d (8 Oct 00:45 to 01:54Z), report 6acec71d… | the same worker d7a413c7…, the same pack | v4-devnet-epoch0, e370fb2080b7dbb1 | the knee | the 5080 column |
|
||||
| The cohort rows (14 rented card classes, stock only) | the fleet lane's rows.jsonl; denominator.md sha a4ec182a… on master | per row | per row | stock | the cohort column (count-weighted about 3.6 microjoules, approximate) |
|
||||
| Layer 8 off (register row 18, CLOSED 04:49 UK): hl-v6-all against hl-v6-all-nowin 0xec0c3757f8caef4a: stock 70.526 / 412.0 W against 70.209 / 417.2 (rate -0.4, joules +1.7 percent); lock 70.306 / 282.0 against 66.303 / 275.5 (rate -5.7, joules +3.6); the v5 pair the same way, +5.7 percent at the lock | the same job, report 05f00bd8…; the row document docs/analysis/class-v6/rows/pc1-l8off-ds2g-20261009.md sha fb2d5686… (master ac2f143c) | the l8off kit | the four packs | both | the D2 row: performed and closed, the layer stays ON |
|
||||
| The microbench per-op rows (int_arx 11.3 pJ stock, 6.2 at the lock; the class v4 draw read as 10.3 pJ per op at the lock from the packs job's 10.8 / 6.4) | run-ca4-pc1-microbench-5090-20261008-c (8 Oct 05:15 to 05:56Z), report 62dbb138…; the 10.3 is counter-asic-4-research.md's reading (sha 82af4424…) | igneum-worker-cuda-ca4mb.exe 49aa60c4… | 20 probes x 60 s at full residency | both | the denominator of every per-family k |
|
||||
|
||||
A caveat the hash lane names: since 14:2x UK on 8 October the rig's 5090 reads about 13 percent under the morning
|
||||
(the enclosure swap), so pairs inside a job are the figures and a job's absolutes are not compared with 7 October's.
|
||||
The served 2.33 microjoules is a 7 October absolute; the frozen-tree control is a 9 October job. Section 4 carries
|
||||
what that does to r0.
|
||||
|
||||
## 3. The chip side: every figure, its software, its boundary, its node
|
||||
|
||||
| Figure | Software by sha | What it is | Boundary | Node scaling |
|
||||
|---|---|---|---|---|
|
||||
| The placed 8-lane 18-family core: 9.36 pJ per lane-op on the class v4 draw at ASAP7 (8.6 to 11.1), 6.55 at N5, 4.72 at N3 | class-v6-adversary 43c9f7cd8: tools/chip-model/mf (RTL core_mf.v, the flow, results/table.csv), docs/analysis/class-v6/multi-family-adversary.md (landed e3d0208d) | ORFS on ASAP7 (Yosys 0.68, OpenROAD, the orfs docker image), placed and routed with SPEF, OpenSTA power under a gate-level VCD of a random-input simulation; the register window a FakeRAM2.0 64x256 macro per 8 lanes whose dynamic energy is MODELLED (3.5 pJ per access, 2.0 to 7.0) | the core only: no memory controller, no PHY, no host; the machine terms are board.py's (section 5) | ASAP7 to N5 x0.70, to N3 x0.50 (TSMC's claimed per-node reductions at the same speed) |
|
||||
| The placed 32-lane 18-family core: 9.54 pJ (8.9 to 11.0), 6.68 at N5 | the same; parasitics from the global route, calibrated within 1 percent of SPEF on the 32-lane genesis core; the detailed route did not converge (the fault, multi-family-adversary.md section 14) | | | the same |
|
||||
| The program the chip rows execute | the testbench's draw with the class v4 WEIGHTS (add 12, xor 10, mul 8, mad 8, shfl 8, rotl 7, sub 6, mulhi 6, rotr 6, or 4), NOT the frozen tree's drawn program: the chip rows are per-op energies by family under the draw's weights; the frozen program's op counts (102,100 shadow ops, 512 base, 128 loads, W = 1) set the per-hash sum | | | |
|
||||
| The memory system | chip-model-v3.md 5.3 and 5.5 (the GDDR7 board: 2.0 nJ per random read, 1.5 to 2.6; 21.3 G activates per second, the 5090's 82 percent as the sustained fraction; 20 W static, 15 W controller; USD 320 of devices), the N2 SRAM die (floor lane 3, 0.25 nJ per read at W = 1) | modelled on claimed device figures | the devices, controller and PHY | the memory does not scale with the core's node |
|
||||
| The hybrid's hit rate | adv-cache-2's window-layer distribution on Devnet 3's programs (the hottest half of the items serves 0.7188 of reads: the p98 program), the hash lane's census reconcile (the mean over programs 0.581, coexistence-model.md line 537), census-packs.md's flat-control ratio 1.1 to 1.7x (sha 146b578c…) | measured on programs of the class v5 and v6 crates, the window model as the null | | |
|
||||
| The complete machine | tools/chip-model/mf/flow/board.py: PSU 92 percent, VRM 90 percent, cooling 3 percent, one full node per 100 machines (85 W, USD 1,500), board and assembly USD 200, the core die sized to the memory's sustained rate | approximate allowances | the wall of the machine, less the host's share | |
|
||||
|
||||
## 4. The reconciliation, line by line
|
||||
|
||||
### 4.1 The served 1.9x to 2.1x hybrid against the placed 2.4x hybrid
|
||||
|
||||
Both are the stored-half hybrid (1 GiB of SRAM beside the 16 GDDR7 devices) against the 5090 at its lock, same
|
||||
node. They differ in four assumptions, each named:
|
||||
|
||||
| Assumption | The served line (10.0r, "about 2.4x") | This lane's placed line (1.9x to 2.1x) | Which the programme uses |
|
||||
|---|---|---|---|
|
||||
| The hit rate of the hottest half | 0.72 (adv-cache-2's p98 program) | 0.581 (the hash lane's reconciled mean over programs) for the 1.93x; 0.72 for the 2.09x | the mean, 0.581: the p98 is the tail, carried beside it |
|
||||
| The core's energy | the synthesised 8-lane core (4.61 pJ at N5) scaled by the first placed genesis core's factor | the placed 8-lane 18-family core itself, 6.55 pJ at N5 | the placed row |
|
||||
| The machine terms | the board identity (2.33 over 0.466 + the shadow) with the hybrid's memory term, no power train | the complete machine: PSU, VRM, cooling, the host share, the sustained derate | the complete machine (the review's evidence standard) |
|
||||
| The node | N5 node-for-node | N5 node-for-node | the same |
|
||||
|
||||
So the 2.4x is the synthesised core at the tail hit rate on the board identity, and the 1.9x to 2.1x is the placed
|
||||
core at the mean and the tail on the complete machine; the two agree once the three assumptions are aligned
|
||||
(section 13 of multi-family-adversary.md: 1.93x at 0.581 placed, 2.09x at 0.72 placed; 2.69x at 0.72 synthesised
|
||||
on the board identity, the 10.0q figure). The programme's hybrid r0 is the placed, complete-machine, mean-hit
|
||||
figure.
|
||||
|
||||
### 4.2 The window-conditioned 72 percent against the flat control, as the brief states it
|
||||
|
||||
The brief reads: the hottest half serves 72 percent because the window model concentrates traffic, and a flat
|
||||
distribution would give 50 percent. The record's reading (census-packs.md section 3): the 72 percent is the p98
|
||||
program on the window-conditioned null, the mean over programs is 0.581, and a flat control sits at 1.1 to 1.7x of
|
||||
the window model's top-0.1-percent share (printed beside the census, not used). The layer-8-off experiment is
|
||||
PERFORMED and CLOSED (register row 18): with the windows removed the hit of the hottest half is exactly 0.50 at
|
||||
every program (multi-family-adversary.md section 13 (5)), and the honest side at the knee loses 5.7 percent of rate
|
||||
and 3.6 to 5.7 percent of joules, so the layer stays ON. The chip-side arithmetic of the brief's conditional cache
|
||||
calculation holds (misses 0.50 against 0.419 at the mean, +19 percent of DRAM reads; against 0.28 at the tail, +79
|
||||
percent) and the placed rows say what it buys: the hybrid at hit 0.50 reads 1.85x against 1.93x at the mean and
|
||||
2.09x at the tail, 4 percent at the mean, 11 at the tail; the honest side pays more than that at the knee. X1 is the
|
||||
new-consensus-version form of the same question (a class v7 shape without the fixed windows, everything else
|
||||
frozen) and its traces replace this arithmetic on the hybrid rows when they land.
|
||||
|
||||
### 4.3 Layer-8-off and compaction in any figure
|
||||
|
||||
Layer 8 off is in NO served or placed figure (every GPU row and every chip row above carries the windows ON; the
|
||||
hit-0.50 row is a labelled variant). Result compaction (X2) is in NO figure on either side.
|
||||
|
||||
### 4.4 The wall-power boundary, both sides
|
||||
|
||||
The GPU side's boundary is the card's board power (nvidia-smi, 1 Hz, idle not subtracted), the card alone through
|
||||
cards-off: it excludes the host, the PSU's loss and the fans' share beyond the card's own. The chip side's boundary
|
||||
is the complete machine (the core, the memory, the controller and PHY, PSU and VRM losses, cooling, the host's
|
||||
share). The two are not the same boundary: the GPU's host, PSU loss and case fans are NOT in its 2.33, while the
|
||||
chip carries about 21 percent of power-train loss, 3 percent of cooling and 0.85 W of host. The reconciliation the
|
||||
programme should carry: either add the GPU's own machine terms (a mining rig's PSU at 92 percent and its host share,
|
||||
about +10 percent on the card's figure, approximate) or read the chip at the board identity; the first is the honest
|
||||
one and moves every served ratio up by about 0.1x. It is NOT applied in this file's r0 (the served convention is
|
||||
kept so every row stays comparable); it is the first of the three assumptions below.
|
||||
|
||||
### 4.5 The node scaling applied
|
||||
|
||||
Every chip row is stated at ASAP7 and scaled by claimed factors: N5 (the 5090's class, "same node") x0.70, N3 ("a
|
||||
node ahead") x0.50, N2 x0.36. The served sentence's "2.0x on the GPU's own node" is the N5 column; "2.2x to 2.4x"
|
||||
the N3. The memory terms do not scale. The GPU side is measured at its own node and never scaled.
|
||||
|
||||
## 5. The r0 the programme uses
|
||||
|
||||
| Opponent (complete machine, placed core, same node N5) | r0 | Range | Source rows |
|
||||
|---|---|---|---|
|
||||
| The pure DRAM board | 1.5x | 1.3x to 1.7x (the SRAM access band and the memory band) | multi-family-adversary.md section 6 at the placed energy; the 32-lane core the same |
|
||||
| The stored-half hybrid at the mean hit 0.581 | 1.93x | 1.65x to 2.12x | section 13 (placed) |
|
||||
| The stored-half hybrid at the tail hit 0.72 (the 10.0q case) | 2.09x | 1.78x to 2.29x | section 13 (placed) |
|
||||
| The N2 SRAM die | 2.3x | 2.0x to 2.7x | section 6 at the placed energy |
|
||||
|
||||
**The reconciled r0 the programme uses is the worst credible opponent's: the pure DRAM board at 1.5x (1.3x to
|
||||
1.7x) same-node; the hybrid at 1.93x (1.65x to 2.12x) is the X5 sweep's starting row and X1's target.** The
|
||||
yardstick from 1.5x to 1.5x is nothing; from the hybrid's 1.93x, a 22 percent GPU cut or a 29 percent specialist
|
||||
rise. A node ahead: the board 1.8x (1.5x to 2.0x), the hybrid 2.4x (2.05x to 2.63x).
|
||||
|
||||
The three assumptions most likely to move r0:
|
||||
1. The boundary mismatch (4.4): putting the GPU's own PSU loss and host share into its figure moves every ratio up
|
||||
about 0.1x (the board to about 1.6x, the hybrid to about 2.1x), approximate.
|
||||
2. The frozen-tree control against the class v4 numerator: the served 2.33 is class v4 on a 7 October job; the
|
||||
frozen class v6 object reads 4.011 microjoules at the lock on the l8off kit's export at the research pairing (a
|
||||
9 October job on a card reading 13 percent low). The chip's shadow at class v6's op count is the same core on
|
||||
102,612 ops; if the honest card's class v6 energy per hash is 4.0 against the chip's 1.53, the board ratio reads
|
||||
2.6x and the hybrid 3.3x, which is the number the programme must reconcile first: either the class v6 object
|
||||
costs the 5090 1.7x what class v4 did (then the chip's shadow op count for class v6 must be re-read from the
|
||||
frozen program, not from class v4's 102,100) or the l8off job's absolute is the enclosure-swap artefact. The
|
||||
hash lane's paired control (class v4 and class v6 in one session on one card, same kit) decides it; until it
|
||||
lands r0 stays on the class v4 pair.
|
||||
3. The SRAM macro's access energy (the one modelled chip term; 2.0 to 7.0 pJ per access gives the band's width) and
|
||||
the hybrid's hit rate (0.50 with the windows off, 0.581 mean, 0.72 tail).
|
||||
|
||||
## 5a. The decider in (the hash lane, 09:59 UK): assumption 2 is real, and what it does to r0
|
||||
|
||||
One session on a rented RTX 5090 (Vast 54992886, driver 580.105.08, the gen-6 Linux CUDA worker sha 804a6f7f…, 250 batches of
|
||||
2^24 per pack, board power at 1 Hz, mean from 8 s in, idle not subtracted, stock only; evidence
|
||||
build-1:/srv/artefacts/tas/x0-pins/x0-run-fk-5090c/, run-x0.log sha 9bc40685…, smi.csv 7ab9f16f…): class v4 v4-devnet-epoch0
|
||||
(0xa785001687d8688a, fingerprint e370fb2080b7dbb1) 141.963 MH/s at 500.0 W = 3,522 nJ per hash; the frozen class v6 hl-v6-all
|
||||
(0x9d40978601a7df2a, 59e6708e46f1e87c) 70.900 at 445.1 W = 6,278 nJ; the signing object hl-v6-all-cs (0x2a1d6caab4c24564,
|
||||
01f51b9d4805e5e6) 70.650 at 457.1 W = 6,470 nJ. Class v6 over class v4: 1.78x and 1.84x in joules per hash, 2.00x in rate; the
|
||||
house 5090's own pair 1.68x at stock and 1.73x at the 1,300 MHz knee (2,319 against 4,011 nJ). So the served numerator (2.33
|
||||
microjoules, class v4) understates the frozen object's card cost by about 1.7x, and the only class v6 lock row is 4.01
|
||||
microjoules per hash on the house 5090.
|
||||
|
||||
What it does to r0: the chip must be paired with the card on the same program. X4's read of the exported texts (10:0x UK)
|
||||
gives the frozen object 256 loads per hash against class v4's 128, so the card's doubled cost per hash is doubled work, and
|
||||
the chip's memory term doubles with it while its shadow (carried at class v4's 102,612 ALU ops until the hash lane's count lands)
|
||||
does not. On the consistent class v6 pair (the card 4.011 microjoules at the knee, the chip at 256 loads) every ratio moves UP,
|
||||
not down: X5's sweep (section 2, pair B) reads the DRAM board 1.85x (1.5x to 2.1x), the stored-half hybrid 2.53x, the N2 die 4.0x
|
||||
(3.4x to 4.4x) same-node. The earlier worry (a 1.7x structural card cost the chip does not share) is not what the data say: the
|
||||
cost is loads, which the chip pays too. The programme's r0 is therefore pair B's, with pair A (the served class v4 convention:
|
||||
the board 1.5x, the hybrid 1.93x, the die 2.44x) kept as the served convention's row until the ALU count lands:
|
||||
|
||||
| Opponent, same node | r0, pair A (class v4: the card 2.319, 128 loads) | r0, pair B (the frozen object: the card 4.011, 256 loads, the shadow at class v4's count) | Source |
|
||||
|---|---|---|---|
|
||||
| The pure DRAM board | 1.5x (1.3 to 1.7) | 1.85x (1.5 to 2.1) | X5's sweep on the placed core |
|
||||
| The stored-half hybrid, mean hit | 1.93x (1.65 to 2.12) | 2.53x | the same |
|
||||
| The N2 SRAM die (the worst credible design) | 2.44x (2.05 to 2.66) | 4.0x (3.4 to 4.4) | the same |
|
||||
|
||||
The hash lane's per-family count (10:11 UK): the frozen object executes 62,120 ALU instructions per hash (loads 256, the base
|
||||
1,024, the per-load address path 5,376, the shadow 55,296 unchanged, the reg64 init and fold 168); class v4 about 56,300; the card
|
||||
halves its rate on the read count, which the chip pays too. That settles assumption 2 and opens the largest term of all: every
|
||||
placed chip row charges the chip 102,612 ops per hash (the record's counted-op convention, from which the card's 0.652
|
||||
microjoules reads 6.4 pJ per counted op), but the chip executes one instruction per lane-op, so at the instruction count its
|
||||
shadow energy is 0.36 to 0.41 microjoules instead of 0.67 and every ratio rises by 1.3x to 1.6x (X5 section 2: the board 2.05x
|
||||
and 2.2x, the die 4.3x and 6.3x same-node on the two pairs). Both counts are carried until the hash lane defines the counted op
|
||||
(asked 10:1x UK, default 10:45); the instruction count is the chip's physics and the default.
|
||||
|
||||
## 5b. The amendment (the coordinator's order, 10:1x UK; every row WITNESS E_D): the frozen object's own numerator, the instruction count, the measured hits
|
||||
|
||||
Two facts change every chip ratio. (1) The decider (5a): the frozen class v6 object does twice the random dataset reads per hash of
|
||||
class v4 (256 against 128) with 9 to 10 percent more ALU (62,120 instructions against about 56,300; the hash lane's per-family
|
||||
read of the signing object, 10:11 UK); the card pays 1.78x to 1.84x the joules per hash at stock on the rented 5090 and 1.68x at
|
||||
stock / 1.73x at the knee on the house 5090. (2) The units fault (the hash lane, 10:13 UK, from the record's own units table,
|
||||
attack-pass/f1-shadow.md section 1 and shadow-k.md section 1): the record's "counted op" is unit B, the GPU's micro-op tally (add
|
||||
5, rotr 2, shfl 2, every other family 1: 1.83 per shadow instruction), so the 102,100 counted ops per class v4 hash are 55,296
|
||||
instructions; the chip executes one instruction per lane-op and its k was derived per instruction (algorithm.md 5.3), so every
|
||||
placed row that multiplied 102,612 counted ops by a per-instruction chip energy (chip-model-v3's re-reads, the design document's
|
||||
10.0r, this lane's sections 4, 6, 13 of multi-family-adversary.md and X5's first tables) overstated the chip's shadow term by
|
||||
1.85x. The record's own gate unit is the instruction (unit A); the chip datapath count (unit C, rotates as wiring, the constants
|
||||
hoisted) is lower still, about 50,100 per class v4 hash.
|
||||
|
||||
Recomputed on the placed 8-lane 18-family core's per-family energies (ASAP7 placed with SPEF, N5 x0.70, N3 x0.50, claimed)
|
||||
weighted by the frozen object's own per-family instruction counts: the chip's ALU energy per hash 0.312 microjoules at N5 (0.280
|
||||
to 0.388; 0.231 at unit C with the rotates as wiring), 0.225 at N3; 5.05 pJ per instruction at N5 (3.64 at N3). The GPU numerator
|
||||
is the frozen object's own measured joules per hash: the house 5090 at the 1,300 MHz knee 4.011 microjoules (run-ca3-pc1-l8off-5090-
|
||||
20261008, hl-v6-all, the only class v6 lock row; the card alone, nvidia-smi board power at 1 Hz, idle not subtracted); at stock
|
||||
6.470 (the signing object on the rented 5090, x0-run-fk-5090c). The board-power boundary excludes the rig's PSU loss and host,
|
||||
which the chip's machine carries; the wall-meter column adds 10 percent to the card's figure for them (approximate, unmeasured).
|
||||
The hybrid's hit rate is the measured one for the hottest half: 0.56 on the signing object, 0.61 the mean over programs (the
|
||||
quarter and three quarters scaled by the same ratio); 0.72 is the p98 tail and is not used. 256 loads per hash on both sides.
|
||||
Nothing here is a chip measurement; every row is a modelled witness; no pass and no fail is claimed on these lines.
|
||||
|
||||
| Opponent (WITNESS E_D, complete machine) | microjoules per hash, same node (band) | USD per MH/s | Knee, same node, hit 0.56 (signing) | Knee, same node, hit 0.61 (mean) | Knee + the wall-meter term (+10 percent on the card) | Stock, same node | Knee, a node ahead | Stock, a node ahead |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| The pure DRAM board | 1.707 (1.516 to 2.112) | 8.91 | 2.35x (1.90 to 2.65) | 2.35x (1.90 to 2.65) | 2.58x (2.09 to 2.91) | 3.79x (3.06 to 4.27) | 2.51x (2.02 to 2.83) | 4.05x (3.26 to 4.57) |
|
||||
| The hybrid, the hottest quarter | 1.362 (1.222 to 1.679) | 7.02 | 2.86x (2.32 to 3.19) | 2.94x (2.39 to 3.28) | 3.24x (2.63 to 3.61) | 4.75x (3.85 to 5.29) | 3.20x (2.59 to 3.58) | 5.16x (4.18 to 5.77) |
|
||||
| The hybrid, the hottest half | 1.077 (0.973 to 1.322) | 5.11 | 3.49x (2.84 to 3.86) | 3.73x (3.03 to 4.12) | 4.10x (3.34 to 4.54) | 6.01x (4.89 to 6.65) | 4.15x (3.36 to 4.60) | 6.69x (5.43 to 7.42) |
|
||||
| The hybrid, the hottest three quarters | 0.894 (0.811 to 1.094) | 3.77 | 4.03x (3.29 to 4.45) | 4.49x (3.67 to 4.95) | 4.94x (4.03 to 5.44) | 7.24x (5.91 to 7.98) | 5.11x (4.16 to 5.64) | 8.25x (6.71 to 9.11) |
|
||||
| The full SRAM die (one N2 reticle) at 1 kW | 0.513 (0.464 to 0.622) | 0.68 | 7.81x (6.45 to 8.65) | 7.81x (6.45 to 8.65) | 8.60x (7.10 to 9.51) | 12.60x (10.41 to 13.95) | 10.01x (8.22 to 11.13) | 16.15x (13.26 to 17.95) |
|
||||
| Recomputation | 7.000 (6.459 to 8.238) | 7.74 | 0.51x (0.44 to 0.55) | 0.57x (0.49 to 0.62) | 0.63x (0.54 to 0.68) | 0.92x (0.79 to 1.00) | 0.77x (0.66 to 0.84) | 1.25x (1.06 to 1.35) |
|
||||
|
||||
The one sentence: at the frozen object's own numerator and the instruction count, the pure DRAM board as a complete machine sits
|
||||
ABOVE 1.5x by 0.85x (2.35x same-node at the knee, band 1.90x to 2.65x; 2.58x with the wall-meter term; 3.79x at stock; 2.51x a node
|
||||
ahead), the stored-half hybrid at the measured hit 3.5x to 3.7x, and the N2 SRAM die, the worst credible design and the
|
||||
programme's a (0.513 microjoules per hash, 0.464 to 0.622), 7.8x; the earlier 1.5x read was two errors in the honest side's
|
||||
favour (class v4's numerator against a class v6 chip, and the GPU's micro-op tally charged to the chip), not the chip's cost.
|
||||
|
||||
What this means for the yardstick: r_new = r0 x g / a from the board's 2.35x needs a 36 percent GPU cut at a = 1 (a 15 plus 20
|
||||
split reaches 1.66x); from the die's 7.8x no candidate within the brief's arithmetic reaches 1.5x on energy, which puts the
|
||||
die's hold where the coexistence model put it (its dollars and its project), and the per-joule target at the board. X5's rows
|
||||
and X6's blind run follow this line; the served energy sentence should not be re-worded from this file (the panel reads it).
|
||||
|
||||
## 6. BLOCKED rows
|
||||
|
||||
| Row | Why | Owner |
|
||||
|---|---|---|
|
||||
| The class v6 paired control at the lock (the rented pod refuses the clock lock; the stock pair is in, section 5a) | stock done 09:59 UK; the knee pair is the house 5090's 1.73x | the hash lane |
|
||||
| The definition of the record's "counted op" | CLOSED 10:13 UK: unit B, the GPU's micro-op tally (1.83 per instruction); the chip's unit is the instruction (5b) | the hash lane |
|
||||
| The chip's op count on the frozen program (the shadow's 55,296 writes in r0..r7, the base program's 512, the loads' 128 at W = 1: read from 1a938abe4's generator, not from class v4) | the chip rows use the class v4 draw's weights; the frozen program's per-family counts are owed | this lane, with the hash lane |
|
||||
| The GPU's own machine terms (PSU, host) | not measured; approximate +10 percent | the hash lane (a wall meter on the rig) |
|
||||
|
||||
## 7. Sources
|
||||
|
||||
The hash lane's pin line (09:58 UK) and build-1:/srv/artefacts/tas/x0-pins/; the gpu-row-recipe.md (sha cbab453c…);
|
||||
docs/analysis/class-v6/multi-family-adversary.md (master e3d0208d); docs/analysis/class-v6/coexistence-model.md (line
|
||||
537); docs/analysis/class-v6/census-packs.md (sha 146b578c…); docs/design/class-v6-rotating-family.md 10.0h, 10.0i,
|
||||
10.0q, 10.0r (sha 6eaf43bd…); docs/analysis/counter-asic-4-research.md 15.1a and 20.3 (sha 82af4424…);
|
||||
docs/analysis/class-v6/floor/denominator.md (sha a4ec182a…); the register rows 18 and 105 to 126.
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
# X1: fixed-window removal as a versioned candidate (the class v7 shape), 9 October 2026
|
||||
|
||||
The 1.5x programme's X1 (the founder's brief, `docs/plans/igneum-2.0-master/1p5x/igneum-1p5x-experiment-brief.md`; the
|
||||
matrix in `docs/plans/igneum-2.0-master/1p5x/README.md`). This document lands rows, tools and the candidate's identity.
|
||||
Nothing activates: the frozen object keeps layer 8 on (`class_v6_family_flags` 0xf), the candidate is a research class
|
||||
behind its own flag, and no row here is a PASS. Every figure below is printed by `tools/1p5x/x1/summarise.py` from the
|
||||
row files under `rows/` (the command at the end). UTC in the rows, UK time in the prose.
|
||||
|
||||
## 1. The candidate and its identity
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Frozen control | class-v6 tree `1a938abe4`, generator 6, class `mx8-erad810f22d+sh256x27+state+reg64c+fold+rw`, igneum-pow/src fingerprint `5f4d6dc6199294db89042171004e6420c1e5791e716d4b39b73d375818c10b6f` (read back at the candidate's merge-base by this lane and by the hash lane, 09:49 UK) |
|
||||
| Candidate | branch `1p5x-x1-v7` on the box mirror, sha `a99de07cded99a9daa0e5bbd1a6192783322347e` (parent `e4b6bb633`, parent `1a938abe4`) |
|
||||
| Candidate fingerprint | igneum-pow/src `b9b5d0d1448bc9ce4003b119c66c5734c554c4a84e95b008476a02fb69ae2b54` (the recipe `cd igneum-pow && find src -type f \| LC_ALL=C sort \| xargs shasum -a 256 \| shasum -a 256`; pin confirmed by the hash lane) |
|
||||
| Candidate class string | `mx8-erad810f22d+sh256x27+state+reg64c+fold+rw+flat` (on the signing pair) |
|
||||
| The one flag | `LoadClass::flat`, class suffix `+flat` (outermost), program-id literal `v7flat/`; NOT `+nowin` (layer 8 off, register row 18, closed) |
|
||||
| What it changes | the window layer does not exist: no window draw is made, every load site has `win = 0, off = 0` and addresses the whole live dataset as `stride(e, x) & mask` (the frozen class's own product, fold and rotation); the family set, the fold, rw1, the state leaves, reg64c and the dataset policy `70a6c703` are the frozen class's |
|
||||
| The diff | `igneum-pow/src/generator.rs` +39 (the field, `with_flat`, the suffix, the id literal, the draw guard), `accept.rs` (the acceptance shape sets the flag aside like `nowin`), `main.rs` +31 (`--flat` on the export path), `tests/v7flat.rs` (3 tests) |
|
||||
| Pack shape | generator 6, `program_class` v6: the generator-6 readers (engine, CLI, CUDA, OpenCL, Metal gates) take it unchanged |
|
||||
| Signing pair (epoch seed `af89be5d...`, era `0:edc4fa84...`, day 20730, node1 state `abb58003...`) | control `2a1d6caab4c24564` (attempt 0), candidate `34d0b421b17fe803` (attempt 1) |
|
||||
| Packs | `build-1:/srv/artefacts/1p5x/x1/packs/x1-{ctrl,flat}-ds{28,30}.tgz` with sha256 files (ctrl-ds28 `69e009cd...`, ctrl-ds30 `7606c13d...`, flat-ds28 `020dbdc7...`, flat-ds30 `f25160bd...`); the kit lane's zip `packs-x1-20261009T085304Z.zip` (`88f5abf4...`) carries the same four byte for byte |
|
||||
|
||||
The export recipe (any epoch seed, era seed and day): `igneum-pow export --out <dir> --epoch-hex <epoch seed> --day-hex <day bytes> --program-class v6 --era-hex <era seed> --state <state stream> --dataset-log2 <28|30> --flat` (omit `--flat` for the control). The control export reproduces the hash lane's `hl-v6-all-cs` pack byte for byte (`kernel.cu` blake2b `c3ee55b0...`).
|
||||
|
||||
**What this candidate is, said plainly.** At the program level the class v7 shape is the address form of the closed layer-8-off row (`+nowin` zeroes the windows after drawing them; `+flat` never draws them), so the two differ in the draw stream and the id, not in what a load site reads. The GPU side was therefore expected to repeat register row 18; the new evidence X1 owes is the cache side.
|
||||
|
||||
## 2. Native correctness and generation availability
|
||||
|
||||
| Row | Result | Evidence |
|
||||
|---|---|---|
|
||||
| Crate check | `cargo check --all-targets` clean on build-9 (rustc 1.99.0) | `rows/suite-a99de07cd.log` |
|
||||
| The pow suite | `cargo test --release --no-fail-fast` on build-9, EXIT 0, every binary green, `tests/v7flat.rs` 3 of 3 (the frozen class string and draw untouched; no window on any instruction of 64 accepted programs under 4 eras, every load `stride & mask` at 2^28 and 2^30; the frozen control keeps live windows) | `rows/suite-a99de07cd.log` |
|
||||
| A fault found on the frozen tree, not the candidate's | `tests/derive.rs` `stats_beside_x8` overflows under `cargo test` WITHOUT `--release` (a debug-profile multiply at line 191) on `1a938abe4` itself; the gate runs release, where it passes | `rows/suite-a99de07cd.log` (the release run); the debug run's log on build-9 `/srv/builds/x1/logs/suite-e4b6bb633.log` |
|
||||
| Same work, three contexts on the candidate's own pack | (1) the CLI export path (`--program-class v6 --flat`) draws `34d0b421b17fe803` attempt 1; (2) the class-string recipe (`--class ...+flat --era 0:...`, the census harness) draws the same id and attempt; (3) CUDA on the pack: the self-test PASS on all 32 runs (cache head, last line and FNV, dataset samples, 96 of 96 vector lanes against the CPU reference's vectors) and the 2^24 fingerprint equal on four cards of two architectures (`8cd11a827ac3ffe4` at 1 GiB, `18c4c6a80809c4b9` at 4 GiB); the kit lane's CPU emulation check on `x1-flat-ds28` PASS | `rows/cache/cache-flat-sign.txt`, `rows/cache/export-x1-flat-ds30.txt`, `rows/gpu/*/worker.log` |
|
||||
| Generation availability | 64 accepted draws (chain-shaped seeds, era `edc4fa84`, 2^30): control 136.0 and 138.3 s, candidate 135.9 and 136.2 s (two runs each, build-9, one thread); the attempts over 256 seeds: control mean 0.176 (215 at 0, 37 at 1, 4 at 2), candidate mean 0.160 (218, 35, 3), 0 exhausted both; the day pack (export at 4 GiB) 2.87 s control and 4.90 s candidate, the extra 2.0 s being the CLI drawing the frozen program first and then the candidate's (an export-path artefact, not the class: the draws above are equal) | `rows/cache/draw-timing.txt`, `rows/cache/model-*-256.txt`, `rows/cache/gen-timing.txt` |
|
||||
|
||||
## 3. (a) The census on the candidate (build-8, 09:49 to 09:56 UK)
|
||||
|
||||
The frozen live-dataset census jobs verbatim (`live-dataset-census-1a938abe4.md` section 3: the object on the genesis pairing over headers 0 to 7, the 16 chain-shaped seeds p18 to p33, the node1 state, 2^28 words) with the class `+flat`, through `addrsite` of the frozen census harness (`class-v6-census-all4` `b819c23a4`) with the candidate's two commits cherry-picked (branch `1p5x-x1-harness`, binary `bin-h-5264b8152` sha256 `9dc22b1d...`). The control seed p18 was re-run in the same session and reproduces the frozen row to the last digit, so the instrument is the frozen one.
|
||||
|
||||
| Run | Program | Reads | Top 0.1 percent share | Against flat | Against window model | Min site distinct ratio | Worst bit (sigma) | Top-K 1 GiB / 256 MiB / 64 MiB |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| ctrl-seed-p18 | 0b686c80e25cb4d3 | 67,108,864 | 0.003391 | 1.1032x | 1.0006x | 0.99991 | 3.09 | 1.0000 / 0.4404 / 0.1414 |
|
||||
| flat-object-h0 | caf198d6c4b4767b | 268,435,456 | 0.001942 | 1.0002x | 1.0000x | 1.00010 | 3.06 | 1.0000 / 0.3316 / 0.0951 |
|
||||
| flat-object-h4 | caf198d6c4b4767b | 268,435,456 | 0.001942 | 1.0003x | 1.0001x | 1.00006 | -3.11 | 1.0000 / 0.3316 / 0.0951 |
|
||||
| flat-seed-p18 | 0fc8727ab6190454 | 67,108,864 | 0.003075 | 1.0005x | 0.9993x | 0.99993 | 3.55 | 1.0000 / 0.4151 / 0.1306 |
|
||||
| flat-seed-p19 | 7c5149150271b038 | 67,108,864 | 0.003075 | 1.0004x | 1.0013x | 0.99990 | -4.10 | 1.0000 / 0.4151 / 0.1306 |
|
||||
| flat-seed-p20 | 8ecb2e06575657b8 | 67,108,864 | 0.003069 | 0.9983x | 0.9992x | 0.99996 | -3.09 | 1.0000 / 0.4151 / 0.1306 |
|
||||
| flat-seed-p21 | 8b36c28c52ba99c4 | 67,108,864 | 0.003072 | 0.9990x | 1.0008x | 0.99989 | 3.07 | 1.0000 / 0.4150 / 0.1305 |
|
||||
| flat-seed-p22 | 970fb4d69b2fe33c | 67,108,864 | 0.003080 | 1.0012x | 1.0036x | 0.99995 | 3.36 | 1.0000 / 0.4152 / 0.1306 |
|
||||
| flat-seed-p23 | a251a84ef66d3834 | 67,108,864 | 0.003073 | 0.9987x | 0.9990x | 0.99996 | -3.24 | 1.0000 / 0.4151 / 0.1306 |
|
||||
| flat-seed-p24 | 46acf991dd1407dd | 67,108,864 | 0.003074 | 1.0005x | 0.9999x | 0.99992 | 4.01 | 1.0000 / 0.4151 / 0.1306 |
|
||||
| flat-seed-p25 | b9b94de564ab230b | 67,108,864 | 0.003075 | 0.9996x | 1.0010x | 0.99992 | 3.03 | 1.0000 / 0.4151 / 0.1306 |
|
||||
| flat-seed-p26 | 134b855d549b2f7e | 67,108,864 | 0.003071 | 0.9995x | 1.0003x | 0.99991 | 3.73 | 1.0000 / 0.4151 / 0.1306 |
|
||||
| flat-seed-p27 | 2d87599f68893d21 | 67,108,864 | 0.003072 | 1.0003x | 0.9980x | 0.99995 | -3.68 | 1.0000 / 0.4150 / 0.1305 |
|
||||
| flat-seed-p28 | 2e3f0c9b69ebfbbe | 67,108,864 | 0.003071 | 1.0005x | 1.0002x | 0.99993 | 3.26 | 1.0000 / 0.4150 / 0.1305 |
|
||||
| flat-seed-p29 | 4668c5c8a3f7250e | 67,108,864 | 0.003072 | 0.9998x | 0.9982x | 0.99995 | 2.88 | 1.0000 / 0.4151 / 0.1305 |
|
||||
| flat-seed-p30 | a62cdf47fc0fe7f8 | 67,108,864 | 0.003072 | 0.9991x | 1.0004x | 0.99991 | -3.42 | 1.0000 / 0.4150 / 0.1305 |
|
||||
| flat-seed-p31 | bf0d33b7f1958a63 | 67,108,864 | 0.003070 | 1.0001x | 0.9998x | 0.99994 | 4.30 | 1.0000 / 0.4151 / 0.1306 |
|
||||
| flat-seed-p32 | 80b54bc0701d7269 | 67,108,864 | 0.003076 | 1.0015x | 1.0008x | 0.99990 | 3.59 | 1.0000 / 0.4151 / 0.1306 |
|
||||
| flat-seed-p33 | b025a053532694f0 | 67,108,864 | 0.003076 | 1.0018x | 1.0007x | 0.99995 | 3.68 | 1.0000 / 0.4150 / 0.1306 |
|
||||
|
||||
Reading: the candidate's live-dataset access distribution is flat to the Poisson floor on all 17 programs. The top 0.1 percent of items hold 0.998x to 1.002x the flat control's share (the frozen class: 1.03x to 1.28x flat on these seeds, 1.10x on the object, its windows by design); every site's distinct ratio sits at 0.99989 to 1.00010; no index bit reaches 4.4 sigma (band 6); the object touches every one of its 16,777,216 items. Nothing new concentrates when the windows go.
|
||||
|
||||
## 4. (b) The live memory trace and (c) the cache-hit curves (build-9, 08:45 to 09:01 UTC)
|
||||
|
||||
`x1cache` (branch `1p5x-x1-harness` `5264b8152`, `tools/1p5x/x1/cache.sh`): the interpreter's tracing probe on the chain's dataset at 4 GiB (2^30 words, 67,108,864 items of 64 bytes) with the node1 state, the header-bound init words as `addrsite` makes them; 2^22 nonces per program (the frozen trace's N), header 0 the TRAIN half and header 1 the HOLDOUT half (2^21 nonces, 536,870,912 reads each, 256 reads per nonce). The policies: the window-model static set (exact: the items of highest probability from the program's own load sites, a set a chip can compute at the era's start), the train-ranked static set (the brief's train and holdout policy), the in-sample oracle (an upper bound inflated by ranking noise at 8 reads per item), and exact LRU over the holdout stream in warp order from a cold start. `x1model` (`fc6bfaf25`): the exact window-model hot-set mass of 256 accepted chain-shaped seeds.
|
||||
|
||||
```
|
||||
# x1cache: program 2a1d6caab4c24564 class mx8-erad810f22d+sh256x27+state+reg64c+fold+rw attempt 0 generator v6 day bytes:69676e65756d2d6461792ffa50000000000000 dataset 2^30 words (memory-hard, 67108864 items of 64 B) headers 2 from 0 (train 1, holdout 1) warps/header 65536 nonces/header 2097152 threads 32
|
||||
# load sites (instr order): 0:0 1:0 2:1 0:0 1:1 1:0 2:3 0:0 1:0 0:0 2:2 0:0 1:1 0:0 1:0 1:0
|
||||
# trace: 536870912 train reads, 536870912 holdout reads (256 reads per nonce), 59.0 s
|
||||
# (1) window model, hot-set mass at stored fraction f (exact): 0.05:0.0625 0.10:0.1250 0.15:0.1875 0.20:0.2500 0.25:0.3125 0.30:0.3625 0.35:0.4125 0.40:0.4625 0.45:0.5125 0.50:0.5625 0.55:0.6062 0.60:0.6500 0.65:0.6938 0.70:0.7375 0.75:0.7812 0.80:0.8250 0.85:0.8687 0.90:0.9125 0.95:0.9563 1.00:1.0000
|
||||
# (1) window model: 3 distinct probability levels; max/min item probability 1.4286; max item probability x items 1.2500
|
||||
policy f=0.25 f=0.50 f=0.75
|
||||
window-model static 0.3125 0.5625 0.7813
|
||||
train-ranked static (holdout) 0.2717 0.5240 0.7662
|
||||
oracle static (in-sample) 0.3775 0.6519 0.8640
|
||||
LRU (holdout stream, cold start) 0.2506 0.4883 0.7064
|
||||
# (3) holdout counts per item: mean 8.000; quantiles max 29 p0.1% 19 p1% 16 p10% 12 p25% 10 p50% 8 p75% 6 p90% 4 min 0; untouched items 37303; 177.2 s
|
||||
```
|
||||
```
|
||||
# x1cache: program 34d0b421b17fe803 class mx8-erad810f22d+sh256x27+state+reg64c+fold+rw+flat attempt 1 generator v6 day bytes:69676e65756d2d6461792ffa50000000000000 dataset 2^30 words (memory-hard, 67108864 items of 64 B) headers 2 from 0 (train 1, holdout 1) warps/header 65536 nonces/header 2097152 threads 32
|
||||
# load sites (instr order): 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0
|
||||
# trace: 536870912 train reads, 536870912 holdout reads (256 reads per nonce), 49.5 s
|
||||
# (1) window model, hot-set mass at stored fraction f (exact): 0.05:0.0500 0.10:0.1000 0.15:0.1500 0.20:0.2000 0.25:0.2500 0.30:0.3000 0.35:0.3500 0.40:0.4000 0.45:0.4500 0.50:0.5000 0.55:0.5500 0.60:0.6000 0.65:0.6500 0.70:0.7000 0.75:0.7500 0.80:0.8000 0.85:0.8500 0.90:0.9000 0.95:0.9500 1.00:1.0000
|
||||
# (1) window model: 1 distinct probability levels; max/min item probability 1.0000; max item probability x items 1.0000
|
||||
policy f=0.25 f=0.50 f=0.75
|
||||
window-model static 0.2500 0.5000 0.7500
|
||||
train-ranked static (holdout) 0.2500 0.5000 0.7500
|
||||
oracle static (in-sample) 0.3657 0.6396 0.8563
|
||||
LRU (holdout stream, cold start) 0.2457 0.4808 0.6996
|
||||
# (3) holdout counts per item: mean 8.000; quantiles max 29 p0.1% 18 p1% 15 p10% 12 p25% 10 p50% 8 p75% 6 p90% 5 min 0; untouched items 22336; 171.0 s
|
||||
```
|
||||
|
||||
ctrl, 256 accepted chain-shaped seeds: attempts mean 0.176, max 2
|
||||
f=0.25: programs 256 min 0.2656 mean 0.3409 median 0.3438 p90 0.4062 max 0.5625
|
||||
f=0.50: programs 256 min 0.5312 mean 0.6110 median 0.5938 p90 0.6875 max 0.7812
|
||||
f=0.75: programs 256 min 0.7656 mean 0.8301 median 0.8281 p90 0.8750 max 0.9375
|
||||
|
||||
flat, 256 accepted chain-shaped seeds: attempts mean 0.160, max 2
|
||||
f=0.25: programs 256 min 0.2500 mean 0.2500 median 0.2500 p90 0.2500 max 0.2500
|
||||
f=0.50: programs 256 min 0.5000 mean 0.5000 median 0.5000 p90 0.5000 max 0.5000
|
||||
f=0.75: programs 256 min 0.7500 mean 0.7500 median 0.7500 p90 0.7500 max 0.7500
|
||||
|
||||
### The brief's conditional cache calculation, re-done with measured numbers
|
||||
|
||||
At the half store the miss fraction goes from the control's to the candidate's 0.5000:
|
||||
|
||||
| Control reading | Control hit at f = 0.5 | Misses | Candidate misses | Relative miss increase |
|
||||
|---|---|---|---|---|
|
||||
| the signing program, window-model static (exact) | 0.5625 | 0.4375 | 0.5000 | +14.3 percent |
|
||||
| the signing program, train-ranked static (measured, holdout) | 0.5240 | 0.4760 | 0.5000 | +5.0 percent |
|
||||
| the signing program, LRU (measured) | 0.4883 | 0.5117 | 0.5192 (LRU) | +1.5 percent |
|
||||
| 256 chain-shaped seeds, window model, mean | 0.6110 | 0.3890 | 0.5000 | +28.5 percent |
|
||||
| 256 seeds, p90 | 0.6875 | 0.3125 | 0.5000 | +60.0 percent |
|
||||
| 256 seeds, max | 0.7812 | 0.2188 | 0.5000 | +128.5 percent |
|
||||
| the brief's reported static figure | 0.72 | 0.28 | 0.50 | +78.6 percent |
|
||||
|
||||
What it means. Removing the windows does what the hypothesis says: every static and LRU policy sees the uniform stream and a half store serves exactly half. But the window concentration the hybrid row assumed (72 percent at the half store) is not the frozen object's: the signing program's windows put 56.25 percent of reads in the hottest half (3 probability levels, max to min 1.43), the chain-shaped seeds 61.1 percent on average (53 to 78 percent; 72 percent sits near the 95th percentile). On the signing object the candidate takes the hybrid's half-store misses up by 14.3 percent, not 78.6. This is a miss count, not an energy (the brief's own caveat): the energy is X5's.
|
||||
|
||||
## 5. The GPU rows (rented cards, stock; the four sessions 08:48 to 09:11 UTC)
|
||||
|
||||
The hash lane's recipe (`build-1:/srv/artefacts/1p5x/gpu-row-recipe.md`, `cbab453c...`): the generator-6 CUDA worker `igneum-worker-cuda-gen6` (sha256 `804a6f7fea10a62e07cf6cd01e99331de5149e683112b5870fc25d1e11e199bd`), `--bench --batches 250 --batch-log2 24 --block-warps 1 --device 0`, `nvidia-smi` at 1 Hz for the whole session, watts the mean `power.draw` from 8 s into the timed window (the worker's 250 x mean dispatch, ending at the run's end) to its end, board power as the card reports it (no wall meter, idle not subtracted); same-session ABBA pairs per dataset on one card (control, candidate, candidate, control at 1 GiB, then at 4 GiB, 10 s apart); the pack's self-test gates each row. `tools/1p5x/x1/gpu-pairs.sh` ran the sessions, `tools/1p5x/x1/gpu-rows.py` read them. The cards: two RTX 5090 (one host capped at 400 W against a 575 W default, so its rows are a power-capped state; the other at a 500 W limit, unthrottled at 2,932 MHz) and two RTX 4090 at their default 450 W; all rented through the fleet lane, every row on an island, not a network.
|
||||
|
||||
| Card (pod) | Dataset | Control MH/s | Candidate MH/s | Rate | Control W | Candidate W | Control nJ/hash | Candidate nJ/hash | g |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| g5090b | 1 GiB | 70.66 | 70.55 | -0.14% | 400.0 | 400.0 | 5662 | 5669 | 1.0014 |
|
||||
| g5090b | 4 GiB | 68.66 | 68.59 | -0.10% | 400.0 | 400.0 | 5826 | 5832 | 1.0010 |
|
||||
| g5090e | 1 GiB | 70.64 | 70.54 | -0.15% | 441.5 | 439.5 | 6249 | 6230 | 0.9970 |
|
||||
| g5090e | 4 GiB | 68.64 | 68.59 | -0.08% | 448.6 | 440.6 | 6534 | 6423 | 0.9830 |
|
||||
| g4090b | 1 GiB | 31.30 | 31.28 | -0.06% | 262.4 | 259.9 | 8382 | 8308 | 0.9911 |
|
||||
| g4090b | 4 GiB | 30.70 | 30.69 | -0.01% | 261.6 | 258.1 | 8521 | 8410 | 0.9869 |
|
||||
| g4090c | 1 GiB | 31.30 | 31.28 | -0.06% | 251.2 | 248.9 | 8025 | 7958 | 0.9916 |
|
||||
| g4090c | 4 GiB | 30.70 | 30.69 | -0.01% | 250.8 | 248.2 | 8171 | 8088 | 0.9898 |
|
||||
|
||||
The knee: NOT RUN. Rented containers refuse a clock lock (`nvidia-smi -lgc 0,1300`: "does not have permission to change clocks", read on all four cards) and the house rigs are BLOCKED today. The only knee figure for this address form is register row 18 (`+nowin` on the frozen class, the first rig's 5090 at the 1,300 MHz lock: rate -5.7 percent, joules +3.6 percent), which a knee row of this candidate is expected to repeat.
|
||||
|
||||
Reading: at stock the candidate costs no card anything (rate within -0.15 percent on all four, joules -1.7 to +0.14 percent; g 0.983 to 1.001, median 0.990). ABBA spread under 0.03 percent of rate on every card; the 5090 at 500 W drifted in watts within a session (431 to 452 W on the control at 1 GiB), which the ABBA mean carries.
|
||||
|
||||
## 6. The arithmetic (the brief's yardstick, r_new = r0 x g / a)
|
||||
|
||||
g, the GPU side, from the rows: 0.99 at stock (0.983 to 1.001); at the knee row 18's 1.036 is the best estimate until a knee row of this candidate exists. a, the specialist side, is X5's. What the cache rows bound: the window layer only ever reached the hybrid through its stored fraction; even if all of a hybrid's energy per hash were its DRAM misses, its energy rises at most by the miss ratio, a <= 0.5 / 0.4375 = 1.143 on the signing object, 1.285 at the 256-seed mean, 1.786 only at the 72 percent premise. With the hybrid placed at about 2.4x (the coordinator's common brief):
|
||||
|
||||
| Case | g | a (upper bound) | r_new from 2.4 |
|
||||
|---|---|---|---|
|
||||
| the signing object, stock | 0.99 | 1.143 | 2.08 |
|
||||
| the 256-seed mean, stock | 0.99 | 1.285 | 1.85 |
|
||||
| the 72 percent premise, stock | 0.99 | 1.786 | 1.33 |
|
||||
| the signing object, at the knee (row 18's g) | 1.036 | 1.143 | 2.18 |
|
||||
| the pure DRAM board (no store: the windows buy it nothing), 1.6x | 0.99 | 1.00 | 1.58 |
|
||||
| the full-SRAM die (stores everything: uniform does not hurt it), 2.4x | 0.99 | 1.00 | 2.38 |
|
||||
|
||||
So on the frozen object's own windows, fixed-window removal moves the hybrid at most from about 2.4x toward 2.1x and leaves the DRAM board and the SRAM die where they are; the 1.5x target is not reached by X1 alone on any measured row. These are bounds on the arithmetic, not a verdict: X5 re-optimises the specialist on the candidate and X6 runs blind.
|
||||
|
||||
## 7. BLOCKED and NOT RUN rows
|
||||
|
||||
| Row | State | Why, and what unblocks it |
|
||||
|---|---|---|
|
||||
| GPU at the knee (5090 at the 1,300 MHz lock, 4090 at its knee) | NOT RUN | rented containers cannot lock clocks; the house rigs unreachable (BLOCKED for every lane today); unblocks on a house rig or a bare-metal rented card with clock control |
|
||||
| GPU wall power | NOT RUN | no wall meter on rented cards; board power only |
|
||||
| GPU cohort beyond 5090 and 4090 (5080, AMD, Intel, Apple) | NOT RUN | not in X1's order; X6's cohort |
|
||||
| Same work on OpenCL and Metal for the candidate's pack | NOT RUN | CUDA only today (the kit carries the OpenCL worker; Metal needs the mini) |
|
||||
| The specialist side a | BLOCKED on X5 | the adversary lane's sweep re-run on these traces (the x1cache command regenerates any program's stream) |
|
||||
| A live trace at 2^24 nonces per header (the 64 x 2^24 point the frozen census owes too) | NOT RUN | 50 to 100 core-hours per object |
|
||||
| Registry batch | NOT RUN, filed | `tools/ci/batches/1p5x-x1-20261009-01.json`, every cell NOT RUN, for the steward to record |
|
||||
|
||||
## 8. Reproduce
|
||||
|
||||
```
|
||||
# the tables above, from the row files
|
||||
python3 tools/1p5x/x1/summarise.py docs/analysis/class-v6/1p5x/x1/rows
|
||||
# (a) on a box: build branch 1p5x-x1-harness (cargo build --release --bin igneum-pow in igneum-pow), then
|
||||
tools/1p5x/x1/census.sh # paths inside name build-8's layout; run.sh writes the pid files
|
||||
# (b) and (c)
|
||||
tools/1p5x/x1/cache.sh <harness binary> <node1 state stream>
|
||||
# GPU, on a rented card with the worker and the four packs under /root/x1
|
||||
tools/1p5x/x1/gpu-pairs.sh <label>; python3 tools/1p5x/x1/gpu-rows.py /root/x1/runs/gpu-<label>
|
||||
```
|
||||
|
||||
Sources by sha: the candidate `a99de07cd` (branch `1p5x-x1-v7`), the harness `fc6bfaf25` (branch `1p5x-x1-harness`, over `b819c23a4`), the frozen control `1a938abe4`, the frozen census rows `docs/analysis/class-v6/rows/live-dataset-census-1a938abe4.md` and `chainseed-census-1a938abe4.md`, register row 18 `docs/analysis/class-v6/rows/pc1-l8off-ds2g-20261009.md`.
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# 2026-10-09T08:45:35Z host igneum-build-9 cmd: /srv/builds/x1/bin-h-5264b8152 x1cache --count 2 --warps 65536 --threads 32 --dataset-log2 30 --epoch-hex af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3 --day-hex 69676e65756d2d6461792ffa50000000000000 --era 0:edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07 --state /srv/builds/x1/packs/node1-state.igsd1 --class mx8+sh256x27+state+reg64c+fold+rw
|
||||
state stream /srv/builds/x1/packs/node1-state.igsd1: chain block 159357 af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3, root 1c583d352bb9c75a06dadb8d82d42836ebe8afa82d0b413be87bf921741f1526, 93 records, 93 leaves
|
||||
# x1cache: program 2a1d6caab4c24564 class mx8-erad810f22d+sh256x27+state+reg64c+fold+rw attempt 0 generator v6 day bytes:69676e65756d2d6461792ffa50000000000000 dataset 2^30 words (memory-hard, 67108864 items of 64 B) headers 2 from 0 (train 1, holdout 1) warps/header 65536 nonces/header 2097152 threads 32
|
||||
# load sites (instr order): 0:0 1:0 2:1 0:0 1:1 1:0 2:3 0:0 1:0 0:0 2:2 0:0 1:1 0:0 1:0 1:0
|
||||
# trace: 536870912 train reads, 536870912 holdout reads (256 reads per nonce), 59.0 s
|
||||
# (1) window model, hot-set mass at stored fraction f (exact): 0.05:0.0625 0.10:0.1250 0.15:0.1875 0.20:0.2500 0.25:0.3125 0.30:0.3625 0.35:0.4125 0.40:0.4625 0.45:0.5125 0.50:0.5625 0.55:0.6062 0.60:0.6500 0.65:0.6938 0.70:0.7375 0.75:0.7812 0.80:0.8250 0.85:0.8687 0.90:0.9125 0.95:0.9563 1.00:1.0000
|
||||
# (1) window model: 3 distinct probability levels; max/min item probability 1.4286; max item probability x items 1.2500
|
||||
policy f=0.25 f=0.50 f=0.75
|
||||
window-model static 0.3125 0.5625 0.7813
|
||||
train-ranked static (holdout) 0.2717 0.5240 0.7662
|
||||
oracle static (in-sample) 0.3775 0.6519 0.8640
|
||||
LRU (holdout stream, cold start) 0.2506 0.4883 0.7064
|
||||
# (3) holdout counts per item: mean 8.000; quantiles max 29 p0.1% 19 p1% 16 p10% 12 p25% 10 p50% 8 p75% 6 p90% 4 min 0; untouched items 37303; 177.2 s
|
||||
# end 2026-10-09T08:48:35Z rc 0
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# 2026-10-09T08:45:35Z host igneum-build-9 cmd: /srv/builds/x1/bin-h-5264b8152 x1cache --count 2 --warps 65536 --threads 32 --dataset-log2 30 --epoch-hex af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3 --day-hex 69676e65756d2d6461792ffa50000000000000 --era 0:edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07 --state /srv/builds/x1/packs/node1-state.igsd1 --class mx8+sh256x27+state+reg64c+fold+rw+flat
|
||||
state stream /srv/builds/x1/packs/node1-state.igsd1: chain block 159357 af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3, root 1c583d352bb9c75a06dadb8d82d42836ebe8afa82d0b413be87bf921741f1526, 93 records, 93 leaves
|
||||
# x1cache: program 34d0b421b17fe803 class mx8-erad810f22d+sh256x27+state+reg64c+fold+rw+flat attempt 1 generator v6 day bytes:69676e65756d2d6461792ffa50000000000000 dataset 2^30 words (memory-hard, 67108864 items of 64 B) headers 2 from 0 (train 1, holdout 1) warps/header 65536 nonces/header 2097152 threads 32
|
||||
# load sites (instr order): 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0
|
||||
# trace: 536870912 train reads, 536870912 holdout reads (256 reads per nonce), 49.5 s
|
||||
# (1) window model, hot-set mass at stored fraction f (exact): 0.05:0.0500 0.10:0.1000 0.15:0.1500 0.20:0.2000 0.25:0.2500 0.30:0.3000 0.35:0.3500 0.40:0.4000 0.45:0.4500 0.50:0.5000 0.55:0.5500 0.60:0.6000 0.65:0.6500 0.70:0.7000 0.75:0.7500 0.80:0.8000 0.85:0.8500 0.90:0.9000 0.95:0.9500 1.00:1.0000
|
||||
# (1) window model: 1 distinct probability levels; max/min item probability 1.0000; max item probability x items 1.0000
|
||||
policy f=0.25 f=0.50 f=0.75
|
||||
window-model static 0.2500 0.5000 0.7500
|
||||
train-ranked static (holdout) 0.2500 0.5000 0.7500
|
||||
oracle static (in-sample) 0.3657 0.6396 0.8563
|
||||
LRU (holdout stream, cold start) 0.2457 0.4808 0.6996
|
||||
# (3) holdout counts per item: mean 8.000; quantiles max 29 p0.1% 18 p1% 15 p10% 12 p25% 10 p50% 8 p75% 6 p90% 5 min 0; untouched items 22336; 171.0 s
|
||||
# end 2026-10-09T08:48:29Z rc 0
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# 2026-10-09T08:44:21Z host igneum-build-9 cmd: /srv/builds/x1/bin-h-5264b8152 x1cache --count 2 --warps 32 --threads 32 --dataset-log2 30 --epoch-hex af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3 --day-hex 69676e65756d2d6461792ffa50000000000000 --era 0:edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07 --state /srv/builds/x1/packs/node1-state.igsd1 --class mx8+sh256x27+state+reg64c+fold+rw
|
||||
state stream /srv/builds/x1/packs/node1-state.igsd1: chain block 159357 af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3, root 1c583d352bb9c75a06dadb8d82d42836ebe8afa82d0b413be87bf921741f1526, 93 records, 93 leaves
|
||||
# x1cache: program 2a1d6caab4c24564 class mx8-erad810f22d+sh256x27+state+reg64c+fold+rw attempt 0 generator v6 day bytes:69676e65756d2d6461792ffa50000000000000 dataset 2^30 words (memory-hard, 67108864 items of 64 B) headers 2 from 0 (train 1, holdout 1) warps/header 32 nonces/header 1024 threads 32
|
||||
# load sites (instr order): 0:0 1:0 2:1 0:0 1:1 1:0 2:3 0:0 1:0 0:0 2:2 0:0 1:1 0:0 1:0 1:0
|
||||
# trace: 262144 train reads, 262144 holdout reads (256 reads per nonce), 0.0 s
|
||||
# (1) window model, hot-set mass at stored fraction f (exact): 0.05:0.0625 0.10:0.1250 0.15:0.1875 0.20:0.2500 0.25:0.3125 0.30:0.3625 0.35:0.4125 0.40:0.4625 0.45:0.5125 0.50:0.5625 0.55:0.6062 0.60:0.6500 0.65:0.6938 0.70:0.7375 0.75:0.7812 0.80:0.8250 0.85:0.8687 0.90:0.9125 0.95:0.9563 1.00:1.0000
|
||||
# (1) window model: 3 distinct probability levels; max/min item probability 1.4286; max item probability x items 1.2500
|
||||
policy f=0.25 f=0.50 f=0.75
|
||||
window-model static 0.3125 0.5616 0.7812
|
||||
train-ranked static (holdout) 0.2504 0.5012 0.7499
|
||||
oracle static (in-sample) 1.0000 1.0000 1.0000
|
||||
LRU (holdout stream, cold start) 0.0020 0.0020 0.0020
|
||||
# (3) holdout counts per item: mean 0.004; quantiles max 2 p0.1% 1 p1% 0 p10% 0 p25% 0 p50% 0 p75% 0 p90% 0 min 0; untouched items 66847246; 49.9 s
|
||||
# end 2026-10-09T08:45:13Z rc 0
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# 2026-10-09T08:44:21Z host igneum-build-9 cmd: /srv/builds/x1/bin-h-5264b8152 x1cache --count 2 --warps 32 --threads 32 --dataset-log2 30 --epoch-hex af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3 --day-hex 69676e65756d2d6461792ffa50000000000000 --era 0:edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07 --state /srv/builds/x1/packs/node1-state.igsd1 --class mx8+sh256x27+state+reg64c+fold+rw+flat
|
||||
state stream /srv/builds/x1/packs/node1-state.igsd1: chain block 159357 af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3, root 1c583d352bb9c75a06dadb8d82d42836ebe8afa82d0b413be87bf921741f1526, 93 records, 93 leaves
|
||||
# x1cache: program 34d0b421b17fe803 class mx8-erad810f22d+sh256x27+state+reg64c+fold+rw+flat attempt 1 generator v6 day bytes:69676e65756d2d6461792ffa50000000000000 dataset 2^30 words (memory-hard, 67108864 items of 64 B) headers 2 from 0 (train 1, holdout 1) warps/header 32 nonces/header 1024 threads 32
|
||||
# load sites (instr order): 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0 0:0
|
||||
# trace: 262144 train reads, 262144 holdout reads (256 reads per nonce), 0.0 s
|
||||
# (1) window model, hot-set mass at stored fraction f (exact): 0.05:0.0500 0.10:0.1000 0.15:0.1500 0.20:0.2000 0.25:0.2500 0.30:0.3000 0.35:0.3500 0.40:0.4000 0.45:0.4500 0.50:0.5000 0.55:0.5500 0.60:0.6000 0.65:0.6500 0.70:0.7000 0.75:0.7500 0.80:0.8000 0.85:0.8500 0.90:0.9000 0.95:0.9500 1.00:1.0000
|
||||
# (1) window model: 1 distinct probability levels; max/min item probability 1.0000; max item probability x items 1.0000
|
||||
policy f=0.25 f=0.50 f=0.75
|
||||
window-model static 0.2483 0.4992 0.7484
|
||||
train-ranked static (holdout) 0.2483 0.4992 0.7484
|
||||
oracle static (in-sample) 1.0000 1.0000 1.0000
|
||||
LRU (holdout stream, cold start) 0.0019 0.0019 0.0019
|
||||
# (3) holdout counts per item: mean 0.004; quantiles max 3 p0.1% 1 p1% 0 p10% 0 p25% 0 p50% 0 p75% 0 p90% 0 min 0; untouched items 66847219; 50.8 s
|
||||
# end 2026-10-09T08:45:14Z rc 0
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
ctrl run1 64 accepted draws 135.954324113 s
|
||||
flat run1 64 accepted draws 135.904787836 s
|
||||
ctrl run2 64 accepted draws 138.311705871 s
|
||||
flat run2 64 accepted draws 136.222405217 s
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
# 2026-10-09T08:47:52Z host igneum-build-9 cmd: /srv/builds/x1/bin-a99de07cd export --out packs-out/x1-ctrl-ds28 --epoch-hex af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3 --day-hex 69676e65756d2d6461792ffa50000000000000 --program-class v6 --era-hex edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07 --state packs/node1-state.igsd1 --dataset-log2 28
|
||||
state stream packs/node1-state.igsd1: chain block 159357 af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3, root 1c583d352bb9c75a06dadb8d82d42836ebe8afa82d0b413be87bf921741f1526, 93 records, 93 leaves
|
||||
igneum-pow export packs-out/x1-ctrl-ds28
|
||||
seed "igneum-epoch/af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3/day/69676e65756d2d6461792ffa50000000000000", day "bytes:69676e65756d2d6461792ffa50000000000000", dataset 2^28 words (memory-hard), generator v6 attempt 0 program id 2a1d6caab4c24564, loads/hash 128; epoch built in 2529.0 ms
|
||||
op mix: load=16 add=10 shfl=8 rotl=5 rotr=5 xor=5 mad=4 mul=4 sub=4 mulhi=3; class mx8-erad810f22d+sh256x27+state+reg64c+fold+rw, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0]
|
||||
seed words 0x9c8caa3d 0x83537296 0xf21169a4 0x12d4d157 0x66e0ce33 0xcc049080 0x8579c352 0x4c6e4ac9
|
||||
wrote packs-out/x1-ctrl-ds28/program.json (57646 bytes)
|
||||
wrote packs-out/x1-ctrl-ds28/identity.json (995 bytes)
|
||||
wrote packs-out/x1-ctrl-ds28/vectors.json (6435 bytes)
|
||||
wrote packs-out/x1-ctrl-ds28/kernel.cu (86706 bytes)
|
||||
wrote packs-out/x1-ctrl-ds28/kernel.cl (94771 bytes)
|
||||
wrote packs-out/x1-ctrl-ds28/program.h (7694 bytes)
|
||||
wrote packs-out/x1-ctrl-ds28/vectors.h (5918 bytes)
|
||||
wrote packs-out/x1-ctrl-ds28/program.metal (82654 bytes)
|
||||
wrote packs-out/x1-ctrl-ds28/program_bound.metal (82806 bytes)
|
||||
wrote packs-out/x1-ctrl-ds28/kernel_bound.cu (84481 bytes)
|
||||
wrote packs-out/x1-ctrl-ds28/kernel_bound.cl (177158 bytes)
|
||||
wrote packs-out/x1-ctrl-ds28/memhard.h (7369 bytes)
|
||||
wrote packs-out/x1-ctrl-ds28/memhard.metal (7258 bytes)
|
||||
vector warp base 0: lane0 1622090e6446f887 lane31 a6a77c3e6146a18e
|
||||
vector warp base 4096: lane0 a419b22606fe741d lane31 5184fe27855a3c9a
|
||||
vector warp base 1000000: lane0 082d4fda6ab0e7da lane31 76cdecd145ff0270
|
||||
cache FNV-1a 64 448274a57f508cbc
|
||||
OVERALL: PASS (pack written)
|
||||
# end 2026-10-09T08:47:55Z rc 0
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
# 2026-10-09T08:47:55Z host igneum-build-9 cmd: /srv/builds/x1/bin-a99de07cd export --out packs-out/x1-ctrl-ds30 --epoch-hex af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3 --day-hex 69676e65756d2d6461792ffa50000000000000 --program-class v6 --era-hex edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07 --state packs/node1-state.igsd1 --dataset-log2 30
|
||||
state stream packs/node1-state.igsd1: chain block 159357 af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3, root 1c583d352bb9c75a06dadb8d82d42836ebe8afa82d0b413be87bf921741f1526, 93 records, 93 leaves
|
||||
igneum-pow export packs-out/x1-ctrl-ds30
|
||||
seed "igneum-epoch/af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3/day/69676e65756d2d6461792ffa50000000000000", day "bytes:69676e65756d2d6461792ffa50000000000000", dataset 2^30 words (memory-hard), generator v6 attempt 0 program id 2a1d6caab4c24564, loads/hash 128; epoch built in 2537.2 ms
|
||||
op mix: load=16 add=10 shfl=8 rotl=5 rotr=5 xor=5 mad=4 mul=4 sub=4 mulhi=3; class mx8-erad810f22d+sh256x27+state+reg64c+fold+rw, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0]
|
||||
seed words 0x9c8caa3d 0x83537296 0xf21169a4 0x12d4d157 0x66e0ce33 0xcc049080 0x8579c352 0x4c6e4ac9
|
||||
wrote packs-out/x1-ctrl-ds30/program.json (57646 bytes)
|
||||
wrote packs-out/x1-ctrl-ds30/identity.json (995 bytes)
|
||||
wrote packs-out/x1-ctrl-ds30/vectors.json (6458 bytes)
|
||||
wrote packs-out/x1-ctrl-ds30/kernel.cu (86706 bytes)
|
||||
wrote packs-out/x1-ctrl-ds30/kernel.cl (94771 bytes)
|
||||
wrote packs-out/x1-ctrl-ds30/program.h (7694 bytes)
|
||||
wrote packs-out/x1-ctrl-ds30/vectors.h (5942 bytes)
|
||||
wrote packs-out/x1-ctrl-ds30/program.metal (82654 bytes)
|
||||
wrote packs-out/x1-ctrl-ds30/program_bound.metal (82806 bytes)
|
||||
wrote packs-out/x1-ctrl-ds30/kernel_bound.cu (84481 bytes)
|
||||
wrote packs-out/x1-ctrl-ds30/kernel_bound.cl (177158 bytes)
|
||||
wrote packs-out/x1-ctrl-ds30/memhard.h (7369 bytes)
|
||||
wrote packs-out/x1-ctrl-ds30/memhard.metal (7258 bytes)
|
||||
vector warp base 0: lane0 66e49c7c7e8ff768 lane31 1be50893eafe01d6
|
||||
vector warp base 4096: lane0 5a1ed1e578bc6379 lane31 54238f6fabc4f5e5
|
||||
vector warp base 1000000: lane0 572e7cedf5cbd28c lane31 cd5923c994e9a1c7
|
||||
cache FNV-1a 64 448274a57f508cbc
|
||||
OVERALL: PASS (pack written)
|
||||
# end 2026-10-09T08:47:58Z rc 0
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
# 2026-10-09T08:47:58Z host igneum-build-9 cmd: /srv/builds/x1/bin-a99de07cd export --out packs-out/x1-flat-ds28 --epoch-hex af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3 --day-hex 69676e65756d2d6461792ffa50000000000000 --program-class v6 --era-hex edc4fa844da9dc98d37e965176f6558a31560e40502ab3ae5491b21aaaabfb07 --state packs/node1-state.igsd1 --dataset-log2 28 --flat
|
||||
state stream packs/node1-state.igsd1: chain block 159357 af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3, root 1c583d352bb9c75a06dadb8d82d42836ebe8afa82d0b413be87bf921741f1526, 93 records, 93 leaves
|
||||
igneum-pow export packs-out/x1-flat-ds28
|
||||
seed "igneum-epoch/af89be5ddbadb6f6b4aee28ac8f249713be5d4c12621e3cea7f83ceada3c66b3/day/69676e65756d2d6461792ffa50000000000000", day "bytes:69676e65756d2d6461792ffa50000000000000", dataset 2^28 words (memory-hard), generator v6 attempt 1 program id 34d0b421b17fe803, loads/hash 128; epoch built in 4566.2 ms
|
||||
op mix: load=16 xor=10 sub=8 add=6 mad=6 shfl=6 rotr=5 rotl=4 mul=3; class mx8-erad810f22d+sh256x27+state+reg64c+fold+rw+flat, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0]
|
||||
seed words 0xe60ed2a2 0x883319ea 0x6b6aa4f7 0xc4c7864e 0x5d7f9712 0x6027eee6 0x37f56d14 0x9555c104
|
||||
wrote packs-out/x1-flat-ds28/program.json (57656 bytes)
|
||||
wrote packs-out/x1-flat-ds28/identity.json (1000 bytes)
|
||||
wrote packs-out/x1-flat-ds28/vectors.json (6435 bytes)
|
||||
wrote packs-out/x1-flat-ds28/kernel.cu (85799 bytes)
|
||||
wrote packs-out/x1-flat-ds28/kernel.cl (93802 bytes)
|
||||
wrote packs-out/x1-flat-ds28/program.h (7688 bytes)
|
||||
wrote packs-out/x1-flat-ds28/vectors.h (5918 bytes)
|
||||
wrote packs-out/x1-flat-ds28/program.metal (81794 bytes)
|
||||
wrote packs-out/x1-flat-ds28/program_bound.metal (81946 bytes)
|
||||
wrote packs-out/x1-flat-ds28/kernel_bound.cu (83574 bytes)
|
||||
wrote packs-out/x1-flat-ds28/kernel_bound.cl (175220 bytes)
|
||||
wrote packs-out/x1-flat-ds28/memhard.h (7369 bytes)
|
||||
wrote packs-out/x1-flat-ds28/memhard.metal (7258 bytes)
|
||||
vector warp base 0: lane0 83fb47bf0f22157b lane31 4a4ef60478466c75
|
||||
vector warp base 4096: lane0 585856e5af7b2752 lane31 be7d4bfef849d1c6
|
||||
vector warp base 1000000: lane0 853e2ad04bc31a15 lane31 e0330b1c45fcb6a5
|
||||
cache FNV-1a 64 448274a57f508cbc
|
||||
OVERALL: PASS (pack written)
|
||||
# end 2026-10-09T08:48:03Z rc 0
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue