Merge remote-tracking branch 'origin/master' into site-ui-5
This commit is contained in:
commit
4a5759e188
8 changed files with 204 additions and 17 deletions
File diff suppressed because one or more lines are too long
41
infra/build-server/devnet3/devnet3.sh
Executable file
41
infra/build-server/devnet3/devnet3.sh
Executable file
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env bash
|
||||
# Devnet 3 (0.3.22) on igneum-build-1: the seed as a bare process and the hands' second instances. Dry run by default, --go executes.
|
||||
# infra/build-server/devnet3/devnet3.sh seed [--go] start the Devnet 3 seed (empty datadir, NET_FLAGS, the seed ports), read back
|
||||
# infra/build-server/devnet3/devnet3.sh node1 [--go] a second node1 instance on Devnet 3 (own datadir and ports), read back
|
||||
# infra/build-server/devnet3/devnet3.sh observer-node [--go] the same for the observer node (p2p on loopback, peers node1-dn3)
|
||||
# infra/build-server/devnet3/devnet3.sh status pids, ports, digest and genesis lines of the three
|
||||
# Read-back per instance: the first log line (igneumd/<version>-<commit>), the commit string count in the running binary, the
|
||||
# "Consensus params digest" line (the devnet-3 digest), the "[igneum-exec] genesis <hash> executed" line, the GRPC and P2P server
|
||||
# lines. No unit yet: the processes are detached (setsid nohup) under the build user like the Devnet 2 seed; units follow once the
|
||||
# network is green. The old devnet's node1 and observer-node are not touched.
|
||||
set -euo pipefail
|
||||
HERE=$(cd "$(dirname "$0")" && pwd); . "$HERE/devnet3.env"
|
||||
KEY="${IGNEUM_BUILD_KEY:-$HOME/.ssh/igneum_ed25519}"; HOST=$(head -1 "${IGNEUM_BUILD_HOST_FILE:-$HOME/.config/igneum/build-server}" | tr -d '[:space:]')
|
||||
SSH=(ssh -i "$KEY" -o BatchMode=yes -o ConnectTimeout=15 "$HOST")
|
||||
say() { echo "$(TZ=Europe/London date '+%H:%M:%S %Z') devnet3: $*" >&2; }
|
||||
mode="${1:-}"; shift || true; GO=0; [ "${1:-}" = --go ] && GO=1
|
||||
case "$NET_FLAGS" in *suffix=3*) say "NET_FLAGS is still the placeholder ($NET_FLAGS): the node lane's flag goes into devnet3.env first" ;; esac
|
||||
case "$IGNEUMD" in *PLACEHOLDER*) [ "$mode" = status ] || { say "IGNEUMD is the placeholder: the 0.3.22 candidate's build fills it"; [ "$GO" = 0 ] || exit 1; } ;; esac
|
||||
start_one() { # <name> <appdir> <p2p bind> <rpc> <json> <evm> <log> [extra args...]
|
||||
local name="$1" appdir="$2" p2p="$3" rpc="$4" json="$5" evm="$6" log="$7"; shift 7
|
||||
local args="$NET_FLAGS --appdir=$appdir --rpclisten=127.0.0.1:$rpc --rpclisten-json=127.0.0.1:$json --evm-rpclisten=127.0.0.1:$evm --listen=$p2p --nodnsseed --disable-upnp --nologfiles --yes $*"
|
||||
if [ "$GO" = 0 ]; then say "DRY $name: $IGNEUMD $args > $log"; return 0; fi
|
||||
"${SSH[@]}" bash -s -- "$name" "$IGNEUMD" "$appdir" "$log" "$args" <<'REMOTE'
|
||||
set -euo pipefail; name="$1"; bin="$2"; appdir="$3"; log="$4"; args="$5"
|
||||
[ -x "$bin" ] || { echo "no binary $bin"; exit 1; }
|
||||
mkdir -p "$appdir"; [ -z "$(ls -A "$appdir")" ] || echo "note: $appdir is not empty"
|
||||
cd "$(dirname "$log")"; setsid nohup "$bin" $args > "$log" 2>&1 < /dev/null & pid=$!; sleep 8
|
||||
echo "$name pid $pid alive=$(kill -0 $pid 2>/dev/null && echo yes || echo NO) commit-strings=$(grep -a -c "$(echo "$bin" | grep -oE '[0-9a-f]{8}' | tail -1)" /proc/$pid/exe 2>/dev/null || echo ?)"
|
||||
head -1 "$log" | cut -c1-120; grep -oE "Consensus params digest: [0-9a-f]+" "$log" | head -1
|
||||
for i in $(seq 1 20); do grep -qE "genesis [0-9a-f]{8,} executed|P2P Server starting" "$log" && break; sleep 1; done
|
||||
grep -oE "genesis [0-9a-f]+ executed" "$log" | head -1; grep -E "GRPC Server starting|P2P Server starting" "$log" | head -2 | cut -c1-100
|
||||
grep -iE "panic|error" "$log" | head -2 | cut -c1-140 || true
|
||||
REMOTE
|
||||
}
|
||||
case "$mode" in
|
||||
seed) start_one dn3-seed "$DN3_SEED_APPDIR" "0.0.0.0:$DN3_SEED_P2P" "$DN3_SEED_RPC" "$DN3_SEED_JSON" "$DN3_SEED_EVM" "$DN3_SEED_LOG" --maxinpeers=128 --outpeers=8 ;;
|
||||
node1) start_one node1-dn3 "$DN3_NODE1_APPDIR" "0.0.0.0:$DN3_NODE1_P2P" "$DN3_NODE1_RPC" "$DN3_NODE1_JSON" "$DN3_NODE1_EVM" /srv/hands/node1-dn3.log --enable-unsynced-mining --addpeer=127.0.0.1:$DN3_SEED_P2P --maxinpeers=128 --outpeers=8 ;;
|
||||
observer-node) start_one observer-dn3 "$DN3_OBS_APPDIR" "127.0.0.1:$DN3_OBS_P2P" "$DN3_OBS_RPC" "$DN3_OBS_JSON" "$DN3_OBS_EVM" /srv/hands/observer-dn3.log --addpeer=127.0.0.1:$DN3_NODE1_P2P --addpeer=127.0.0.1:$DN3_SEED_P2P ;;
|
||||
status) "${SSH[@]}" "for l in $DN3_SEED_LOG /srv/hands/node1-dn3.log /srv/hands/observer-dn3.log; do [ -f \$l ] && { echo \"== \$l\"; head -1 \$l | cut -c1-100; grep -oE 'Consensus params digest: [0-9a-f]+|genesis [0-9a-f]+ executed' \$l | head -2; tail -1 \$l | cut -c1-120; }; done; ss -ltn | awk '{print \$4}' | grep -E ':(26631|26651|26661|27630|26650|26660)\$' | tr '\n' ' '; echo" ;;
|
||||
*) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
|
||||
esac
|
||||
|
|
@ -55,7 +55,7 @@ SCCACHE_VERSION="${SCCACHE_VERSION:-}"
|
|||
NODE_MAJOR="${NODE_MAJOR:-22}"
|
||||
WORKTREES="${WORKTREES:-}"
|
||||
SLOTS_GIVEN="${SLOTS:-}"; SLOTS="${SLOTS:-2}" # 2 since main's ruling of 6 October 2026 (20:3x UK); remote-run.sh gives 90 jobs alone, 45 beside another
|
||||
P2P_PORTS="${P2P_PORTS:-26611 26811 26621}" # 26621: the Devnet 2 seed on build-1 (blocked by ufw until 7 Oct 2026 16:40 BST; the fleet lane found it closed from outside)
|
||||
P2P_PORTS="${P2P_PORTS:-26611 26811 26621 26631 26651}" # 26631 the Devnet 3 seed, 26651 the hands' Devnet 3 node1 (0.3.22, placeholders until the object lands) # 26621: the Devnet 2 seed on build-1 (blocked by ufw until 7 Oct 2026 16:40 BST; the fleet lane found it closed from outside)
|
||||
BOX_HOSTNAME="${BOX_HOSTNAME:-igneum-build-1}"
|
||||
case "$BOX_HOSTNAME" in *-2|*-4) [ -n "$SLOTS_GIVEN" ] || SLOTS=3 ;; esac # build-2 and build-4 (AX162-1, 96 threads): three slots (main, 7 Oct 2026; the bounded runs take one 32-core band each)
|
||||
WORKERS_HOST="${WORKERS_HOST:-build.igneum.network}" # the dashboard feed's HTTPS name (A record in deSEC, 6 Oct 2026)
|
||||
|
|
|
|||
16
site/lib/money-regen.mjs
Normal file
16
site/lib/money-regen.mjs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env node
|
||||
// Rewrites the four tables in site/money.js from app/igneum-app/ui/app.js (View.REGIONS, ISO_CURRENCY, EU_MEMBERS,
|
||||
// CURRENCY_CODES). Run after a change to the app's tables, then commit both; site/lib/money.test.mjs fails on drift.
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const m = { exports: {} }; new Function('module', readFileSync(join(here, '../../app/igneum-app/ui/app.js'), 'utf8'))(m);
|
||||
const V = m.exports.View, p = join(here, '../money.js');
|
||||
const regions = V.REGIONS.map(r => ({ code: r.code, name: r.name, cur: r.cur, price: r.price, source: r.source || '', restricted: !!r.restricted }));
|
||||
let s = readFileSync(p, 'utf8');
|
||||
s = s.replace(/ M\.REGIONS = .*\n/, ' M.REGIONS = ' + JSON.stringify(regions) + ';\n');
|
||||
s = s.replace(/ M\.ISO_CURRENCY = .*\n/, ' M.ISO_CURRENCY = ' + JSON.stringify(V.ISO_CURRENCY) + ';\n');
|
||||
s = s.replace(/ M\.EU_MEMBERS = .*\n/, ' M.EU_MEMBERS = ' + JSON.stringify(V.EU_MEMBERS) + ';\n');
|
||||
s = s.replace(/ M\.CURRENCY_CODES = .*\n/, ' M.CURRENCY_CODES = ' + JSON.stringify(V.CURRENCY_CODES) + ';\n');
|
||||
writeFileSync(p, s); console.log('site/money.js tables regenerated');
|
||||
48
site/lib/money.test.mjs
Normal file
48
site/lib/money.test.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// node --test site/lib/money.test.mjs (the pre-push gate runs it beside the explorer and emission tests)
|
||||
// site/money.js is the app's currency rules for the site: its tables must equal the app's, its formatter must place the
|
||||
// symbol as the locale does, and the picker's choice must follow navigator.language with a saved override winning.
|
||||
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 here = dirname(fileURLToPath(import.meta.url));
|
||||
const load = (p) => { const mod = { exports: {} }; new Function('module', 'window', readFileSync(p, 'utf8'))(mod, undefined); return mod.exports; };
|
||||
const M = load(join(here, '../money.js'));
|
||||
const V = load(join(here, '../../app/igneum-app/ui/app.js')).View;
|
||||
|
||||
test('the site tables equal the app tables (regenerate with node site/lib/money-regen.mjs)', (t) => {
|
||||
// the app side rides release-0.3.21 first; on a tree whose app has no ISO_CURRENCY yet the drift check waits for it
|
||||
if (!V.ISO_CURRENCY) { t.skip('the app on this tree carries no currency tables yet (currency-21 app side not merged here)'); return; }
|
||||
assert.deepEqual(M.REGIONS, V.REGIONS.map(r => ({ code: r.code, name: r.name, cur: r.cur, price: r.price, source: r.source || '', restricted: !!r.restricted })));
|
||||
assert.deepEqual(M.ISO_CURRENCY, V.ISO_CURRENCY); assert.deepEqual(M.EU_MEMBERS, V.EU_MEMBERS); assert.deepEqual(M.CURRENCY_CODES, V.CURRENCY_CODES);
|
||||
for (const c of ['GBP', 'EUR', 'JPY', 'CHF']) assert.deepEqual(M.minorOf(c), V.minorOf(c), c);
|
||||
});
|
||||
|
||||
test('the picker follows navigator.language, a saved choice wins, no row falls back to USD and asks', () => {
|
||||
assert.equal(M.currencyChoice('', 'de-DE').code, 'EUR'); assert.equal(M.currencyChoice('', 'en-GB').code, 'GBP'); assert.equal(M.currencyChoice('', 'fr-CA').code, 'CAD'); assert.equal(M.currencyChoice('', 'en-AU').code, 'AUD');
|
||||
assert.equal(M.currencyChoice('USD', 'de-DE').code, 'USD'); assert.equal(M.currencyChoice('USD', 'de-DE').source, 'override');
|
||||
const eo = M.currencyChoice('', 'eo'); assert.equal(eo.code, 'USD'); assert.equal(eo.prompt, true);
|
||||
assert.equal(M.currencyChoice('zzz', 'eo').prompt, true, 'an unknown saved code is ignored');
|
||||
});
|
||||
|
||||
test('the default tariff is the region row, else the first table row in the currency, and the cost line uses one formatter', () => {
|
||||
const de = M.tariffFor(M.currencyChoice('', 'de-DE')); assert.equal(de.price, 38.69); assert.match(de.source, /Eurostat/); assert.equal(de.region, 'Germany');
|
||||
const gbp = M.tariffFor(M.currencyChoice('GBP', 'de-DE')); assert.equal(gbp.price, 26.32); assert.equal(gbp.region, 'United Kingdom');
|
||||
const fr = M.tariffFor(M.currencyChoice('', 'fr-FR')); assert.equal(fr.price, 28.96, 'an EU member on the euro takes the EU average');
|
||||
assert.equal(M.tariffFor(M.currencyChoice('', 'sv-SE')), null, 'no table tariff in kronor: the picker says so');
|
||||
assert.equal(M.fmtMoney(M.costPerDay(227, 38.69), 'EUR', 'de-DE'), '2,11 €');
|
||||
assert.equal(M.fmtMoney(M.costPerDay(227, 26.32), 'GBP', 'en-GB'), '£1.43');
|
||||
assert.equal(M.fmtMoney(M.costPerDay(227, 17.7), 'USD', 'en-US'), '$0.96');
|
||||
assert.equal(M.costPerDay(null, 26.32), null, 'no watts, no cost');
|
||||
assert.equal(M.fmtMoney(1234.5, 'USD', 'en-US'), '$1,235'); assert.equal(M.fmtMoney(1.5, 'JPY', 'en-US'), '¥2');
|
||||
});
|
||||
|
||||
test('yourcard.js loads money.js and shows the electricity line with the currency switch; no hand-built money string', () => {
|
||||
const yc = readFileSync(join(here, '../yourcard.js'), 'utf8');
|
||||
assert.ok(yc.includes("'/money.js'"), 'loads the module when the page did not');
|
||||
assert.ok(yc.includes('window.IgneumMoney') && yc.includes('.fmtMoney(') && yc.includes('.currencyChoice(') && yc.includes('.tariffFor('), 'the module is the only money path');
|
||||
assert.ok(yc.includes('igneum-currency'), 'the saved choice');
|
||||
assert.ok(yc.includes('class="cur"'), 'the switch');
|
||||
assert.equal(/'£'|"£"/.test(yc), false);
|
||||
});
|
||||
61
site/money.js
Normal file
61
site/money.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/* Igneum money (currency-21, 7 October 2026): the site's copy of the miner app's currency rules, for the card picker
|
||||
(yourcard.js) and any page that shows a money figure. No IP lookup: navigator.language's region picks the currency and
|
||||
the default tariff; a saved choice (localStorage igneum-currency) wins; every figure goes through one Intl.NumberFormat
|
||||
call with the currency code and the narrow symbol, placed as the locale places it; the user's own tariff is the
|
||||
number, so no exchange rate exists anywhere. The tables below are generated from app/igneum-app/ui/app.js (View.REGIONS,
|
||||
View.ISO_CURRENCY, View.EU_MEMBERS, View.CURRENCY_CODES); site/lib/money.test.mjs fails when they drift, and
|
||||
`node site/lib/money-regen.mjs` rewrites them. A classic script (window.IgneumMoney) so yourcard.js can load it on
|
||||
pages that carry no module tag; under node it also exports through module.exports for the test. */
|
||||
(function (root) {
|
||||
var M = {};
|
||||
M.REGIONS = [{"code":"gb","name":"United Kingdom","cur":"GBP","price":26.32,"source":"Ofgem cap, 1 October to 31 December 2026","restricted":false},{"code":"ie","name":"Ireland","cur":"EUR","price":40.42,"source":"Eurostat, second half of 2025","restricted":false},{"code":"de","name":"Germany","cur":"EUR","price":38.69,"source":"Eurostat, second half of 2025","restricted":false},{"code":"be","name":"Belgium","cur":"EUR","price":34.99,"source":"Eurostat, second half of 2025","restricted":false},{"code":"eu","name":"European Union, other","cur":"EUR","price":28.96,"source":"Eurostat, second half of 2025, the EU average","restricted":false},{"code":"hu","name":"Hungary","cur":"EUR","price":10.82,"source":"Eurostat, second half of 2025, in euro cents","restricted":false},{"code":"mt","name":"Malta","cur":"EUR","price":12.82,"source":"Eurostat, second half of 2025","restricted":false},{"code":"bg","name":"Bulgaria","cur":"EUR","price":13.55,"source":"Eurostat, second half of 2025, in euro cents","restricted":false},{"code":"us","name":"United States","cur":"USD","price":17.7,"source":"EIA average, second half of 2025","restricted":false},{"code":"kz","name":"Kazakhstan","cur":"USD","price":4,"source":"about 4 c, a secondary source, approximate","restricted":false},{"code":"py","name":"Paraguay","cur":"USD","price":4.4,"source":"ANDE crypto tariff, 4.4 to 6 c; every crypto contract ends 31 December 2027","restricted":false},{"code":"ir","name":"Iran","cur":"USD","price":1,"source":"licensed mining tariff, about 1 c, a secondary source, approximate","restricted":false},{"code":"et","name":"Ethiopia","cur":"USD","price":2,"source":"2 to 5.3 c, a secondary source","restricted":false},{"code":"ng","name":"Nigeria","cur":"USD","price":4.8,"source":"hosted, 4.8 c, a secondary source","restricted":false},{"code":"ru-mow","name":"Russia, Moscow region","cur":"USD","price":0,"source":"","restricted":true},{"code":"ru-irk","name":"Russia, Irkutsk","cur":"USD","price":0,"source":"","restricted":true},{"code":"ru-bur","name":"Russia, Buryatia","cur":"USD","price":0,"source":"","restricted":true},{"code":"ru-zab","name":"Russia, Zabaikalsky","cur":"USD","price":0,"source":"","restricted":true},{"code":"ru","name":"Russia, other region","cur":"USD","price":0,"source":"","restricted":true},{"code":"cn","name":"China","cur":"USD","price":0,"source":"","restricted":true},{"code":"other","name":"Somewhere else","cur":"USD","price":0,"source":"","restricted":false}];
|
||||
M.ISO_CURRENCY = {"at":"EUR","be":"EUR","cy":"EUR","de":"EUR","ee":"EUR","es":"EUR","fi":"EUR","fr":"EUR","gr":"EUR","hr":"EUR","ie":"EUR","it":"EUR","lt":"EUR","lu":"EUR","lv":"EUR","mt":"EUR","nl":"EUR","pt":"EUR","si":"EUR","sk":"EUR","gb":"GBP","us":"USD","ca":"CAD","au":"AUD","nz":"NZD","ch":"CHF","li":"CHF","se":"SEK","no":"NOK","dk":"DKK","is":"ISK","pl":"PLN","cz":"CZK","hu":"HUF","ro":"RON","bg":"BGN","rs":"RSD","tr":"TRY","ua":"UAH","ge":"GEL","kz":"KZT","ru":"RUB","jp":"JPY","kr":"KRW","cn":"CNY","hk":"HKD","tw":"TWD","sg":"SGD","my":"MYR","th":"THB","vn":"VND","ph":"PHP","id":"IDR","in":"INR","pk":"PKR","bd":"BDT","ae":"AED","sa":"SAR","il":"ILS","ir":"IRR","za":"ZAR","ng":"NGN","ke":"KES","eg":"EGP","ma":"MAD","et":"ETB","br":"BRL","mx":"MXN","ar":"ARS","cl":"CLP","co":"COP","pe":"PEN","py":"PYG"};
|
||||
M.EU_MEMBERS = ["at","be","bg","hr","cy","cz","dk","ee","fi","fr","de","gr","hu","ie","it","lv","lt","lu","mt","nl","pl","pt","ro","sk","si","es","se"];
|
||||
M.CURRENCY_CODES = ["GBP","EUR","USD","CAD","AUD","AED","ARS","BDT","BGN","BRL","CHF","CLP","CNY","COP","CZK","DKK","EGP","ETB","GEL","HKD","HUF","IDR","ILS","INR","IRR","ISK","JPY","KES","KRW","KZT","MAD","MXN","MYR","NGN","NOK","NZD","PEN","PHP","PKR","PLN","PYG","RON","RSD","RUB","SAR","SEK","SGD","THB","TRY","TWD","UAH","VND","ZAR"];
|
||||
var MINOR = { GBP: ['pence', 'p'], EUR: ['euro cents', 'c'], USD: ['cents', 'c'], CAD: ['cents', 'c'], AUD: ['cents', 'c'], NZD: ['cents', 'c'], CHF: ['rappen', 'rp'], SEK: ['öre', 'öre'], NOK: ['øre', 'øre'], DKK: ['øre', 'øre'], PLN: ['grosze', 'gr'], CZK: ['haléře', 'h'], INR: ['paise', 'p'], BRL: ['centavos', 'c'], MXN: ['centavos', 'c'], ZAR: ['cents', 'c'], JPY: ['yen', '¥'], KRW: ['won', '₩'] };
|
||||
M.minorOf = function (cur) {
|
||||
cur = String(cur || '').toUpperCase(); var digits = 2;
|
||||
try { digits = new Intl.NumberFormat('en', { style: 'currency', currency: cur }).resolvedOptions().maximumFractionDigits; } catch (e) { }
|
||||
var mm = MINOR[cur] || (digits === 0 ? [cur.toLowerCase(), cur] : ['cents', 'c']);
|
||||
return { word: mm[0], abbr: mm[1], digits: digits };
|
||||
};
|
||||
M.fmtMoney = function (v, cur, loc) {
|
||||
if (v === null || v === undefined || isNaN(v)) return '';
|
||||
var d = v >= 100 ? 0 : M.minorOf(cur).digits;
|
||||
try { return new Intl.NumberFormat(loc || 'en-GB', { style: 'currency', currency: cur, currencyDisplay: 'narrowSymbol', minimumFractionDigits: d, maximumFractionDigits: d }).format(v).replace(/[\u00a0\u202f]/g, ' '); }
|
||||
catch (e) { return cur + ' ' + (d ? v.toFixed(d) : String(Math.round(v))); }
|
||||
};
|
||||
M.regionFromLocale = function (lang) {
|
||||
lang = String(lang || '').trim(); if (!lang) return ''; var reg = '';
|
||||
try { var l = new Intl.Locale(lang); reg = l.region || (l.maximize ? l.maximize().region : '') || ''; } catch (e) { var mm = /^[A-Za-z]{2,3}[-_]([A-Za-z]{2})\b/.exec(lang); reg = mm ? mm[1] : ''; }
|
||||
return /^[A-Za-z]{2}$/.test(reg) ? reg.toLowerCase() : '';
|
||||
};
|
||||
var synth = {};
|
||||
function regionName(code) { try { var n = new Intl.DisplayNames(['en'], { type: 'region' }).of(code.toUpperCase()); if (n && n !== code.toUpperCase()) return n; } catch (e) { } return code.toUpperCase(); }
|
||||
M.regionOf = function (code) {
|
||||
for (var i = 0; i < M.REGIONS.length; i++) if (M.REGIONS[i].code === code) return M.REGIONS[i];
|
||||
if (!/^[a-z]{2}$/.test(code || '')) return null;
|
||||
if (synth[code]) return synth[code];
|
||||
var cur = M.ISO_CURRENCY[code] || '', eu = M.EU_MEMBERS.indexOf(code) >= 0 && cur === 'EUR', avg = M.regionOf('eu');
|
||||
return (synth[code] = { code: code, name: regionName(code), cur: cur || 'USD', unknown: !cur, price: eu && avg ? avg.price : 0, source: eu && avg ? avg.source : '' });
|
||||
};
|
||||
// the currency in force: a saved choice, else the locale's region, else USD with the prompt
|
||||
M.currencyChoice = function (saved, lang) {
|
||||
var ov = String(saved || '').toUpperCase(), loc = M.regionFromLocale(lang);
|
||||
if (ov && M.CURRENCY_CODES.indexOf(ov) >= 0) return { code: ov, source: 'override', region: loc, prompt: false };
|
||||
var r = loc ? M.regionOf(loc) : null;
|
||||
if (r && !r.unknown) return { code: r.cur, source: 'locale', region: loc, prompt: false };
|
||||
return { code: 'USD', source: 'fallback', region: loc, prompt: true };
|
||||
};
|
||||
// the default tariff for a choice: the locale's region row when it carries the currency, else the first table row in that currency
|
||||
M.tariffFor = function (choice) {
|
||||
var r = choice.region ? M.regionOf(choice.region) : null;
|
||||
if (r && r.cur === choice.code && r.price > 0) return { price: r.price, source: r.source, region: r.name, cur: choice.code };
|
||||
for (var i = 0; i < M.REGIONS.length; i++) if (M.REGIONS[i].cur === choice.code && M.REGIONS[i].price > 0 && !M.REGIONS[i].restricted) return { price: M.REGIONS[i].price, source: M.REGIONS[i].source, region: M.REGIONS[i].name, cur: choice.code };
|
||||
return null;
|
||||
};
|
||||
// watts over a day at a tariff in hundredths of the unit per kWh: the unit amount
|
||||
M.costPerDay = function (watts, pricePerKwh) { return watts > 0 && pricePerKwh > 0 ? watts * 24 / 1000 * pricePerKwh / 100 : null; };
|
||||
root.IgneumMoney = M;
|
||||
if (typeof module === 'object' && module && module.exports) module.exports = M;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
|
|
@ -1,21 +1,42 @@
|
|||
/* The "your card" line: a measured card, and how often it finds a block at the network's hash rate right now. One block a
|
||||
second is the target, so the wait is network hashes per second over the card's rate. The rates are the bench table's
|
||||
measured numbers (site/miners.html); nothing here is a promise. Reads /api/live every 15 s. */
|
||||
/* The "your card" line: a measured card, how often it finds a block at the network's hash rate right now, and what its
|
||||
electricity costs a day in your currency. One block a second is the target, so the wait is network hashes per second
|
||||
over the card's rate. The rates are the bench table's measured numbers (site/miners.html); the watts are the tuned
|
||||
draws from the same runs (app.html's Ember table: 5090 226.8 W, 4070 75.6 W; the 9070 XT 202 W from docs/bench-log.md,
|
||||
6 October 2026; Apple silicon reports no draw). The currency follows navigator.language (no IP lookup); the switch saves
|
||||
a choice in localStorage igneum-currency; the tariff is the public table's default for that region or currency, so the
|
||||
cost is arithmetic from a typical price, never a promise (currency-21, 7 October 2026). Reads /api/live every 15 s. */
|
||||
(function () {
|
||||
var CARDS = [['NVIDIA RTX 5090', 127.7], ['NVIDIA RTX 5060 Ti', 30.9], ['NVIDIA RTX 4070', 28.8], ['Apple M5 Max', 26.7], ['AMD RX 9070 XT', 17.8]];
|
||||
var CARDS = [['NVIDIA RTX 5090', 127.7, 226.8], ['NVIDIA RTX 4070', 28.8, 75.6], ['Apple M5 Max', 26.7, null], ['AMD RX 9070 XT', 17.8, 202]];
|
||||
var els = document.querySelectorAll('[data-yourcard]'); if (!els.length) return;
|
||||
var net = null, chosen = (function () { try { return localStorage.getItem('igneum-card'); } catch (e) { return null; } })();
|
||||
var savedCur = (function () { try { return localStorage.getItem('igneum-currency') || ''; } catch (e) { return ''; } })();
|
||||
function dur(sec) { if (!(sec > 0)) return 'pending'; if (sec < 90) return Math.round(sec) + ' s'; if (sec < 5400) return Math.round(sec / 60) + ' min'; if (sec < 172800) { var h = Math.floor(sec / 3600), m = Math.round((sec % 3600) / 60); return h + ' h' + (m ? ' ' + m + ' min' : ''); } return Math.round(sec / 86400) + ' days'; }
|
||||
function render() { els.forEach(function (el) { var r = parseFloat(el.querySelector('select').value) * 1e6, eta = el.querySelector('.eta'); eta.textContent = net && r ? 'every ' + dur(net / r) : 'pending'; }); }
|
||||
// the vendor badge beside the picker: the five mini marks the build stamped as JSON (site/lib/marks.mjs), chosen by the card's name
|
||||
var marks = {}; try { var mj = document.getElementById('gpu-marks'); if (mj) marks = JSON.parse(mj.textContent); } catch (e) { marks = {}; }
|
||||
function vendorOf(n) { return /nvidia|geforce|\bRTX\b|\bGTX\b/i.test(n) ? 'nvidia' : /\bamd\b|radeon/i.test(n) ? 'amd' : /intel|\barc\b/i.test(n) ? 'intel' : /apple|\bM\d (max|pro|ultra)\b/i.test(n) ? 'apple' : 'gpu'; }
|
||||
function badge(el) { var sel = el.querySelector('select'), b = el.querySelector('.badge-slot'); if (!sel || !b) return; var name = sel.options[sel.selectedIndex].text.split(' \u00b7 ')[0]; b.innerHTML = marks[vendorOf(name)] || ''; }
|
||||
els.forEach(function (el) {
|
||||
el.innerHTML = '<span class="badge-slot"></span><b>Your card</b><select aria-label="Your card">' + CARDS.map(function (c) { return '<option value="' + c[1] + '"' + (chosen === c[0] ? ' selected' : '') + '>' + c[0] + ' \u00b7 ' + c[1] + ' MH/s</option>'; }).join('') + '</select><span>finds a block about <span class="eta">pending</span> at the network\u2019s hash rate right now.</span>';
|
||||
el.querySelector('select').addEventListener('change', function (e) { var name = e.target.options[e.target.selectedIndex].text.split(' \u00b7 ')[0]; try { localStorage.setItem('igneum-card', name); } catch (x) {} document.querySelectorAll('[data-yourcard] select').forEach(function (s) { s.value = e.target.value; }); els.forEach(badge); render(); });
|
||||
badge(el);
|
||||
});
|
||||
function costWords(watts) {
|
||||
var M = window.IgneumMoney; if (!M) return '';
|
||||
var ch = M.currencyChoice(savedCur, navigator.language), t = M.tariffFor(ch);
|
||||
if (watts === null) return 'no power reading on this card';
|
||||
if (!t) return 'no typical ' + ch.code + ' tariff in the table; the app uses the price you type';
|
||||
var c = M.costPerDay(watts, t.price);
|
||||
return 'about ' + M.fmtMoney(c, ch.code, navigator.language) + ' a day in electricity at ' + t.price + ' ' + M.minorOf(ch.code).abbr + '/kWh (' + t.region + ', typical)';
|
||||
}
|
||||
function render() {
|
||||
els.forEach(function (el) {
|
||||
var sel = el.querySelector('select.card'), r = parseFloat(sel.value) * 1e6, eta = el.querySelector('.eta'), cost = el.querySelector('.cost');
|
||||
eta.textContent = net && r ? 'every ' + dur(net / r) : 'pending';
|
||||
var row = CARDS[sel.selectedIndex]; if (cost) cost.textContent = costWords(row ? row[2] : null);
|
||||
});
|
||||
}
|
||||
function init() {
|
||||
var M = window.IgneumMoney, ch = M ? M.currencyChoice(savedCur, navigator.language) : null;
|
||||
els.forEach(function (el) {
|
||||
el.innerHTML = '<b>Your card</b><select class="card" aria-label="Your card">' + CARDS.map(function (c) { return '<option value="' + c[1] + '"' + (chosen === c[0] ? ' selected' : '') + '>' + c[0] + ' \u00b7 ' + c[1] + ' MH/s</option>'; }).join('') + '</select><span>finds a block about <span class="eta">pending</span> at the network\u2019s hash rate right now.</span>' +
|
||||
(M ? '<span class="cost" style="display:block;margin-top:4px"></span><select class="cur" aria-label="Currency" style="margin-left:8px">' + M.CURRENCY_CODES.map(function (c) { return '<option value="' + c + '"' + (ch.code === c ? ' selected' : '') + '>' + c + '</option>'; }).join('') + '</select>' : '');
|
||||
el.querySelector('select.card').addEventListener('change', function (e) { var name = e.target.options[e.target.selectedIndex].text.split(' \u00b7 ')[0]; try { localStorage.setItem('igneum-card', name); } catch (x) {} document.querySelectorAll('[data-yourcard] select.card').forEach(function (s) { s.value = e.target.value; }); render(); });
|
||||
var cur = el.querySelector('select.cur'); if (cur) cur.addEventListener('change', function (e) { savedCur = e.target.value; try { localStorage.setItem('igneum-currency', savedCur); } catch (x) {} document.querySelectorAll('[data-yourcard] select.cur').forEach(function (s) { s.value = savedCur; }); render(); });
|
||||
});
|
||||
render();
|
||||
}
|
||||
if (window.IgneumMoney) init(); else { var sc = document.createElement('script'); sc.src = '/money.js'; sc.onload = init; sc.onerror = init; document.head.appendChild(sc); }
|
||||
function poll() { fetch('/api/live?window=30', { cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (d) { if (d && d.ok && d.state && d.state.hashes_per_second_estimate > 0) { net = d.state.hashes_per_second_estimate; render(); } }).catch(function () {}); }
|
||||
poll(); setInterval(function () { if (!document.hidden) poll(); }, 15000);
|
||||
window.IgneumYourCard = { update: function (h) { if (h > 0) { net = h; render(); } } };
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ tree_checks() {
|
|||
run "chain scene: the live feed contract (the recorded reply validates; a rewritten miner, a float now, a stray key refused)" node --test tools/scene/feed-contract.test.mjs
|
||||
run "chain scene parity: one recorded feed through the home fold, /live and the app's Inspect view on build-2, three frames each pixel-equal apart from the app's own-key overlay (a changed token fails first; skipped with no box and no Playwright)" bash tools/scene/parity-remote.sh
|
||||
run "no text overlaps: every served page at 390 to 1600 px, light and dark, the hero at each step (self-test first; IGNEUM_OVERLAP_APPS=1 adds the miner and wallet UIs)" overlap_sweep
|
||||
run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs
|
||||
run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/lib/money.test.mjs site/api/public-stats.test.mjs
|
||||
run "ship tool self-test" node tools/ship-app.mjs --self-test
|
||||
run "relay unit tests" node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs
|
||||
run "miner app notice strip and update card tests" node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs
|
||||
|
|
|
|||
Loading…
Reference in a new issue